Compare commits

...

15 Commits

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,9 @@
node_modules
dist
.data
.config
.git
.vscode
*.md
design/
*.log

@ -0,0 +1,32 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server.js ./
COPY --from=builder /app/src ./src
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["node", "server.js"]

File diff suppressed because one or more lines are too long

@ -0,0 +1,464 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能行程规划系统 - 交互设计 v2</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;background:#f5f7fa;color:#1a1a2e;line-height:1.6}
.top-bar{background:#fff;padding:12px 32px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 1px 3px rgba(0,0,0,0.06);position:sticky;top:0;z-index:100}
.top-bar .brand{display:flex;align-items:center;gap:10px;font-size:17px;font-weight:700;color:#2d3436}
.top-bar .brand span.icon{font-size:24px}
.top-bar .steps{display:flex;align-items:center;gap:6px}
.top-bar .step{display:flex;align-items:center;gap:6px;font-size:13px;color:#b2bec3}
.top-bar .step.active{color:#6c5ce7;font-weight:600}
.top-bar .step .num{width:22px;height:22px;border-radius:50%;background:#dfe6e9;color:#636e72;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600}
.top-bar .step.active .num{background:#6c5ce7;color:#fff}
.top-bar .step .line{width:28px;height:2px;background:#dfe6e9}
.top-bar .step.active .line{background:#6c5ce7}
.top-bar .step.done .num{background:#00b894;color:#fff}
.top-bar .step.done .line{background:#00b894}
.top-bar .back-btn{background:none;border:none;color:#636e72;cursor:pointer;font-size:14px;padding:6px 12px;border-radius:6px}
.top-bar .back-btn:hover{background:#f5f7fa}
.container{max-width:1100px;margin:0 auto;padding:24px 32px}
.page{display:none}.page.active{display:block}
.home-header{text-align:center;padding:48px 0 36px}
.home-header h1{font-size:28px;font-weight:800;color:#2d3436;margin-bottom:8px}
.home-header p{color:#636e72;font-size:15px}
.mode-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:28px}
.mode-card{background:#fff;border-radius:14px;padding:28px 22px;cursor:pointer;transition:all 0.25s;border:2px solid transparent;box-shadow:0 2px 8px rgba(0,0,0,0.04)}
.mode-card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(108,92,231,0.12);border-color:#6c5ce7}
.mode-card .mc-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-size:26px;margin-bottom:14px}
.mode-card:nth-child(1) .mc-icon{background:#e8e4ff}
.mode-card:nth-child(2) .mc-icon{background:#fff3e0}
.mode-card:nth-child(3) .mc-icon{background:#e0f7fa}
.mode-card h3{font-size:17px;font-weight:700;margin-bottom:6px;color:#2d3436}
.mode-card p{font-size:13px;color:#636e72;margin-bottom:14px;min-height:36px}
.mode-card .mc-tags{display:flex;flex-wrap:wrap;gap:6px}
.mode-card .mc-tag{font-size:11px;padding:3px 10px;border-radius:12px;background:#f0f0f5;color:#636e72}
.form-wrap{max-width:580px;margin:0 auto;background:#fff;border-radius:16px;padding:32px;box-shadow:0 4px 20px rgba(0,0,0,0.05)}
.form-wrap h2{font-size:20px;font-weight:700;margin-bottom:24px;color:#2d3436;text-align:center}
.fg{margin-bottom:18px}
.fg label{display:block;font-size:13px;font-weight:600;color:#2d3436;margin-bottom:6px}
.fg input,.fg select,.fg textarea{width:100%;padding:10px 14px;border:1.5px solid #dfe6e9;border-radius:10px;font-size:14px;outline:none;transition:border 0.2s;font-family:inherit}
.fg input:focus,.fg select:focus,.fg textarea:focus{border-color:#6c5ce7}
.fg textarea{min-height:120px;resize:vertical}
.fg .hint{font-size:12px;color:#b2bec3;margin-top:4px}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.btn-primary{width:100%;padding:13px;background:linear-gradient(135deg,#6c5ce7,#a29bfe);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;transition:all 0.2s}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 14px rgba(108,92,231,0.35)}
.btn-primary:disabled{opacity:0.5;cursor:not-allowed;transform:none}
.load-panel{max-width:580px;margin:20px auto 0;background:#fff;border-radius:14px;box-shadow:0 2px 12px rgba(0,0,0,0.05);overflow:hidden}
.load-panel .lp-head{padding:14px 20px;background:#f8f9fa;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px}
.load-panel .lp-head .spin{width:18px;height:18px;border:2.5px solid #dfe6e9;border-top-color:#6c5ce7;border-radius:50%;animation:spin 0.7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.load-panel .lp-body{padding:16px 20px;max-height:200px;overflow-y:auto}
.load-panel .lp-step{display:flex;gap:8px;font-size:13px;color:#636e72;margin-bottom:8px}
.load-panel .lp-step .ck{color:#00b894;font-weight:700}
.plan-list{max-width:760px;margin:0 auto}
.plan-list .pl-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px}
.plan-list .pl-head h2{font-size:20px;font-weight:700}
.plan-card{background:#fff;border-radius:14px;padding:22px;margin-bottom:14px;box-shadow:0 2px 8px rgba(0,0,0,0.04);border:2px solid #eee;transition:all 0.2s;cursor:pointer}
.plan-card:hover{border-color:#6c5ce7;box-shadow:0 4px 16px rgba(108,92,231,0.1)}
.plan-card .pc-top{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.plan-card .pc-badge{font-size:11px;padding:3px 10px;border-radius:6px;font-weight:700}
.plan-card .pc-badge.a{background:#fff3e0;color:#e17055}
.plan-card .pc-badge.b{background:#e0f2fe;color:#0984e3}
.plan-card .pc-badge.c{background:#d1fae5;color:#00b894}
.plan-card .pc-title{font-size:17px;font-weight:700;color:#2d3436}
.plan-card .pc-meta{display:flex;gap:16px;font-size:13px;color:#636e72;margin-bottom:10px}
.plan-card .pc-route{background:#f8f9fa;padding:8px 12px;border-radius:8px;font-size:13px;color:#636e72;margin-bottom:10px}
.plan-card .pc-tags{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
.plan-card .pc-tag{font-size:12px;padding:3px 10px;border-radius:6px;background:#f0f0f5;color:#636e72}
.plan-card .pc-actions{text-align:right}
.plan-card .pc-btn{padding:8px 22px;background:linear-gradient(135deg,#6c5ce7,#a29bfe);color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
.chat-wrap{max-width:800px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 130px)}
.chat-msgs{flex:1;overflow-y:auto;padding:20px 0;display:flex;flex-direction:column;gap:18px}
.chat-bubble{display:flex;gap:10px;max-width:75%}
.chat-bubble.bot{align-self:flex-start}
.chat-bubble.user{align-self:flex-end;flex-direction:row-reverse}
.chat-bubble .cb-av{width:34px;height:34px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.chat-bubble.bot .cb-av{background:#e8e4ff}
.chat-bubble.user .cb-av{background:#d1fae5}
.chat-bubble .cb-body{padding:12px 16px;border-radius:14px;font-size:14px;line-height:1.6}
.chat-bubble.bot .cb-body{background:#fff;color:#2d3436;border-bottom-left-radius:4px;box-shadow:0 2px 6px rgba(0,0,0,0.04)}
.chat-bubble.user .cb-body{background:linear-gradient(135deg,#6c5ce7,#a29bfe);color:#fff;border-bottom-right-radius:4px}
.chat-input-bar{padding:14px 0;display:flex;gap:10px}
.chat-input-bar input{flex:1;padding:12px 18px;border:1.5px solid #dfe6e9;border-radius:24px;font-size:14px;outline:none}
.chat-input-bar input:focus{border-color:#6c5ce7}
.chat-input-bar button{width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#6c5ce7,#a29bfe);color:#fff;border:none;cursor:pointer;font-size:16px}
.chat-cards{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px}
.chat-cards .cc{background:#fff;border-radius:12px;padding:16px;border:1.5px solid #eee;cursor:pointer;transition:all 0.2s}
.chat-cards .cc:hover{border-color:#6c5ce7}
.chat-cards .cc .cc-badge{font-size:11px;padding:2px 8px;border-radius:4px;background:#fff3e0;color:#e17055;font-weight:600}
.chat-cards .cc h4{font-size:14px;margin:6px 0 4px}
.chat-cards .cc .cc-meta{font-size:12px;color:#636e72}
.chat-cards .cc .cc-link{margin-top:10px;font-size:12px;color:#6c5ce7;font-weight:600}
.wb-wrap{display:grid;grid-template-columns:220px 300px 1fr;gap:14px;height:calc(100vh - 120px)}
.wb-left{background:#2d3436;border-radius:12px;padding:16px;overflow-y:auto}
.wb-left .wl-title{color:#fff;font-size:13px;font-weight:600;margin-bottom:14px}
.wb-left .wl-item{display:flex;gap:10px;padding:8px 6px;cursor:pointer;color:rgba(255,255,255,0.5);transition:all 0.2s;border-radius:6px}
.wb-left .wl-item:hover,.wb-left .wl-item.active{color:#fff;background:rgba(108,92,231,0.3)}
.wb-left .wl-dot{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,0.3);margin-top:7px}
.wb-left .wl-item.active .wl-dot{background:#6c5ce7}
.wb-left .wl-name{font-size:13px}
.wb-left .wl-day{font-size:11px;opacity:0.5}
.wb-left .wl-nav{margin-top:16px;display:flex;gap:8px}
.wb-left .wl-nav button{flex:1;padding:8px;border-radius:8px;border:none;font-size:12px;cursor:pointer}
.wb-left .wl-nav .prev{background:rgba(255,255,255,0.1);color:#fff}
.wb-left .wl-nav .next{background:#6c5ce7;color:#fff}
.wb-mid{background:#fff;border-radius:12px;overflow-y:auto}
.wb-mid .wm-hero{height:140px;background:linear-gradient(135deg,#6c5ce7,#a29bfe);position:relative}
.wb-mid .wm-hero .wm-ov{position:absolute;bottom:0;left:0;right:0;padding:14px 16px;background:linear-gradient(transparent,rgba(0,0,0,0.5));color:#fff}
.wb-mid .wm-hero .wm-ov h3{font-size:16px}
.wb-mid .wm-hero .wm-ov p{font-size:12px;opacity:0.8}
.wb-mid .wm-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;padding:14px}
.wb-mid .wm-stat{text-align:center;padding:10px;background:#f8f9fa;border-radius:10px}
.wb-mid .wm-stat .ws-val{font-size:15px;font-weight:700;color:#6c5ce7}
.wb-mid .wm-stat .ws-lbl{font-size:11px;color:#636e72}
.wb-mid .wm-sec{padding:14px 16px;border-top:1px solid #f0f0f0}
.wb-mid .wm-sec h4{font-size:13px;margin-bottom:8px}
.wb-mid .wm-si{display:flex;gap:8px;font-size:12px;margin-bottom:6px}
.wb-mid .wm-si .si-time{color:#6c5ce7;font-weight:600;min-width:32px}
.wb-mid .wm-foods,.wb-mid .wm-spots{display:flex;flex-wrap:wrap;gap:5px}
.wb-mid .wm-foods span,.wb-mid .wm-spots span{font-size:11px;padding:3px 8px;border-radius:5px;background:#f0f0f5;color:#636e72}
.wb-mid .wm-tips{background:#fff8e1;border-left:3px solid #f39c12;padding:10px 12px;border-radius:0 8px 8px 0;font-size:12px;color:#856404}
.wb-right{background:#dfe6e9;border-radius:12px;display:flex;align-items:center;justify-content:center;color:#636e72;font-size:14px;position:relative;overflow:hidden}
.wb-right .wb-map-demo{width:100%;height:100%;background:#e8e8e8}
.wb-top{grid-column:1/-1;background:#fff;padding:12px 20px;border-radius:10px;display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.wb-top .wb-plan-selector{display:flex;gap:8px}
.wb-top .wb-plan-btn{padding:6px 16px;border-radius:6px;font-size:12px;cursor:pointer;border:1.5px solid #dfe6e9;background:#fff;color:#636e72;transition:all 0.2s}
.wb-top .wb-plan-btn.active{background:#6c5ce7;color:#fff;border-color:#6c5ce7}
.wb-top .wb-back-btn{padding:8px 16px;border-radius:6px;font-size:13px;cursor:pointer;border:1.5px solid #6c5ce7;background:#fff;color:#6c5ce7;transition:all 0.2s}
.wb-top .wb-back-btn:hover{background:#6c5ce7;color:#fff}
.divider{margin:50px 0 24px;text-align:center;padding-top:30px;border-top:2px dashed #dfe6e9}
.divider h2{font-size:20px;color:#2d3436;margin-bottom:6px}
.divider p{color:#636e72;font-size:13px}
.btn-sm{padding:6px 16px;border-radius:6px;font-size:12px;cursor:pointer;border:none;font-weight:500}
.btn-sm.outline{background:#fff;border:1.5px solid #dfe6e9;color:#636e72}
.btn-sm.outline:hover{border-color:#6c5ce7;color:#6c5ce7}
</style>
</head>
<body>
<nav class="top-bar">
<div class="brand"><span class="icon">🗺️</span> 智能行程规划</div>
<div class="steps" id="navSteps">
<div class="step active" id="s1"><div class="num">1</div><span>选择方式</span><div class="line"></div></div>
<div class="step" id="s2"><div class="num">2</div><span>输入需求</span><div class="line"></div></div>
<div class="step" id="s3"><div class="num">3</div><span>方案选择</span><div class="line"></div></div>
<div class="step" id="s4"><div class="num">4</div><span>工作台</span></div>
</div>
<button class="back-btn" onclick="goHome()">← 首页</button>
</nav>
<div class="container">
<!-- 页面1首页 -->
<div class="page active" id="p-home">
<div class="home-header">
<h1>智能行程规划</h1>
<p>选择一种方式开始你的旅行规划</p>
</div>
<div class="mode-grid">
<div class="mode-card" onclick="goTo('p-quick');setStep(2)">
<div class="mc-icon"></div>
<h3>快速规划</h3>
<p>输入目的地和天数,一键生成多个旅行方案供你选择</p>
<div class="mc-tags"><span class="mc-tag">快速生成</span><span class="mc-tag">多方案对比</span><span class="mc-tag">即输即得</span></div>
</div>
<div class="mode-card" onclick="goTo('p-chat');setStep(2)">
<div class="mc-icon">💬</div>
<h3>聊天问答式</h3>
<p>通过对话交流,我逐步了解你的需求,为你量身定制完美行程</p>
<div class="mc-tags"><span class="mc-tag">多轮对话</span><span class="mc-tag">逐步收集信息</span><span class="mc-tag">个性化规划</span></div>
</div>
<div class="mode-card" onclick="goTo('p-custom');setStep(2)">
<div class="mc-icon">✏️</div>
<h3>自定义行程</h3>
<p>输入你已规划的行程AI 评估合理性并给出优化建议</p>
<div class="mc-tags"><span class="mc-tag">行程评估</span><span class="mc-tag">智能建议</span><span class="mc-tag">优化方案</span></div>
</div>
</div>
</div>
<!-- 页面2快速规划表单 -->
<div class="page" id="p-quick">
<div class="home-header"><h1>⚡ 快速规划</h1><p>输入目的地和天数,立即生成多个旅行方案</p></div>
<div class="form-wrap">
<div class="form-row">
<div class="fg"><label>目的地</label><input placeholder="如:云南、贵州、四川" value="四川"></div>
<div class="fg"><label>出行天数</label><select><option>2天</option><option selected>3天</option><option>5天</option><option>7天</option></select></div>
</div>
<div class="form-row">
<div class="fg"><label>出发日期</label><input type="date" value="2026-07-02"></div>
<div class="fg"><label>出行方式</label><select><option selected>自驾</option><option>公共交通</option><option>步行/骑行</option></select></div>
</div>
<div class="fg"><label>其他需求(选填)</label><input placeholder="如:带老人小孩、预算范围、必去景点等"></div>
<button class="btn-primary" onclick="simulateLoading()">立即生成方案</button>
</div>
<div class="load-panel" id="quick-load" style="display:none">
<div class="lp-head"><div class="spin"></div><span>AI 正在规划中...</span><span style="margin-left:auto;font-size:12px;color:#b2bec3">收起</span></div>
<div class="lp-body">
<div class="lp-step"><span class="ck"></span> 分析用户需求目的地四川3天自驾</div>
<div class="lp-step"><span class="ck"></span> 检索当地景点与路线数据</div>
<div class="lp-step"><span class="ck"></span> 计算交通时间与距离</div>
<div class="lp-step"><span class="ck"></span> 生成 3 个差异化方案</div>
<div class="lp-step" style="opacity:0.5">⏳ 正在优化方案详情...</div>
</div>
</div>
</div>
<!-- 页面3快速规划方案列表 -->
<div class="page" id="p-quick-plans">
<div class="plan-list">
<div class="pl-head"><h2>📋 为你生成了 3 个方案</h2><button class="btn-sm outline" onclick="goTo('p-quick')">🔄 重新生成</button></div>
<div class="plan-card" onclick="goToWorkbench('方案A')">
<div class="pc-top"><span class="pc-badge a">方案A</span><span class="pc-title">经典熊猫与古堰休闲游</span></div>
<div class="pc-meta"><span> 3天</span><span>🚗 ~650km</span><span>💰 ¥3200</span></div>
<div class="pc-route">成都→都江堰→青城山→成都</div>
<div class="pc-tags"><span class="pc-tag">世界遗产都江堰水利工程</span><span class="pc-tag">青城山道教清幽</span><span class="pc-tag">成都熊猫基地</span></div>
<div class="pc-actions"><button class="pc-btn">选择此方案 →</button></div>
</div>
<div class="plan-card" onclick="goToWorkbench('方案B')">
<div class="pc-top"><span class="pc-badge b">方案B</span><span class="pc-title">川西深度生态游</span></div>
<div class="pc-meta"><span>📅 3天</span><span>🚗 ~1200km</span><span>💰 ¥4500</span></div>
<div class="pc-route">成都→九寨沟→黄龙→若尔盖→成都</div>
<div class="pc-tags"><span class="pc-tag">九寨沟彩林与瀑布群</span><span class="pc-tag">黄龙钙华池与雪山</span><span class="pc-tag">若尔盖湿地草原星空</span></div>
<div class="pc-actions"><button class="pc-btn">选择此方案 →</button></div>
</div>
<div class="plan-card" onclick="goToWorkbench('方案C')">
<div class="pc-top"><span class="pc-badge c">方案C</span><span class="pc-title">川南秘境探索线</span></div>
<div class="pc-meta"><span>📅 3天</span><span>🚗 ~850km</span><span>💰 ¥2800</span></div>
<div class="pc-route">成都→宜宾→蜀南竹海→兴文石海→成都</div>
<div class="pc-tags"><span class="pc-tag">蜀南竹海翡翠长廊</span><span class="pc-tag">兴文天坑溶洞奇观</span><span class="pc-tag">宜宾长江第一城文化</span></div>
<div class="pc-actions"><button class="pc-btn">选择此方案 →</button></div>
</div>
</div>
</div>
<!-- 页面4聊天问答 -->
<div class="page" id="p-chat">
<div class="chat-wrap">
<div class="chat-msgs">
<div class="chat-bubble bot">
<div class="cb-av">🤖</div>
<div class="cb-body">你好!我是你的行程规划助手。请告诉我:<br>• 想去哪里?(单个地点或多个地点组合)<br>• 几月出发?几天行程?<br>• 交通方式?(自驾/公共交通/步行)<br>• 有什么特殊需求?(带老人/小孩/特定景点)<br><br><em style="color:#b2bec3">示例9月去云南5天自驾带老人</em></div>
</div>
<div class="chat-bubble user">
<div class="cb-av">👤</div>
<div class="cb-body">9月去四川大概5天时间自驾</div>
</div>
<div class="chat-bubble bot">
<div class="cb-av">🤖</div>
<div class="cb-body">已为你生成 2 个方案,请查看下方卡片。<br><br><strong>【方案A】九寨黄龙秋韵线</strong><br>路线:成都-绵阳-九寨沟-黄龙-松潘-成都<br>5天 · 1150km · ¥3500<br><br><strong>【方案B】四姑娘山丹巴环线</strong><br>路线:成都-四姑娘山-丹巴甲居藏寨-康定-成都<br>5天 · 980km · ¥3200</div>
</div>
</div>
<div class="chat-cards">
<div class="cc" onclick="goToWorkbench('方案A')">
<span class="cc-badge">方案A</span>
<h4>九寨黄龙秋韵线</h4>
<div class="cc-meta">🚗 ~1150km &nbsp; ⏱ 18h &nbsp; 📅 5天 &nbsp; 💰 ¥3500</div>
<div class="cc-link">选择此方案进入工作台 →</div>
</div>
<div class="cc" onclick="goToWorkbench('方案B')">
<span class="cc-badge" style="background:#e0f2fe;color:#0984e3">方案B</span>
<h4>四姑娘山丹巴环线</h4>
<div class="cc-meta">🚗 ~980km &nbsp; ⏱ 16h &nbsp; 📅 5天 &nbsp; 💰 ¥3200</div>
<div class="cc-link">选择此方案进入工作台 →</div>
</div>
</div>
<div class="chat-input-bar">
<input placeholder="例如9月去云南5天自驾">
<button>发送</button>
</div>
</div>
</div>
<!-- 页面5自定义行程 -->
<div class="page" id="p-custom">
<div class="home-header">
<h1>✏️ 自定义行程</h1>
<p>输入你已规划的行程AI 评估合理性并给出优化建议</p>
</div>
<div class="form-wrap">
<div class="fg">
<label>行程安排</label>
<textarea class="form-textarea" placeholder="例如:&#10;Day 1: 昆明 -> 大理,上午出发,下午逛大理古城&#10;Day 2: 大理 -> 丽江,上午游览洱海,下午前往丽江&#10;Day 3: 丽江一日游,游览玉龙雪山、束河古镇&#10;Day 4: 丽江返程&#10;&#10;也可以自由描述AI 会自动解析..."></textarea>
</div>
<div class="form-row">
<div class="fg">
<label>出行方式</label>
<select>
<option selected>自驾</option>
<option>公共交通</option>
<option>步行/骑行</option>
</select>
</div>
<div class="fg">
<label>同行人员</label>
<select>
<option selected>独自</option>
<option>情侣/夫妻</option>
<option>家庭(带老人小孩)</option>
<option>朋友结伴</option>
</select>
</div>
</div>
<button class="btn-primary" onclick="alert('AI 正在评估行程合理性...')">AI 评估行程</button>
</div>
</div>
<!-- 页面6工作台沉浸式编辑 -->
<div class="page" id="p-workbench">
<div class="wb-wrap">
<!-- 工作台顶部:方案选择 + 返回按钮 -->
<div class="wb-top">
<div class="wb-plan-selector">
<button class="wb-plan-btn active" onclick="switchPlan(this,'A')">方案A</button>
<button class="wb-plan-btn" onclick="switchPlan(this,'B')">方案B</button>
<button class="wb-plan-btn" onclick="switchPlan(this,'C')">方案C</button>
</div>
<button class="wb-back-btn" onclick="goBackFromWorkbench()">← 返回方案列表</button>
</div>
<!-- 左侧:时间线 -->
<div class="wb-left">
<div class="wl-title">📋 行程时间线</div>
<div class="wl-item active">
<div class="wl-dot"></div>
<div>
<div class="wl-name">成都市区</div>
<div class="wl-day">Day 1 · 0km</div>
</div>
</div>
<div class="wl-item">
<div class="wl-dot"></div>
<div>
<div class="wl-name">都江堰景区</div>
<div class="wl-day">Day 2 · 60km</div>
</div>
</div>
<div class="wl-item">
<div class="wl-dot"></div>
<div>
<div class="wl-name">青城山</div>
<div class="wl-day">Day 3 · 25km</div>
</div>
</div>
<div class="wl-nav">
<button class="prev">← 上一站</button>
<button class="next">下一站 →</button>
</div>
</div>
<!-- 中间:详情面板 -->
<div class="wb-mid">
<div class="wm-hero">
<div class="wm-ov">
<h3>成都市区</h3>
<p>天府之国核心,美食与慢生活之都</p>
</div>
</div>
<div class="wm-stats">
<div class="wm-stat">
<div class="ws-val">0km</div>
<div class="ws-lbl">行驶里程</div>
</div>
<div class="wm-stat">
<div class="ws-val"></div>
<div class="ws-lbl">驾驶时间</div>
</div>
<div class="wm-stat">
<div class="ws-val">Day 1</div>
<div class="ws-lbl">第几天</div>
</div>
</div>
<div class="wm-sec">
<h4> 行程安排</h4>
<div class="wm-si"><span class="si-time">上午</span><span>参观大熊猫繁育研究基地</span></div>
<div class="wm-si"><span class="si-time">下午</span><span>逛宽窄巷子、武侯祠,品尝火锅</span></div>
</div>
<div class="wm-sec">
<h4>🍽️ 美食推荐</h4>
<div class="wm-foods">
<span>担担面</span><span>龙抄手</span><span>麻辣火锅</span>
</div>
</div>
<div class="wm-sec">
<h4>🏨 住宿推荐</h4>
<div style="font-size:13px;color:#636e72">春熙路商圈精品酒店</div>
</div>
<div class="wm-sec">
<h4>💡 注意事项</h4>
<div class="wm-tips">市区早晚高峰拥堵,建议错峰出行</div>
</div>
</div>
<div class="wb-right">
<div class="wb-map-demo">🗺️ 地图交互区域</div>
</div>
</div>
</div>
<!-- 底部说明 -->
<div class="divider">
<h2>交互设计说明</h2>
<p>本原型展示了从需求输入到方案生成的完整流程,风格统一、简洁。</p>
</div>
<script>
function goTo(pageId) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const target = document.getElementById(pageId);
if (target) target.classList.add('active');
}
function goHome() {
goTo('p-home');
setStep(1);
}
function setStep(n) {
const steps = document.querySelectorAll('.top-bar .step');
steps.forEach((s, i) => {
s.classList.remove('active', 'done');
if (i + 1 === n) s.classList.add('active');
else if (i + 1 < n) s.classList.add('done');
});
if (n === 4) {
steps[0].classList.add('done');
steps[1].classList.add('done');
steps[2].classList.add('done');
steps[3].classList.add('active');
}
}
function simulateLoading() {
const loadPanel = document.getElementById('quick-load');
loadPanel.style.display = 'block';
setTimeout(() => {
loadPanel.style.display = 'none';
goTo('p-quick-plans');
setStep(3);
}, 2000);
}
function goToWorkbench(scheme) {
goTo('p-workbench');
setStep(4);
}
function goBackFromWorkbench() {
goTo('p-quick-plans');
setStep(3);
}
function switchPlan(btn, scheme) {
document.querySelectorAll('.wb-plan-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
console.log("切换到: " + scheme);
}
</script>
</body>
</html>

@ -0,0 +1,15 @@
version: '3.8'
services:
trip-planner:
build: .
container_name: trip-planner
ports:
- "3001:3001"
volumes:
- E:\docker_workspace\trip-planner_datas:/app/.data
environment:
- NODE_ENV=production
- PORT=3001
- DATA_DIR=/app/.data
restart: unless-stopped

558
package-lock.json generated

@ -8,11 +8,15 @@
"name": "trip-planner",
"version": "0.0.0",
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"cors": "^2.8.6",
"express": "^4.22.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"sortablejs": "^1.15.7",
"uuid": "^14.0.0",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
},
@ -987,6 +991,58 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@ -996,6 +1052,17 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@ -1020,6 +1087,36 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -1058,6 +1155,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -1141,6 +1244,30 @@
"ms": "2.0.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -1160,6 +1287,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1174,6 +1310,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -1189,6 +1334,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@ -1291,6 +1445,15 @@
"node": ">= 0.6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@ -1337,6 +1500,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -1373,6 +1542,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -1434,6 +1609,12 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -1508,12 +1689,38 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -1535,12 +1742,103 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -1619,12 +1917,39 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -1649,6 +1974,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -1658,6 +1989,18 @@
"node": ">= 0.6"
}
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
@ -1691,6 +2034,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1767,6 +2119,33 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1780,6 +2159,16 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@ -1819,6 +2208,35 @@
"node": ">= 0.8"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@ -1896,6 +2314,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@ -2019,6 +2449,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
@ -2052,6 +2527,24 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
@ -2064,6 +2557,34 @@
"node": ">=16"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -2073,6 +2594,18 @@
"node": ">=0.6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -2095,6 +2628,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@ -2104,6 +2643,19 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -2214,6 +2766,12 @@
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

@ -9,11 +9,15 @@
"preview": "vite preview"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"cors": "^2.8.6",
"express": "^4.22.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"sortablejs": "^1.15.7",
"uuid": "^14.0.0",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
},

@ -5,10 +5,16 @@ import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const CONFIG_DIR = path.join(__dirname, '.config')
// Support Docker environment variable for data directory
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '.data')
const CONFIG_DIR = process.env.CONFIG_DIR || path.join(__dirname, '.config')
const CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json')
// Ensure config directory exists
// Ensure directories exist
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true })
}
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true })
}
@ -44,16 +50,29 @@ function saveConfig(config) {
}
import cors from 'cors'
import { db } from './src/db/database.js'
// 导入路由
import authRoutes from './src/routes/auth.js'
import plansRoutes from './src/routes/plans.js'
import statsRoutes from './src/routes/stats.js'
import sharesRoutes from './src/routes/shares.js'
const app = express()
app.use(cors({
origin: 'http://localhost:5173',
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type'],
methods: ['GET', 'POST', 'OPTIONS', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['Content-Type']
}))
app.use(express.json())
// 注册路由
app.use('/api/auth', authRoutes)
app.use('/api/plans', plansRoutes)
app.use('/api/stats', statsRoutes)
app.use('/api/shares', sharesRoutes)
// API: Get config
app.get('/api/config', (req, res) => {
const config = loadConfig()
@ -177,6 +196,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) {
@ -190,6 +212,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) {
@ -197,7 +237,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) {
@ -240,6 +326,11 @@ async function start() {
// Production: serve static files
app.use(express.static(path.join(__dirname, 'dist')))
// SPA catch-all: serve index.html for all non-API routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'))
})
const port = process.env.PORT || 3000
app.listen(port, () => {
console.log(`Server running on port ${port}`)

@ -1,9 +1,40 @@
<template>
<RouterView />
<AuthModal v-model:visible="showAuthModal" @success="handleAuthSuccess" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useAuthStore } from './stores/auth'
import AuthModal from './components/AuthModal.vue'
const authStore = useAuthStore()
const showAuthModal = ref(false)
onMounted(async () => {
//
await authStore.init()
//
checkGuestDataExpiry()
})
function handleAuthSuccess() {
//
if (authStore.guestId) {
if (confirm('检测到您有游客期间的行程数据,是否迁移到当前账户?')) {
authStore.migrateGuestData()
}
}
}
function checkGuestDataExpiry() {
if (authStore.isGuestDataExpiring) {
alert('提示您的游客数据将在2小时内过期建议登录账户以保存数据。')
showAuthModal.value = true
}
}
</script>
<style>

@ -0,0 +1,294 @@
<template>
<Teleport to="body">
<div v-if="visible" class="auth-modal-overlay" @click.self="close">
<div class="auth-modal">
<button class="auth-close" @click="close"></button>
<div class="auth-header">
<h2>{{ isLogin ? '登录' : '注册' }}</h2>
<p>{{ isLogin ? '欢迎回来,继续你的行程规划' : '创建账户,保存你的行程' }}</p>
</div>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label>用户名</label>
<input v-model="form.username" type="text" placeholder="请输入用户名" required :disabled="authStore.loading" />
</div>
<div v-if="!isLogin" class="form-group">
<label>邮箱可选</label>
<input v-model="form.email" type="email" placeholder="请输入邮箱" :disabled="authStore.loading" />
</div>
<div class="form-group">
<label>密码</label>
<input v-model="form.password" type="password" placeholder="至少6个字符" required :disabled="authStore.loading" minlength="6" />
</div>
<div v-if="authStore.error" class="form-error">{{ authStore.error }}</div>
<button type="submit" class="auth-submit" :disabled="authStore.loading">
{{ authStore.loading ? '处理中...' : (isLogin ? '登录' : '注册') }}
</button>
</form>
<div class="auth-switch">
{{ isLogin ? '还没有账户?' : '已有账户?' }}
<a href="#" @click.prevent="toggleMode">{{ isLogin ? '立即注册' : '立即登录' }}</a>
</div>
<div class="auth-guest">
<p>或者</p>
<button class="guest-btn" @click="continueAsGuest">
以游客身份继续
</button>
<p class="guest-note">游客数据将保留24小时</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useAuthStore } from '../stores/auth'
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'update:visible', 'success'])
function close() {
emit('update:visible', false)
emit('close')
authStore.error = null
form.username = ''
form.email = ''
form.password = ''
}
const authStore = useAuthStore()
const isLogin = ref(true)
const form = reactive({
username: '',
email: '',
password: ''
})
function toggleMode() {
isLogin.value = !isLogin.value
authStore.error = null
form.password = ''
}
async function handleSubmit() {
const result = isLogin.value
? await authStore.login(form.username, form.password)
: await authStore.register(form.username, form.email, form.password)
if (result?.success) {
emit('success')
close()
}
}
function continueAsGuest() {
authStore.initGuestSession()
close()
}
</script>
<style scoped>
.auth-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.auth-modal {
background: #fff;
border-radius: 16px;
padding: 32px;
width: 100%;
max-width: 400px;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.auth-close {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #636e72;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.auth-close:hover {
background: #f5f7fa;
color: #2d3436;
}
.auth-header {
text-align: center;
margin-bottom: 24px;
}
.auth-header h2 {
font-size: 24px;
color: #2d3436;
margin-bottom: 8px;
}
.auth-header p {
font-size: 14px;
color: #636e72;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: #2d3436;
}
.form-group input {
padding: 12px 14px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #6c5ce7;
}
.form-group input:disabled {
background: #f5f7fa;
cursor: not-allowed;
}
.form-error {
background: #ffe0e0;
color: #d63031;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.auth-submit {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border: none;
padding: 14px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.auth-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-switch {
text-align: center;
margin-top: 16px;
font-size: 14px;
color: #636e72;
}
.auth-switch a {
color: #6c5ce7;
font-weight: 600;
text-decoration: none;
}
.auth-switch a:hover {
text-decoration: underline;
}
.auth-guest {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
text-align: center;
}
.auth-guest p {
font-size: 13px;
color: #b2bec3;
margin-bottom: 10px;
}
.guest-btn {
background: #f5f7fa;
border: 2px solid #e9ecef;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: #636e72;
cursor: pointer;
transition: all 0.2s;
}
.guest-btn:hover {
border-color: #6c5ce7;
color: #6c5ce7;
}
.guest-note {
font-size: 11px !important;
margin-top: 8px !important;
margin-bottom: 0 !important;
}
</style>

@ -1,118 +1,128 @@
<template>
<div class="chat-interface">
<div class="chat-header">
<h1>🧭 智能行程规划</h1>
<p>告诉我你的旅行想法我来为你规划完美行程</p>
</div>
<!-- Chat messages -->
<div class="chat-messages" ref="messagesContainer">
<div class="message bot">
<div class="avatar">🤖</div>
<div class="bubble">
你好我是你的行程规划助手请告诉我<br/>
想去哪里单个地点或多个地点组合<br/>
几月出发几天行程<br/>
交通方式自驾/公共交通/步行<br/>
有什么特殊需求带老人/小孩/特定景点<br/>
<br/>
<span class="example">示例9月去云南5自驾带老人</span><br/>
<span class="example">示例成都-九寨沟-重庆7天自驾游</span>
<div class="chat-msgs" ref="messagesContainer">
<!-- Initial greeting -->
<div class="chat-bubble bot">
<div class="cb-av"></div>
<div class="cb-body">
你好我是你的行程规划助手 🗺<br>
接下来我会和你聊一聊了解一下你的旅行想法<br>
比如想去哪里什么时候出发玩几天<br><br>
<em style="color:#b2bec3">不用一次说全我们慢慢聊</em>
</div>
</div>
<div v-for="(msg, i) in messages" :key="i" class="message" :class="msg.role">
<div class="avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
<div class="bubble">
<!-- Show thinking process for bot messages -->
<div v-if="msg.thinking" class="thinking-process" :class="{ expanded: msg.thinkingExpanded }">
<div class="thinking-header" @click="msg.thinkingExpanded = !msg.thinkingExpanded">
<span class="thinking-icon">🧠</span>
<span>AI 思考过程</span>
<span class="thinking-toggle">{{ msg.thinkingExpanded ? '收起' : '展开' }}</span>
</div>
<div class="thinking-content">{{ msg.thinking }}</div>
</div>
<!-- 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">
<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>
<!-- Loading indicator -->
<div v-if="isGenerating" class="message bot">
<div class="avatar">🤖</div>
<div class="bubble thinking">
<div v-if="isGenerating" class="chat-bubble bot">
<div class="cb-av bot-av"></div>
<div class="cb-body thinking">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
{{ thinkingStatus }}
</div>
</div>
</div>
<!-- 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>
<h4>{{ scheme.name }}</h4>
<div class="cc-meta"> ~{{ scheme.totalKm }}km &nbsp; {{ scheme.totalDriveTime }}h &nbsp; {{ scheme.days }} &nbsp; {{ scheme.budget }}</div>
<div class="cc-link">选择此方案进入工作台</div>
</div>
</div>
<!-- Input area -->
<div class="chat-input-area">
<div class="chat-input-bar">
<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">
{{ isGenerating ? '生成中...' : '发送' }}
</button>
<button @click="sendMessage" :disabled="!inputText.trim() || isGenerating || phase === 'generating' || schemes.length > 0"></button>
</div>
<!-- Scheme cards (appear after AI response) -->
<transition name="slide-up">
<div v-if="schemes.length > 0" class="scheme-cards">
<h3>📋 为你生成了 {{ schemes.length }} 个方案</h3>
<div class="cards-grid">
<div
v-for="(scheme, i) in schemes"
:key="i"
class="scheme-card"
@click="selectScheme(i)"
>
<div class="card-header">
<span class="card-badge">{{ '方案' + String.fromCharCode(65 + i) }}</span>
<h4>{{ scheme.name }}</h4>
</div>
<div class="card-stats">
<span>🚗 ~{{ scheme.totalKm }}km</span>
<span> {{ scheme.totalDriveTime }}h</span>
<span>📅 {{ scheme.days }}</span>
<span>💰 {{ scheme.budget }}</span>
</div>
<div class="card-highlights">
<span v-for="(h, j) in scheme.highlights" :key="j" class="highlight-tag">{{ h }}</span>
</div>
<div class="card-route">
<span>🗺 {{ scheme.route }}</span>
</div>
<div class="card-action">选择此方案进入工作台 </div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { 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) {
@ -120,69 +130,153 @@ const scrollToBottom = async () => {
}
}
function getSchemeLabel(i) {
return String.fromCharCode(65 + (i % 26))
}
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 chatWithAI(userMsg, (streamContent) => {
// Parse streaming content for thinking
thinkingContent = streamContent
// Update thinking status based on progress
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 = []
// 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 正在思考并搜索目的地信息...'
thinkingStatus.value = 'AI 正在思考并搜索目的地信息...'
} else if (streamContent.includes('schemes')) {
thinkingStatus.value = '📋 正在生成旅行方案...'
thinkingStatus.value = '正在生成旅行方案...'
} else if (streamContent.includes('points')) {
thinkingStatus.value = '📍 正在规划详细行程...'
thinkingStatus.value = '正在规划详细行程...'
}
}, {
userId: authStore.user?.id,
guestId: authStore.guestId,
eventType: 'generate'
})
isGenerating.value = false
// Add bot response message with thinking process
const botMsg = {
role: 'bot',
content: `已为你生成 ${result.schemes.length} 个方案,请查看下方卡片。\n\n${result.schemes.map((s, i) => `【方案${String.fromCharCode(65 + i)}${s.name}\n路线${s.route}\n${s.days}天 · ${s.totalKm}km · ${s.budget}`).join('\n\n')}`,
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)
// Set schemes for card display
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')
? '请先访问 /settings 配置你的 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
// Load the selected scheme into the itinerary store
store.loadFromAI(scheme)
store.setPhase('workbench')
}
@ -192,117 +286,159 @@ const selectScheme = (index) => {
.chat-interface {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
position: relative;
}
.chat-header {
text-align: center;
padding: 30px 20px;
background: linear-gradient(135deg, #1b4332, #2d6a4f);
color: #fff;
height: calc(100vh - 130px);
max-width: 800px;
margin: 0 auto;
padding: 24px 32px;
}
.chat-header h1 { margin: 0 0 8px; font-size: 24px; }
.chat-header p { margin: 0; opacity: 0.8; font-size: 14px; }
.chat-messages {
.chat-msgs {
flex: 1;
overflow-y: auto;
padding: 20px;
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 16px;
gap: 18px;
}
.message {
.chat-bubble {
display: flex;
gap: 12px;
max-width: 85%;
gap: 10px;
max-width: 75%;
}
.message.bot { align-self: flex-start; }
.message.user { align-self: flex-end; flex-direction: row-reverse; }
.chat-bubble.bot { align-self: flex-start; }
.chat-bubble.user { align-self: flex-end; flex-direction: row-reverse; }
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
.cb-av {
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
background: #e8f5e9;
font-size: 16px;
flex-shrink: 0;
}
.message.user .avatar { background: #e3f2fd; }
.bot-av {
background: #e8e4ff;
}
.bot-av::before {
content: '🤖';
}
.bubble {
.user-av {
background: #d1fae5;
}
.user-av::before {
content: '👤';
}
.cb-body {
padding: 12px 16px;
border-radius: 16px;
border-radius: 14px;
font-size: 14px;
line-height: 1.6;
}
.message.bot .bubble {
.chat-bubble.bot .cb-body {
background: #fff;
color: #333;
color: #2d3436;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
}
.message.user .bubble {
background: #2a9d8f;
.chat-bubble.user .cb-body {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border-bottom-right-radius: 4px;
}
.example {
opacity: 0.8;
font-size: 13px;
.message-content {
white-space: pre-wrap;
}
/* 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 process */
.thinking-process {
margin-bottom: 10px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
.confirm-header {
font-size: 16px;
font-weight: 600;
color: #2d3436;
margin-bottom: 16px;
}
.thinking-header {
.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;
}
.confirm-row:last-of-type {
border-bottom: none;
}
.thinking-header:hover { background: #f0f0f0; }
.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;
}
.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;
}
.thinking-process:not(.expanded) .thinking-content {
display: none;
.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 */
@ -315,10 +451,6 @@ const selectScheme = (index) => {
color: #856404;
}
.message-content {
white-space: pre-wrap;
}
/* Loading */
.thinking {
display: flex;
@ -330,7 +462,7 @@ const selectScheme = (index) => {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2a9d8f;
background: #6c5ce7;
animation: pulse 1.4s infinite;
}
@ -342,156 +474,126 @@ const selectScheme = (index) => {
40% { opacity: 1; transform: scale(1); }
}
/* Input */
.chat-input-area {
display: flex;
gap: 8px;
padding: 16px 20px;
background: #fff;
border-top: 1px solid #eee;
}
.chat-input-area input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 24px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.chat-input-area input:focus { border-color: #2a9d8f; }
.chat-input-area input:disabled { background: #f5f5f5; cursor: not-allowed; }
.chat-input-area button {
padding: 12px 24px;
background: #2a9d8f;
color: #fff;
border: none;
border-radius: 24px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.chat-input-area button:hover:not(:disabled) { background: #21867a; }
.chat-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Scheme cards */
.scheme-cards {
position: absolute;
bottom: 80px;
left: 20px;
right: 20px;
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.1);
max-height: 60vh;
overflow-y: auto;
}
.scheme-cards h3 {
margin: 0 0 16px;
font-size: 16px;
color: #1b4332;
}
.cards-grid {
.chat-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px 0;
}
.scheme-card {
border: 2px solid #e0e0e0;
.cc {
background: #fff;
border-radius: 12px;
padding: 16px;
border: 1.5px solid #eee;
cursor: pointer;
transition: all 0.2s;
}
.scheme-card:hover {
border-color: #2a9d8f;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(42,157,143,0.2);
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.cc:hover {
border-color: #6c5ce7;
}
.card-badge {
background: #f4a261;
color: #1b4332;
.cc-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: #fff3e0;
color: #e17055;
font-weight: 600;
}
.card-header h4 {
margin: 0;
.cc-badge.badge-b {
background: #e0f2fe;
color: #0984e3;
}
.cc h4 {
font-size: 14px;
color: #333;
margin: 6px 0 4px;
color: #2d3436;
}
.card-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 8px;
.cc-meta {
font-size: 12px;
color: #636e72;
}
.cc-link {
margin-top: 10px;
font-size: 12px;
color: #666;
color: #6c5ce7;
font-weight: 600;
}
.card-highlights {
/* Input */
.chat-input-bar {
padding: 14px 0;
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
gap: 10px;
}
.highlight-tag {
background: #e8f5e9;
color: #2a9d8f;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
.chat-input-bar input {
flex: 1;
padding: 12px 18px;
border: 1.5px solid #dfe6e9;
border-radius: 24px;
font-size: 14px;
outline: none;
transition: border 0.2s;
}
.card-route {
font-size: 11px;
color: #888;
margin-bottom: 12px;
padding: 6px;
background: #f8f9fa;
border-radius: 6px;
.chat-input-bar input:focus {
border-color: #6c5ce7;
}
.card-action {
text-align: center;
color: #2a9d8f;
font-weight: 600;
font-size: 13px;
.chat-input-bar input:disabled {
background: #f5f7fa;
cursor: not-allowed;
}
.chat-input-bar button {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border: none;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
}
.slide-up-enter-active, .slide-up-leave-active {
transition: all 0.3s ease;
.chat-input-bar button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(108,92,231,0.35);
}
.slide-up-enter-from, .slide-up-leave-to {
transform: translateY(20px);
opacity: 0;
.chat-input-bar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 900px) {
.cards-grid {
.chat-input-bar button::before {
content: '➤';
}
@media (max-width: 768px) {
.chat-interface {
padding: 16px;
}
.chat-cards {
grid-template-columns: 1fr;
}
.chat-bubble {
max-width: 90%;
}
.confirm-card {
width: 95%;
}
}
</style>

@ -1,74 +1,57 @@
<template>
<div class="custom-plan">
<div class="custom-header">
<div class="cp-header">
<h1> 自定义行程</h1>
<p>输入你的行程安排AI 帮你评估合理性并给出优化建议</p>
<p>输入你已规划的行程AI 评估合理性并给出优化建议</p>
</div>
<!-- Input form -->
<div class="custom-form">
<div class="form-group">
<div class="form-wrap">
<div class="fg">
<label>行程安排</label>
<textarea
v-model="itineraryText"
placeholder="请按格式输入你的行程安排,例如:&#10;Day 1昆明 -> 大理,上午出发,下午逛大理古城&#10;Day 2大理 -> 丽江,上午游览洱海,下午前往丽江&#10;Day 3丽江一日游游览玉龙雪山、束河古镇&#10;Day 4丽江返程&#10;&#10;也可以自由描述AI 会自动解析..."
placeholder="例如:&#10;Day 1: 昆明 -> 大理,上午出发,下午逛大理古城&#10;Day 2: 大理 -> 丽江,上午游览洱海,下午前往丽江&#10;Day 3: 丽江一日游,游览玉龙雪山、束河古镇&#10;Day 4: 丽江返程&#10;&#10;也可以自由描述AI 会自动解析..."
:disabled="isGenerating"
rows="10"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<div class="fg">
<label>出行方式</label>
<select v-model="transport" :disabled="isGenerating">
<option value="自驾">自驾</option>
<option value="高铁">高铁</option>
<option value="飞机">飞机</option>
<option value="公共交通">公共交通</option>
<option value="步行/骑行">步行/骑行</option>
</select>
</div>
<div class="form-group">
<div class="fg">
<label>同行人员</label>
<select v-model="companions" :disabled="isGenerating">
<option value="独自">独自</option>
<option value="情侣">情侣</option>
<option value="朋友">朋友</option>
<option value="带老人">带老人</option>
<option value="带小孩">带小孩</option>
<option value="家庭出游">家庭出游</option>
<option value="情侣/夫妻">情侣/夫妻</option>
<option value="家庭(带老人小孩)">家庭带老人小孩</option>
<option value="朋友结伴">朋友结伴</option>
</select>
</div>
</div>
<button
class="generate-btn"
@click="evaluatePlan"
:disabled="!itineraryText.trim() || isGenerating"
>
<button class="btn-primary" @click="evaluatePlan" :disabled="!itineraryText.trim() || isGenerating">
{{ isGenerating ? '评估中...' : 'AI 评估行程' }}
</button>
</div>
<!-- AI Thinking process -->
<div v-if="isGenerating" class="thinking-section">
<div class="thinking-header" @click="thinkingExpanded = !thinkingExpanded">
<div class="thinking-spinner"></div>
<span class="thinking-title">AI 正在评估中...</span>
<span class="thinking-toggle">{{ thinkingExpanded ? '收起' : '展开详情' }}</span>
<div v-if="isGenerating" class="load-panel">
<div class="lp-head" @click="thinkingExpanded = !thinkingExpanded">
<div class="spin"></div><span>AI 正在评估中...</span>
<span class="lp-toggle">{{ thinkingExpanded ? '收起' : '展开' }}</span>
</div>
<transition name="expand">
<div v-if="thinkingExpanded" class="thinking-content">
<div class="thinking-text">{{ thinkingText || '正在初始化...' }}</div>
<div v-if="thinkingExpanded" class="lp-body">
<div class="lp-step" v-if="thinkingText">{{ thinkingText }}</div>
<div class="lp-step" v-else>...</div>
</div>
</transition>
</div>
<!-- Loading (fallback) -->
<div v-if="isGenerating && !thinkingText" class="loading-section">
<div class="loading-spinner"></div>
<p>{{ loadingText }}</p>
</div>
<!-- Evaluation result -->
<transition name="fade-up">
<div v-if="evaluationResult" class="result-section">
@ -119,7 +102,6 @@ const itineraryText = ref('')
const transport = ref('自驾')
const companions = ref('独自')
const isGenerating = ref(false)
const loadingText = ref('正在分析行程安排...')
const thinkingText = ref('')
const thinkingExpanded = ref(true)
const evaluationResult = ref(null)
@ -135,7 +117,6 @@ const evaluatePlan = async () => {
originalScheme.value = null
thinkingText.value = ''
thinkingExpanded.value = true
loadingText.value = '正在解析你的行程安排...'
try {
const userInput = {
@ -179,204 +160,173 @@ const useOriginalPlan = () => {
<style scoped>
.custom-plan {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
overflow-y: auto;
max-width: 580px;
margin: 0 auto;
padding: 24px 32px;
}
.custom-header {
.cp-header {
text-align: center;
padding: 30px 20px;
background: linear-gradient(135deg, #264653, #2a9d8f);
color: #fff;
padding: 20px 0 28px;
}
.custom-header h1 { margin: 0 0 8px; font-size: 24px; }
.custom-header p { margin: 0; opacity: 0.9; font-size: 14px; }
.cp-header h1 {
font-size: 20px;
font-weight: 700;
margin-bottom: 6px;
color: #2d3436;
}
.custom-form {
max-width: 700px;
margin: 30px auto;
padding: 24px;
.cp-header p {
color: #636e72;
font-size: 14px;
}
/* Form */
.form-wrap {
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
padding: 32px;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
.fg {
margin-bottom: 18px;
}
.form-group label {
.fg label {
display: block;
font-size: 13px;
font-weight: 600;
color: #555;
color: #2d3436;
margin-bottom: 6px;
}
.form-group textarea {
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 8px;
.fg input, .fg select, .fg textarea {
width: 100%;
padding: 10px 14px;
border: 1.5px solid #dfe6e9;
border-radius: 10px;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border 0.2s;
font-family: inherit;
}
.fg textarea {
min-height: 120px;
resize: vertical;
transition: border-color 0.2s;
line-height: 1.6;
}
.form-group textarea:focus {
border-color: #2a9d8f;
.fg input:focus, .fg select:focus, .fg textarea:focus {
border-color: #6c5ce7;
}
.form-group textarea:disabled {
background: #f5f5f5;
.fg input:disabled, .fg select:disabled, .fg textarea:disabled {
background: #f5f7fa;
cursor: not-allowed;
}
.form-row {
display: flex;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
.form-group select {
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.form-group select:focus {
border-color: #2a9d8f;
}
.form-group select:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.generate-btn {
.btn-primary {
width: 100%;
padding: 14px;
background: #2a9d8f;
padding: 13px;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border: none;
border-radius: 10px;
font-size: 16px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
transition: all 0.2s;
}
.generate-btn:hover:not(:disabled) { background: #21867a; }
.generate-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(108,92,231,0.35);
}
.thinking-section {
max-width: 700px;
margin: 20px auto;
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Loading Panel */
.load-panel {
margin-top: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
overflow: hidden;
}
.thinking-header {
.lp-head {
padding: 14px 20px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
gap: 10px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.thinking-spinner {
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #2a9d8f;
.spin {
width: 18px;
height: 18px;
border: 2.5px solid #dfe6e9;
border-top-color: #6c5ce7;
border-radius: 50%;
animation: spin 1s linear infinite;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.thinking-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: #333;
@keyframes spin {
to { transform: rotate(360deg); }
}
.thinking-toggle {
.lp-toggle {
margin-left: auto;
font-size: 12px;
color: #999;
color: #b2bec3;
font-weight: 400;
}
.thinking-content {
border-top: 1px solid #f0f0f0;
.lp-body {
padding: 16px 20px;
max-height: 200px;
overflow-y: auto;
}
.thinking-text {
padding: 16px 20px;
.lp-step {
display: flex;
gap: 8px;
font-size: 13px;
color: #555;
line-height: 1.8;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
background: #fafafa;
color: #636e72;
margin-bottom: 8px;
}
.expand-enter-active, .expand-leave-active {
transition: all 0.3s ease;
max-height: 300px;
max-height: 200px;
overflow: hidden;
}
.expand-enter-from, .expand-leave-to {
max-height: 0;
opacity: 0;
}
.loading-section {
text-align: center;
padding: 40px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #2a9d8f;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-section p {
color: #666;
font-size: 14px;
}
/* Results */
.result-section {
max-width: 700px;
margin: 0 auto 40px;
padding: 0 20px;
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 16px;
@ -392,11 +342,11 @@ const useOriginalPlan = () => {
.result-card h3 {
margin: 0 0 12px;
font-size: 16px;
color: #333;
color: #2d3436;
}
.summary-card {
border-left: 4px solid #2a9d8f;
border-left: 4px solid #00b894;
}
.summary-card p {
@ -406,7 +356,7 @@ const useOriginalPlan = () => {
}
.suggestion-card {
border-left: 4px solid #f4a261;
border-left: 4px solid #f39c12;
}
.suggestion-card ul {
@ -417,16 +367,16 @@ const useOriginalPlan = () => {
}
.itinerary-card {
border-left: 4px solid #264653;
border-left: 4px solid #6c5ce7;
}
.original-card {
border-left: 4px solid #e76f51;
border-left: 4px solid #e17055;
}
.route-preview {
font-size: 13px;
color: #666;
color: #636e72;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
@ -437,30 +387,38 @@ const useOriginalPlan = () => {
display: flex;
gap: 16px;
font-size: 13px;
color: #888;
color: #b2bec3;
margin-bottom: 14px;
}
.use-btn {
width: 100%;
padding: 12px;
background: #2a9d8f;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
transition: all 0.2s;
}
.use-btn:hover { background: #21867a; }
.use-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(108,92,231,0.35);
}
.original-btn {
background: #e76f51;
background: #fff;
color: #6c5ce7;
border: 1.5px solid #6c5ce7;
}
.original-btn:hover { background: #d35f44; }
.original-btn:hover {
background: #6c5ce7;
color: #fff;
}
.fade-up-enter-active, .fade-up-leave-active {
transition: all 0.3s ease;
@ -470,4 +428,14 @@ const useOriginalPlan = () => {
transform: translateY(20px);
opacity: 0;
}
@media (max-width: 768px) {
.custom-plan {
padding: 16px;
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,259 @@
<template>
<div class="history-panel">
<div class="history-header">
<h3>📋 历史行程</h3>
<button class="close-btn" @click="$emit('close')"></button>
</div>
<div v-if="loading" class="history-loading">...</div>
<div v-else-if="plans.length === 0" class="history-empty">
<p>暂无历史行程</p>
<p class="hint">完成行程规划后会自动保存</p>
</div>
<div v-else class="history-list">
<div v-for="plan in plans" :key="plan.id" class="history-item" @click="loadPlan(plan)">
<div class="history-item-header">
<div class="history-name">{{ plan.name }}</div>
<button class="delete-btn" @click.stop="deletePlan(plan.id)" title="删除">🗑</button>
</div>
<div v-if="plan.description" class="history-desc">{{ plan.description }}</div>
<div class="history-meta">
<span>{{ formatDate(plan.created_at) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useItineraryStore } from '../stores/itinerary'
import { trackPlanLoad } from '../services/statsService'
const emit = defineEmits(['close', 'load'])
const router = useRouter()
const authStore = useAuthStore()
const itineraryStore = useItineraryStore()
const plans = ref([])
const loading = ref(false)
onMounted(async () => {
await loadPlans()
})
async function loadPlans() {
loading.value = true
try {
const API_BASE = 'http://localhost:3001/api'
let url, headers = {}
if (authStore.isAuthenticated) {
url = `${API_BASE}/plans`
headers = { 'Authorization': `Bearer ${authStore.token}` }
} else if (authStore.guestId) {
url = `${API_BASE}/plans/guest/${authStore.guestId}`
} else {
plans.value = []
return
}
const res = await fetch(url, { headers })
const data = await res.json()
plans.value = data.plans || []
} catch (err) {
console.error('加载历史行程失败:', err)
} finally {
loading.value = false
}
}
async function loadPlan(plan) {
loading.value = true
try {
const API_BASE = 'http://localhost:3001/api'
let url, headers = {}
if (authStore.isAuthenticated) {
url = `${API_BASE}/plans/${plan.id}`
headers = { 'Authorization': `Bearer ${authStore.token}` }
} else if (authStore.guestId) {
url = `${API_BASE}/plans/${plan.id}?guestId=${authStore.guestId}`
}
const res = await fetch(url, { headers })
const data = await res.json()
if (data.plan) {
// store
const planData = data.plan.data
if (planData.points) {
itineraryStore.loadFromAI({ points: planData.points })
itineraryStore.setPhase('workbench')
trackPlanLoad(plan.id)
emit('load', plan)
//
router.push('/')
}
}
} catch (err) {
console.error('加载行程失败:', err)
alert('加载行程失败')
} finally {
loading.value = false
}
}
async function deletePlan(planId) {
if (!confirm('确定要删除这个行程吗?')) return
try {
const API_BASE = 'http://localhost:3001/api'
const res = await fetch(`${API_BASE}/plans/${planId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${authStore.token}` }
})
if (res.ok) {
plans.value = plans.value.filter(p => p.id !== planId)
}
} catch (err) {
console.error('删除行程失败:', err)
}
}
function formatDate(dateStr) {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.history-panel {
position: fixed;
top: 0;
right: 0;
width: 360px;
height: 100vh;
background: #fff;
box-shadow: -4px 0 20px rgba(0,0,0,0.1);
z-index: 9999;
display: flex;
flex-direction: column;
}
.history-header {
padding: 20px;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.history-header h3 {
font-size: 18px;
color: #2d3436;
}
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #636e72;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: #f5f7fa;
}
.history-loading,
.history-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #b2bec3;
gap: 8px;
}
.history-empty .hint {
font-size: 12px;
}
.history-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.history-item {
padding: 16px;
border-radius: 8px;
background: #f5f7fa;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.history-item:hover {
background: #e8e4ff;
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.history-name {
font-weight: 600;
color: #2d3436;
font-size: 14px;
}
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
opacity: 0;
transition: opacity 0.2s;
}
.history-item:hover .delete-btn {
opacity: 1;
}
.history-desc {
font-size: 12px;
color: #636e72;
margin-bottom: 8px;
line-height: 1.4;
}
.history-meta {
font-size: 11px;
color: #b2bec3;
}
</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])
@ -219,7 +250,12 @@ const animateCar = (targetIdx, fromStep = null) => {
}
watch(() => store.currentStep, () => { updateMarkers(); updateRoute(!!animationFrameId) })
watch(() => store.points, () => { updateMarkers(); updateRoute() }, { deep: true })
watch(() => store.points, (newPoints) => {
console.log('[MapView] watch store.points 触发,长度:', newPoints.length)
console.log('[MapView] 站点列表:', newPoints.map(p => p.name))
updateMarkers()
updateRoute()
}, { deep: true })
watch(() => store.mode, () => { updateRoute() })
watch(() => store.hoveredReplacement, (opt) => { updatePreviewLine(opt) })
watch(() => store.routeSegments, () => { updateRoute() }, { deep: true })
@ -227,7 +263,7 @@ watch(() => store.routeSegments, () => { updateRoute() }, { deep: true })
onMounted(() => { initMap() })
onUnmounted(() => { if (animationFrameId) cancelAnimationFrame(animationFrameId); if (map) map.remove() })
defineExpose({ animateCar })
defineExpose({ animateCar, updateRoute, clearRoute: clearRouteLines, showAlternativeMarkers, clearAlternativeMarkers })
</script>
<style scoped>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,416 @@
<template>
<div v-if="props.visible" class="user-panel">
<div class="panel-content">
<!-- 用户信息 -->
<div class="user-section">
<div class="user-avatar">{{ authStore.user?.username?.charAt(0)?.toUpperCase() || 'U' }}</div>
<div class="user-details">
<div class="user-name">{{ authStore.user?.username }}</div>
<div class="user-role" :class="authStore.user?.role">
{{ authStore.user?.role === 'admin' ? '管理员' : '普通用户' }}
</div>
</div>
<button class="close-btn" @click="handleClose"></button>
</div>
<!-- 用户统计 -->
<div class="user-stats">
<div class="stat-item">
<span class="stat-num">{{ plans.length }}</span>
<span class="stat-label">行程规划</span>
</div>
<div class="stat-item">
<span class="stat-num">0</span>
<span class="stat-label">行程收藏</span>
</div>
<div class="stat-item">
<span class="stat-num">0</span>
<span class="stat-label">地点收藏</span>
</div>
</div>
<!-- Tab 导航 -->
<div class="tab-nav">
<button :class="{ active: activeTab === 'plans' }" @click="activeTab = 'plans'">📋 规划记录</button>
<button :class="{ active: activeTab === 'favorites' }" @click="activeTab = 'favorites'"> 行程收藏</button>
<button :class="{ active: activeTab === 'locations' }" @click="activeTab = 'locations'">📍 地点收藏</button>
</div>
<!-- 规划记录 -->
<div v-if="activeTab === 'plans'" class="tab-content">
<div v-if="loadingPlans" class="loading">...</div>
<div v-else-if="plans.length === 0" class="empty">暂无规划记录</div>
<div v-else class="plan-list">
<div v-for="plan in plans" :key="plan.id" class="plan-item" @click="loadPlan(plan)">
<div class="plan-name">{{ plan.name }}</div>
<div v-if="plan.description" class="plan-desc">{{ plan.description }}</div>
<div class="plan-date">{{ formatDate(plan.created_at) }}</div>
</div>
</div>
</div>
<!-- 行程收藏 -->
<div v-if="activeTab === 'favorites'" class="tab-content">
<div class="empty-placeholder">
<div class="empty-icon"></div>
<p>暂无收藏行程</p>
<p class="hint">功能开发中...</p>
</div>
</div>
<!-- 地点收藏 -->
<div v-if="activeTab === 'locations'" class="tab-content">
<div class="empty-placeholder">
<div class="empty-icon">📍</div>
<p>暂无收藏地点</p>
<p class="hint">功能开发中...</p>
</div>
</div>
<!-- 退出登录按钮 -->
<div class="logout-section">
<button class="logout-panel-btn" @click="handleLogout">🚪 退</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useItineraryStore } from '../stores/itinerary'
import { trackPlanLoad } from '../services/statsService'
const props = defineProps({
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['update:visible', 'load', 'close'])
const router = useRouter()
const authStore = useAuthStore()
const itineraryStore = useItineraryStore()
const activeTab = ref('plans')
const plans = ref([])
const loadingPlans = ref(false)
onMounted(async () => {
if (authStore.isAuthenticated) {
await loadPlans()
}
})
function handleClose() {
emit('update:visible', false)
emit('close')
}
function handleLogout() {
if (confirm('确定要退出登录吗?')) {
authStore.logout()
emit('update:visible', false)
router.push('/')
}
}
async function loadPlans() {
loadingPlans.value = true
try {
const API_BASE = 'http://localhost:3001/api'
const headers = { 'Authorization': `Bearer ${authStore.token}` }
const res = await fetch(`${API_BASE}/plans`, { headers })
const data = await res.json()
plans.value = data.plans || []
} catch (err) {
console.error('加载规划记录失败:', err)
} finally {
loadingPlans.value = false
}
}
async function loadPlan(plan) {
loadingPlans.value = true
try {
const API_BASE = 'http://localhost:3001/api'
const headers = { 'Authorization': `Bearer ${authStore.token}` }
const res = await fetch(`${API_BASE}/plans/${plan.id}`, { headers })
const data = await res.json()
if (data.plan) {
const planData = data.plan.data
if (planData.points) {
itineraryStore.loadFromAI({ points: planData.points })
itineraryStore.setPhase('workbench')
trackPlanLoad(plan.id)
emit('load', plan)
router.push('/')
}
}
} catch (err) {
console.error('加载行程失败:', err)
alert('加载行程失败')
} finally {
loadingPlans.value = false
}
}
function formatDate(dateStr) {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped>
.user-panel {
position: fixed;
top: 60px;
right: 0;
width: 320px;
height: calc(100vh - 60px);
background: #fff;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.1);
z-index: 9998;
display: flex;
flex-direction: column;
}
.panel-content {
flex: 1;
overflow: hidden;
padding: 20px;
display: flex;
flex-direction: column;
}
/* 用户信息 */
.user-section {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid #e9ecef;
margin-bottom: 16px;
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
flex-shrink: 0;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 16px;
font-weight: 600;
color: #2d3436;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
display: inline-block;
margin-top: 4px;
}
.user-role.admin {
background: #ffe0e0;
color: #d63031;
}
.user-role.user {
background: #e8e4ff;
color: #6c5ce7;
}
/* 关闭按钮 */
.close-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: #f5f7fa;
color: #636e72;
border: none;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.close-btn:hover {
background: #e8e4ff;
color: #6c5ce7;
}
/* Tab 导航 */
.tab-nav {
display: flex;
gap: 4px;
margin-bottom: 16px;
}
.tab-nav button {
flex: 1;
padding: 8px 4px;
border: none;
background: #f5f7fa;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
color: #636e72;
}
.tab-nav button.active {
background: #6c5ce7;
color: #fff;
}
.tab-nav button:hover:not(.active) {
background: #e8e4ff;
}
/* Tab 内容 - 可滚动区域 */
.tab-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* 用户统计 */
.user-stats {
display: flex;
justify-content: space-around;
padding: 16px 0;
border-bottom: 1px solid #e9ecef;
margin-bottom: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat-num {
font-size: 20px;
font-weight: 700;
color: #6c5ce7;
}
.stat-label {
font-size: 11px;
color: #636e72;
}
.loading,
.empty {
text-align: center;
color: #b2bec3;
padding: 40px 0;
}
.empty-placeholder {
text-align: center;
padding: 40px 0;
color: #b2bec3;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-placeholder .hint {
font-size: 12px;
color: #dfe6e9;
margin-top: 8px;
}
/* 规划列表 */
.plan-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.plan-item {
padding: 12px;
background: #f5f7fa;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.plan-item:hover {
background: #e8e4ff;
}
.plan-name {
font-size: 13px;
font-weight: 600;
color: #2d3436;
margin-bottom: 4px;
}
.plan-desc {
font-size: 11px;
color: #636e72;
margin-bottom: 6px;
line-height: 1.3;
}
.plan-date {
font-size: 10px;
color: #b2bec3;
}
/* 退出登录区域 - 固定在底部,不随列表滚动 */
.logout-section {
flex-shrink: 0;
padding-top: 16px;
border-top: 1px solid #e9ecef;
}
.logout-panel-btn {
width: 100%;
padding: 12px;
border-radius: 8px;
border: 2px solid #d63031;
background: #fff;
color: #d63031;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.logout-panel-btn:hover {
background: #d63031;
color: #fff;
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,115 @@
// SQLite 数据库初始化和表创建
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// Support Docker environment variable for data directory
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../.data')
const DB_PATH = path.join(DATA_DIR, 'trip-planner.db')
// 确保数据目录存在
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true })
}
const db = new Database(DB_PATH)
// 启用 WAL 模式提高并发性能
db.pragma('journal_mode = WAL')
// 创建表
db.exec(`
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 行程计划表
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
guest_id TEXT,
name TEXT NOT NULL,
description TEXT,
data TEXT NOT NULL, -- JSON 格式的完整行程数据
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 分享表
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
plan_id TEXT REFERENCES plans(id) ON DELETE CASCADE,
share_code TEXT UNIQUE NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
views INTEGER DEFAULT 0
);
-- 游客临时数据表24小时过期
CREATE TABLE IF NOT EXISTS guest_plans (
id TEXT PRIMARY KEY,
guest_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
data TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 使用统计表
CREATE TABLE IF NOT EXISTS stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
guest_id TEXT,
event_type TEXT NOT NULL, -- 'page_view', 'plan_create', 'plan_load', 'plan_export', 'chat_start', etc.
event_data TEXT, -- JSON 格式的额外数据
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);
CREATE INDEX IF NOT EXISTS idx_guest_plans_guest_id ON guest_plans(guest_id);
CREATE INDEX IF NOT EXISTS idx_guest_plans_expires_at ON guest_plans(expires_at);
CREATE INDEX IF NOT EXISTS idx_stats_user_id ON stats(user_id);
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);
`)
// 清理过期游客数据的函数
function cleanExpiredGuestPlans() {
db.prepare('DELETE FROM guest_plans WHERE expires_at < ?').run(new Date().toISOString())
}
// 每次启动时清理过期数据
cleanExpiredGuestPlans()
export { db, cleanExpiredGuestPlans }

@ -1,10 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from './views/HomePage.vue'
import SettingsPage from './views/SettingsPage.vue'
import AdminPage from './views/AdminPage.vue'
import SharedPlanView from './views/SharedPlanView.vue'
const routes = [
{ path: '/', component: HomePage },
{ path: '/settings', component: SettingsPage }
{ path: '/settings', component: SettingsPage },
{ path: '/admin', component: AdminPage },
{ path: '/share/:shareCode', component: SharedPlanView }
]
const router = createRouter({

@ -0,0 +1,219 @@
// 用户认证路由
import express from 'express'
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../db/database.js'
const router = express.Router()
// JWT 密钥(生产环境应使用环境变量)
const JWT_SECRET = process.env.JWT_SECRET || 'trip-planner-secret-key-2024'
const JWT_EXPIRES_IN = '7d' // Token 7天过期
// 中间件:验证 JWT Token
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: '未提供认证令牌' })
}
try {
const user = jwt.verify(token, JWT_SECRET)
req.user = user
next()
} catch (err) {
return res.status(403).json({ error: '无效的认证令牌' })
}
}
// 中间件:可选认证(有 token 则验证,没有则继续)
function optionalAuth(req, res, next) {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1]
if (token) {
try {
const user = jwt.verify(token, JWT_SECRET)
req.user = user
} catch (err) {
// Token 无效但继续,视为游客
}
}
next()
}
// 中间件:管理员权限检查
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: '需要管理员权限' })
}
next()
}
// 注册新用户
router.post('/register', async (req, res) => {
try {
const { username, email, password } = req.body
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' })
}
if (password.length < 6) {
return res.status(400).json({ error: '密码至少需要6个字符' })
}
// 检查用户名是否已存在
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
if (existing) {
return res.status(409).json({ error: '用户名已被使用' })
}
// 检查是否是第一个注册用户(自动成为管理员)
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get()
const role = userCount.count === 0 ? 'admin' : 'user'
// 哈希密码
const passwordHash = await bcrypt.hash(password, 10)
// 创建用户
const userId = uuidv4()
db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)').run(
userId, username, email || null, passwordHash, role
)
// 生成 JWT
const token = jwt.sign(
{ id: userId, username, role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
userId, 'user_register', JSON.stringify({ username, role })
)
res.status(201).json({
message: '注册成功',
user: { id: userId, username, role },
token
})
} catch (err) {
console.error('注册失败:', err)
res.status(500).json({ error: '注册失败,请稍后重试' })
}
})
// 用户登录
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' })
}
// 查找用户
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username)
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' })
}
// 验证密码
const validPassword = await bcrypt.compare(password, user.password_hash)
if (!validPassword) {
return res.status(401).json({ error: '用户名或密码错误' })
}
// 生成 JWT
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type) VALUES (?, ?)').run(
user.id, 'user_login'
)
res.json({
message: '登录成功',
user: { id: user.id, username: user.username, role: user.role },
token
})
} catch (err) {
console.error('登录失败:', err)
res.status(500).json({ error: '登录失败,请稍后重试' })
}
})
// 获取当前用户信息
router.get('/me', authenticateToken, (req, res) => {
const user = db.prepare('SELECT id, username, email, role, created_at FROM users WHERE id = ?').get(req.user.id)
if (!user) {
return res.status(404).json({ error: '用户不存在' })
}
res.json({ user })
})
// 生成游客 ID
router.post('/guest', (req, res) => {
const guestId = uuidv4()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 24小时后过期
res.json({
guestId,
expiresAt,
message: '游客会话已创建数据将保留24小时'
})
})
// 迁移游客数据到登录用户
router.post('/migrate-guest-data', authenticateToken, (req, res) => {
try {
const { guestId } = req.body
if (!guestId) {
return res.status(400).json({ error: '缺少游客ID' })
}
// 获取游客的所有计划
const guestPlans = db.prepare('SELECT * FROM plans WHERE guest_id = ?').all(guestId)
// 将游客计划转移到用户
let migrated = 0
const stmt = db.prepare('UPDATE plans SET user_id = ?, guest_id = NULL WHERE id = ?')
for (const plan of guestPlans) {
stmt.run(req.user.id, plan.id)
migrated++
}
// 同时迁移游客计划
const guestTempPlans = db.prepare('SELECT * FROM guest_plans WHERE guest_id = ?').all(guestId)
const insertPlan = db.prepare('INSERT INTO plans (id, user_id, name, description, data) VALUES (?, ?, ?, ?, ?)')
for (const gp of guestTempPlans) {
insertPlan.run(uuidv4(), req.user.id, gp.name, gp.description, gp.data)
migrated++
}
// 删除游客临时数据
db.prepare('DELETE FROM guest_plans WHERE guest_id = ?').run(guestId)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
req.user.id, 'guest_data_migrated', JSON.stringify({ guestId, migrated })
)
res.json({ message: '数据迁移成功', migrated })
} catch (err) {
console.error('数据迁移失败:', err)
res.status(500).json({ error: '数据迁移失败' })
}
})
export default router
export { authenticateToken, optionalAuth, requireAdmin }

@ -0,0 +1,142 @@
// 行程计划路由
import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../db/database.js'
import { authenticateToken, optionalAuth } from './auth.js'
const router = express.Router()
// 保存行程计划需要认证或游客ID
router.post('/', optionalAuth, (req, res) => {
try {
const { name, description, data } = req.body
if (!name || !data) {
return res.status(400).json({ error: '名称和行程数据不能为空' })
}
const planId = uuidv4()
if (req.user) {
// 已登录用户
db.prepare('INSERT INTO plans (id, user_id, name, description, data) VALUES (?, ?, ?, ?, ?)').run(
planId, req.user.id, name, description || null, JSON.stringify(data)
)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
req.user.id, 'plan_create', JSON.stringify({ planId, name })
)
} else {
// 游客
const { guestId } = req.body
if (!guestId) {
return res.status(400).json({ error: '游客需要提供guestId' })
}
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
db.prepare('INSERT INTO guest_plans (id, guest_id, name, description, data, expires_at) VALUES (?, ?, ?, ?, ?, ?)').run(
planId, guestId, name, description || null, JSON.stringify(data), expiresAt
)
// 记录统计
db.prepare('INSERT INTO stats (guest_id, event_type, event_data) VALUES (?, ?, ?)').run(
guestId, 'plan_create', JSON.stringify({ planId, name })
)
}
res.status(201).json({ id: planId, message: '行程保存成功' })
} catch (err) {
console.error('保存行程失败:', err)
res.status(500).json({ error: '保存行程失败' })
}
})
// 获取用户的所有行程计划
router.get('/', authenticateToken, (req, res) => {
try {
const plans = db.prepare('SELECT id, name, description, created_at, updated_at FROM plans WHERE user_id = ? ORDER BY created_at DESC').all(req.user.id)
// 记录统计
db.prepare('INSERT INTO stats (user_id, event_type) VALUES (?, ?)').run(req.user.id, 'plans_list')
res.json({ plans })
} catch (err) {
console.error('获取行程列表失败:', err)
res.status(500).json({ error: '获取行程列表失败' })
}
})
// 获取游客的行程计划
router.get('/guest/:guestId', (req, res) => {
try {
const { guestId } = req.params
// 清理过期数据
db.prepare('DELETE FROM guest_plans WHERE expires_at < ?').run(new Date().toISOString())
const plans = db.prepare('SELECT id, name, description, created_at, expires_at FROM guest_plans WHERE guest_id = ? ORDER BY created_at DESC').all(guestId)
res.json({ plans })
} catch (err) {
console.error('获取游客行程失败:', err)
res.status(500).json({ error: '获取行程失败' })
}
})
// 获取单个行程详情
router.get('/:id', optionalAuth, (req, res) => {
try {
const { id } = req.params
let plan
if (req.user) {
plan = db.prepare('SELECT * FROM plans WHERE id = ? AND user_id = ?').get(id, req.user.id)
} else {
const { guestId } = req.query
plan = db.prepare('SELECT * FROM guest_plans WHERE id = ? AND guest_id = ?').get(id, guestId)
}
if (!plan) {
return res.status(404).json({ error: '行程不存在' })
}
// 解析 JSON 数据
plan.data = JSON.parse(plan.data)
// 记录统计
if (req.user) {
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
req.user.id, 'plan_load', JSON.stringify({ planId: id })
)
}
res.json({ plan })
} catch (err) {
console.error('获取行程详情失败:', err)
res.status(500).json({ error: '获取行程详情失败' })
}
})
// 删除行程
router.delete('/:id', authenticateToken, (req, res) => {
try {
const { id } = req.params
const result = db.prepare('DELETE FROM plans WHERE id = ? AND user_id = ?').run(id, req.user.id)
if (result.changes === 0) {
return res.status(404).json({ error: '行程不存在' })
}
db.prepare('INSERT INTO stats (user_id, event_type, event_data) VALUES (?, ?, ?)').run(
req.user.id, 'plan_delete', JSON.stringify({ planId: id })
)
res.json({ message: '行程删除成功' })
} catch (err) {
console.error('删除行程失败:', err)
res.status(500).json({ error: '删除行程失败' })
}
})
export default router

@ -0,0 +1,91 @@
// 行程分享路由
import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../db/database.js'
import { authenticateToken } from './auth.js'
const router = express.Router()
// 创建分享链接(需要登录)
router.post('/', authenticateToken, (req, res) => {
try {
const { planId } = req.body
if (!planId) {
return res.status(400).json({ error: '缺少行程ID' })
}
// 验证行程属于当前用户
const plan = db.prepare('SELECT * FROM plans WHERE id = ? AND user_id = ?').get(planId, req.user.id)
if (!plan) {
return res.status(404).json({ error: '行程不存在或无权访问' })
}
// 生成唯一的分享码
const shareCode = uuidv4().slice(0, 8)
const shareId = uuidv4()
db.prepare('INSERT INTO shares (id, plan_id, share_code, created_by) VALUES (?, ?, ?, ?)').run(
shareId, planId, shareCode, req.user.id
)
// 开发模式下前端运行在 5173 端口API 在 3001 端口
const isDev = process.env.NODE_ENV !== 'production'
console.log('[shares] NODE_ENV:', process.env.NODE_ENV, 'isDev:', isDev)
const shareUrl = `http://localhost:5173/share/${shareCode}`
console.log('[shares] 分享URL:', shareUrl)
console.log('[shares] 生成分享链接:', { shareCode, isDev, shareUrl })
res.json({
shareCode,
shareUrl,
planName: plan.name
})
} catch (err) {
console.error('创建分享失败:', err)
res.status(500).json({ error: '创建分享失败' })
}
})
// 获取分享行程(公开访问)
router.get('/:shareCode', (req, res) => {
try {
const { shareCode } = req.params
const share = db.prepare(`
SELECT s.id, s.share_code, s.created_at, s.views,
p.id as plan_id, p.name, p.description, p.data,
u.username as creator
FROM shares s
JOIN plans p ON s.plan_id = p.id
LEFT JOIN users u ON s.created_by = u.id
WHERE s.share_code = ?
`).get(shareCode)
if (!share) {
return res.status(404).json({ error: '分享链接无效或已过期' })
}
// 更新浏览次数
db.prepare('UPDATE shares SET views = views + 1 WHERE id = ?').run(share.id)
// 解析行程数据
const planData = JSON.parse(share.data)
res.json({
shareCode: share.share_code,
planName: share.name,
planDescription: share.description,
creator: share.creator,
createdAt: share.created_at,
views: share.views + 1,
data: planData
})
} catch (err) {
console.error('获取分享失败:', err)
res.status(500).json({ error: '获取分享失败' })
}
})
export default router

@ -0,0 +1,246 @@
// 使用统计和管理员路由
import express from 'express'
import { db } from '../db/database.js'
import { authenticateToken, requireAdmin } from './auth.js'
const router = express.Router()
// 记录事件(前端调用)
router.post('/event', (req, res) => {
try {
const { userId, guestId, eventType, eventData } = req.body
if (!eventType) {
return res.status(400).json({ error: '事件类型不能为空' })
}
db.prepare('INSERT INTO stats (user_id, guest_id, event_type, event_data) VALUES (?, ?, ?, ?)').run(
userId || null,
guestId || null,
eventType,
eventData ? JSON.stringify(eventData) : null
)
res.json({ message: '事件记录成功' })
} catch (err) {
console.error('记录事件失败:', err)
res.status(500).json({ error: '记录事件失败' })
}
})
// 管理员:获取总体统计数据
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 = ?').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 > ?').get(new Date().toISOString())
// 总事件数
const totalEvents = db.prepare('SELECT COUNT(*) as count FROM stats').get()
// 今日事件数
const todayEvents = db.prepare(`SELECT COUNT(*) as count FROM stats WHERE date(created_at) = date('now')`).get()
// 今日活跃用户
const todayActiveUsers = db.prepare(`SELECT COUNT(DISTINCT user_id) as count FROM stats WHERE date(created_at) = date('now') AND user_id IS NOT NULL`).get()
// 今日活跃游客
const todayActiveGuests = db.prepare(`SELECT COUNT(DISTINCT guest_id) as count FROM stats WHERE date(created_at) = date('now') AND guest_id IS NOT NULL`).get()
res.json({
overview: {
totalUsers: totalUsers.count,
adminCount: adminCount.count,
totalPlans: totalPlans.count,
activeGuestPlans: guestPlans.count,
totalEvents: totalEvents.count,
todayEvents: todayEvents.count,
todayActiveUsers: todayActiveUsers.count,
todayActiveGuests: todayActiveGuests.count
}
})
} catch (err) {
console.error('获取统计数据失败:', err)
res.status(500).json({ error: '获取统计数据失败' })
}
})
// 管理员:按类型统计事件
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()
const events = db.prepare(`
SELECT event_type, COUNT(*) as count
FROM stats
WHERE created_at > ?
GROUP BY event_type
ORDER BY count DESC
`).all(since)
res.json({ events, days })
} catch (err) {
console.error('获取事件统计失败:', err)
res.status(500).json({ error: '获取事件统计失败' })
}
})
// 管理员:每日活跃趋势
router.get('/admin/daily-active', authenticateToken, requireAdmin, (req, res) => {
try {
const { days = 30 } = req.query
const dailyUsers = db.prepare(`
SELECT date(created_at) as date, COUNT(DISTINCT user_id) as users, COUNT(DISTINCT guest_id) as guests
FROM stats
WHERE created_at > datetime('now', ?)
GROUP BY date(created_at)
ORDER BY date ASC
`).all(`-${days} days`)
res.json({ daily: dailyUsers, days })
} catch (err) {
console.error('获取活跃趋势失败:', err)
res.status(500).json({ error: '获取活跃趋势失败' })
}
})
// 管理员:用户列表
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,
COUNT(DISTINCT p.id) as plan_count,
COUNT(DISTINCT s.id) as event_count,
MAX(s.created_at) as last_active
FROM users u
LEFT JOIN plans p ON u.id = p.user_id
LEFT JOIN stats s ON u.id = s.user_id
GROUP BY u.id
ORDER BY u.created_at DESC
`).all()
res.json({ users })
} catch (err) {
console.error('获取用户列表失败:', err)
res.status(500).json({ error: '获取用户列表失败' })
}
})
// 管理员:热门行程
router.get('/admin/popular-plans', authenticateToken, requireAdmin, (req, res) => {
try {
const { limit = 10 } = req.query
const plans = db.prepare(`
SELECT p.id, p.name, p.description, p.created_at, u.username,
COUNT(s.id) as view_count
FROM plans p
LEFT JOIN users u ON p.user_id = u.id
LEFT JOIN stats s ON s.event_data LIKE '%' || p.id || '%' AND s.event_type = 'plan_load'
GROUP BY p.id
ORDER BY view_count DESC
LIMIT ?
`).all(limit)
res.json({ plans })
} catch (err) {
console.error('获取热门行程失败:', err)
res.status(500).json({ error: '获取热门行程失败' })
}
})
// ===== 管理员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,19 +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": "上午", "content": "行程安排"}],
"foods": ["当地美食"],
"hotel": "推荐住宿",
"tips": "注意事项"
"schedule": [
{
"time": "上午",
"title": "抵达昆明",
"content": "抵达昆明,入住酒店",
"desc": "建议选择翠湖附近的酒店"
}
],
"foods": [
{
"name": "过桥米线",
"icon": "🍜",
"desc": "云南经典美食"
}
],
"waypoints": [
{
"name": "滇池",
"icon": "📍",
"desc": "云南最大淡水湖"
}
],
"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": "提前预订古城内住宿"
}
]
}
@ -91,9 +228,15 @@ JSON 格式如下:
要求
1. 3 个方案风格不同经典路线深度游小众路线
2. lat/lng 必须是真实中国地理坐标
3. 行程安排要合理每天 1-2 个地点
4. daysDetail 数组长度必须等于 days每天一个对象
5. 直接输出 JSON不要其他内容`
3. 行程安排要详细每天至少 3 个时间段上午下午晚上
4. 美食推荐至少 4 种当地特色美食
5. 途径推荐 2-3 个从上一站到当前站之间可以顺路游览的地点
6. schedule title content 必填desc 为可选的详细说明
7. foods 中每个对象必须包含 nameicondesc
8. waypoints 中每个对象必须包含 nameicondesc
9. daysDetail 数组长度必须等于 days每天一个对象
10. points 数组必须包含行程中所有途经站点每个 daysDetail 中的 location 至少对应一个 point总点数至少 3 STARTEND 和至少一个中间站每个 point badge 依次为 STARTD1D2...END
11. 直接输出 JSON不要其他内容`
const CUSTOM_PLAN_PROMPT = `你是一个专业的旅行规划助手。用户已经输入了他/她的行程安排,你需要:
1. 解析用户的行程安排
@ -172,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, {
@ -185,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'
})
})
@ -238,6 +478,10 @@ export async function chatWithAI(userMessage, onThinking = null) {
}
function parseAIResponse(content) {
if (!content || !content.trim()) {
throw new Error('AI 未返回任何内容')
}
// Remove markdown code block markers if present
let cleaned = content.trim()
@ -259,7 +503,7 @@ function parseAIResponse(content) {
throw new Error('AI 未返回有效的 JSON 数据')
}
const jsonStr = cleaned.substring(firstBrace, lastBrace + 1)
let jsonStr = cleaned.substring(firstBrace, lastBrace + 1)
try {
const parsed = JSON.parse(jsonStr)
@ -272,20 +516,44 @@ function parseAIResponse(content) {
return parsed
} catch (e) {
if (e.message.includes('缺少 schemes')) throw e
console.error('JSON parse error:', e)
console.error('Raw content:', cleaned.substring(0, 200))
throw new Error('AI 返回的 JSON 格式有误,请重试')
// Try to fix common JSON issues
try {
// Fix unescaped newlines in string values
jsonStr = jsonStr.replace(/"([^"]*?)\n([^"]*?)"/g, '"$1\\n$2"')
// Remove trailing commas
jsonStr = jsonStr.replace(/,(\s*[}\]])/g, '$1')
const parsed = JSON.parse(jsonStr)
if (!parsed.schemes || !Array.isArray(parsed.schemes) || parsed.schemes.length === 0) {
throw new Error('AI 返回结果缺少 schemes')
}
return parsed
} catch (e2) {
console.error('JSON parse error (original):', e)
console.error('JSON parse error (after fix attempt):', e2)
console.error('Raw content (first 500 chars):', cleaned.substring(0, 500))
console.error('Raw content (last 500 chars):', cleaned.substring(Math.max(0, cleaned.length - 500)))
throw new Error('AI 返回的 JSON 格式有误,请重试')
}
}
}
// Shared streaming function
async function streamAIRequest(messages, onThinking = 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)
@ -347,11 +615,20 @@ async function streamAIRequest(messages, onThinking = null) {
}
function parseJSONFromContent(content) {
if (!content || !content.trim()) {
throw new Error('AI 未返回任何内容')
}
let cleaned = content.trim()
// Remove markdown code block markers
const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/)
if (codeBlockMatch) cleaned = codeBlockMatch[1].trim()
// Remove <thinking> tags
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim()
// Find JSON boundaries
const firstBrace = cleaned.indexOf('{')
const lastBrace = cleaned.lastIndexOf('}')
@ -359,16 +636,32 @@ function parseJSONFromContent(content) {
throw new Error('AI 未返回有效的 JSON 数据')
}
const jsonStr = cleaned.substring(firstBrace, lastBrace + 1)
let jsonStr = cleaned.substring(firstBrace, lastBrace + 1)
try {
return JSON.parse(jsonStr)
} catch (e) {
console.error('JSON parse error:', e)
throw new Error('AI 返回的 JSON 格式有误,请重试')
// Try to fix common JSON issues
try {
// Fix unescaped newlines in string values
jsonStr = jsonStr.replace(/"([^"]*?)\n([^"]*?)"/g, '"$1\\n$2"')
// Fix unescaped quotes within strings
jsonStr = jsonStr.replace(/:"([^"]*?)"([^,}\]])/g, ':"$1"$2')
// Remove trailing commas
jsonStr = jsonStr.replace(/,(\s*[}\]])/g, '$1')
return JSON.parse(jsonStr)
} catch (e2) {
console.error('JSON parse error (original):', e)
console.error('JSON parse error (after fix attempt):', e2)
console.error('Raw content (first 500 chars):', cleaned.substring(0, 500))
console.error('Raw content (last 500 chars):', cleaned.substring(Math.max(0, cleaned.length - 500)))
throw new Error('AI 返回的 JSON 格式有误,请重试')
}
}
}
export async function quickPlan(userInput, onThinking = null) {
export async function quickPlan(userInput, onThinking = null, signal = null, context = {}) {
const userMessage = `目的地:${userInput.destination}
出行天数${userInput.days}
出发日期${userInput.startDate}
@ -377,21 +670,67 @@ export async function quickPlan(userInput, onThinking = null) {
请为我生成 3 个不同风格的旅行方案`
const content = await streamAIRequest([
{ role: 'system', content: QUICK_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking)
const parsed = parseJSONFromContent(content)
const MAX_RETRIES = 3
let lastError = null
if (!parsed.schemes || !Array.isArray(parsed.schemes) || parsed.schemes.length === 0) {
throw new Error('AI 返回结果缺少 schemes')
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.log(`[quickPlan] 尝试 ${attempt}/${MAX_RETRIES}`)
const content = await streamAIRequest([
{ role: 'system', content: QUICK_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking, signal, { ...context, eventType: context.eventType || 'quick_plan' })
const parsed = parseJSONFromContent(content)
if (!parsed.schemes || !Array.isArray(parsed.schemes) || parsed.schemes.length === 0) {
throw new Error('AI 返回结果缺少 schemes')
}
// 验证每个 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 < 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}`)
}
console.log(`[quickPlan] 解析成功,方案数: ${parsed.schemes.length}`)
// 详细打印每个方案的数据
parsed.schemes.forEach((scheme, idx) => {
console.log(`[quickPlan] === 方案 ${idx + 1} 详情 ===`)
console.log(` 名称: ${scheme.name}`)
console.log(` points 数量: ${scheme.points?.length || 0}`)
console.log(` points 列表: ${scheme.points?.map(p => p.name).join(', ') || '无'}`)
if (scheme.points?.length > 0) {
console.log(` points 完整数据:`, JSON.stringify(scheme.points, null, 2))
}
})
return parsed
} catch (e) {
lastError = e
console.warn(`[quickPlan] 尝试 ${attempt} 失败:`, e.message)
if (attempt < MAX_RETRIES) {
// 短暂延迟后重试
await new Promise(resolve => setTimeout(resolve, 1000 * attempt))
}
}
}
return parsed
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}
@ -400,16 +739,297 @@ ${userInput.itinerary}
请评估我的行程是否合理并给出优化建议和两个方案优化版和原版`
const content = await streamAIRequest([
{ role: 'system', content: CUSTOM_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking)
const parsed = parseJSONFromContent(content)
const MAX_RETRIES = 3
let lastError = null
if (!parsed.evaluation) {
throw new Error('AI 返回结果缺少 evaluation')
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.log(`[customPlan] 尝试 ${attempt}/${MAX_RETRIES}`)
const content = await streamAIRequest([
{ role: 'system', content: CUSTOM_PLAN_PROMPT },
{ role: 'user', content: userMessage }
], onThinking, null, { ...context, eventType: context.eventType || 'custom_plan' })
const parsed = parseJSONFromContent(content)
if (!parsed.evaluation) {
throw new Error('AI 返回结果缺少 evaluation')
}
console.log(`[customPlan] 解析成功`)
return parsed
} catch (e) {
lastError = e
console.warn(`[customPlan] 尝试 ${attempt} 失败:`, e.message)
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, 1000 * attempt))
}
}
}
return parsed
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
}

@ -0,0 +1,593 @@
// 导出可交互的 HTML 行程文件(包含地图和站点交互,样式与工作台一致)
export function exportToHtml(scheme, filename = null) {
if (!scheme) return
// 使用完整的 points 数据(包含 schedule, foods, waypoints, gallery 等)
const points = scheme.points || []
if (!points || points.length === 0) {
console.error('导出失败:没有站点数据')
return
}
const html = generateHtml(scheme, points)
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || `${scheme.name || '行程规划'}.html`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// 检查 URL 是否安全
function isSafeUrl(url) {
if (!url) return false
return url.startsWith('http://') || url.startsWith('https://')
}
// 过滤安全的图片 URL
function getSafeGallery(gallery) {
return (gallery || []).filter(g => isSafeUrl(g.url))
}
// 生成站点详情 HTML用于初始化页面
function generatePointDetail(p) {
let html = ''
// 统计信息
html += `<div class="wm-stats">
<div class="wm-stat"><div class="ws-val">${p.km || '—'}</div><div class="ws-lbl"></div></div>
<div class="wm-stat"><div class="ws-val">${p.driveTime || '—'}</div><div class="ws-lbl"></div></div>
<div class="wm-stat"><div class="ws-val">${p.day || 'Day 1'}</div><div class="ws-lbl"></div></div>
</div>`
// 描述
if (p.desc) {
html += `<div class="wm-sec"><p style="font-size:13px;color:#636e72;line-height:1.5;">${p.desc}</p></div>`
}
// 照片画廊
const safeGallery = getSafeGallery(p.gallery);
if (safeGallery.length > 0) {
html += '<div class="wm-sec photo-gallery"><h4>📸 风景照片</h4><div class="gallery-grid">'
safeGallery.forEach(g => {
html += `<div class="gallery-item"><img src="${g.url}" alt="${g.title}" loading="lazy" crossorigin="anonymous" /><div class="gallery-caption">${g.title}</div></div>`
})
html += '</div></div>'
}
// 途径推荐
if (p.waypoints && p.waypoints.length > 0) {
html += '<div class="wm-sec"><h4>🗺️ 途径推荐</h4><div class="waypoint-list">'
p.waypoints.forEach(wp => {
html += `<div class="waypoint-item">
<div class="wp-icon">${wp.icon || '📍'}</div>
<div class="wp-info"><div class="wp-name">${wp.name}</div><div class="wp-desc">${wp.desc || ''}</div></div>
</div>`
})
html += '</div></div>'
}
// 行程安排
if (p.schedule && p.schedule.length > 0) {
html += '<div class="wm-sec"><h4>📅 行程安排</h4>'
p.schedule.forEach(s => {
html += `<div class="wm-si">
<span class="si-time">${s.time}</span>
<div class="si-content">
<div class="si-title">${s.title || s.content || ''}</div>
${s.desc ? `<div class="si-desc">${s.desc}</div>` : ''}
</div>
</div>`
})
html += '</div>'
}
// 美食推荐
if (p.foods && p.foods.length > 0) {
html += '<div class="wm-sec"><h4>🍽️ 美食推荐</h4><div class="foods-grid">'
p.foods.forEach(f => {
const name = typeof f === 'string' ? f : (f.name || '')
const icon = typeof f === 'object' ? (f.icon || '🍜') : '🍜'
const desc = typeof f === 'object' ? (f.desc || '') : ''
html += `<div class="food-item">
<div class="food-icon">${icon}</div>
<div class="food-info">
<div class="food-name">${name}</div>
${desc ? `<div class="food-desc">${desc}</div>` : ''}
</div>
</div>`
})
html += '</div></div>'
}
// 住宿推荐
if (p.hotel) {
html += `<div class="wm-sec"><h4>🏨 住宿推荐</h4><div class="hotel-info">${p.hotel}</div></div>`
}
// 注意事项
if (p.tips) {
html += `<div class="wm-sec"><h4>💡 注意事项</h4><div class="wm-tips">${p.tips}</div></div>`
}
return html
}
function generateHtml(scheme, points) {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${scheme.name || '行程规划'}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f7fa; color: #2d3436; height: 100vh; overflow: hidden; }
/* 工作台三栏布局 */
.wb-layout { display: flex; height: 100vh; }
/* 左侧时间线 */
.wb-left { width: 220px; background: #2d3436; color: #fff; padding: 16px 0; overflow-y: auto; flex-shrink: 0; }
.wb-tl-header { font-size: 13px; padding: 8px 16px; color: #b2bec3; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; }
.wb-tl-item { padding: 10px 16px; cursor: pointer; display: flex; gap: 12px; transition: all 0.2s; position: relative; }
.wb-tl-item:hover { transform: translateX(4px); background: rgba(108,92,231,0.2); }
.wb-tl-item.active { background: rgba(108,92,231,0.4); }
.wb-tl-item.active::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: #6c5ce7; }
.wb-tl-dot { width: 12px; height: 12px; border-radius: 50%; border: 3px solid #6c5ce7; background: #fff; margin-top: 2px; flex-shrink: 0; position: relative; transition: all 0.3s; }
.wb-tl-item.active .wb-tl-dot { transform: scale(1.3); box-shadow: 0 0 0 4px rgba(108,92,231,0.3); background: #6c5ce7; }
.wb-tl-item.completed .wb-tl-dot { background: #6c5ce7; border-color: #6c5ce7; }
.wb-tl-item.completed .wb-tl-dot::after { content: "✓"; color: #fff; font-size: 8px; display: flex; align-items: center; justify-content: center; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.wb-tl-info { flex: 1; min-width: 0; }
.wb-tl-name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wb-tl-day { font-size: 11px; color: #b2bec3; margin-top: 2px; }
.wb-tl-item.active .wb-tl-name { color: #6c5ce7; }
/* 中间详情面板 */
.wb-mid { width: 340px; background: #fff; overflow-y: auto; border-right: 1px solid #e9ecef; flex-shrink: 0; }
.wm-header { padding: 16px; border-bottom: 1px solid #e9ecef; background: #f8f9fa; }
.wm-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.wm-subtitle { font-size: 12px; color: #636e72; }
.wm-body { padding: 16px; }
.wm-sec { margin-bottom: 20px; }
.wm-sec h4 { font-size: 14px; color: #6c5ce7; margin-bottom: 10px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.wm-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; }
.wm-stat { background: #f8f9fa; padding: 10px; border-radius: 8px; text-align: center; }
.ws-val { font-size: 16px; font-weight: 700; color: #6c5ce7; }
.ws-lbl { font-size: 11px; color: #636e72; margin-top: 2px; }
/* 日程安排 */
.wm-si { display: flex; gap: 12px; margin-bottom: 10px; padding: 10px; background: #f8f9fa; border-radius: 8px; }
.si-time { font-size: 12px; font-weight: 600; color: #6c5ce7; min-width: 45px; }
.si-content { flex: 1; }
.si-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.si-desc { font-size: 13px; color: #636e72; line-height: 1.4; }
/* 途径推荐 */
.waypoint-list { display: flex; flex-direction: column; gap: 8px; }
.waypoint-item { display: flex; gap: 10px; padding: 10px; background: #f8f9fa; border-radius: 8px; }
.wp-icon { font-size: 20px; flex-shrink: 0; }
.wp-info { flex: 1; }
.wp-name { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
.wp-desc { font-size: 12px; color: #636e72; }
/* 美食推荐 */
.foods-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.food-item { display: flex; gap: 8px; padding: 10px; background: #f8f9fa; border-radius: 8px; align-items: center; }
.food-icon { font-size: 20px; }
.food-info { flex: 1; }
.food-name { font-size: 13px; font-weight: 600; }
.food-desc { font-size: 12px; color: #636e72; margin-top: 2px; }
/* 照片画廊 */
.photo-gallery { margin-bottom: 16px; }
.photo-gallery h4 { font-size: 14px; color: #6c5ce7; margin-bottom: 10px; }
.gallery-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.gallery-item { border-radius: 8px; overflow: hidden; position: relative; aspect-ratio: 4/3; background: linear-gradient(135deg, #e8e4ff, #d4c8ff); }
.gallery-item img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.2s; }
.gallery-item:hover img { transform: scale(1.05); }
.gallery-caption { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,0.7)); color: #fff; padding: 6px 8px; font-size: 11px; }
/* 住宿和提示 */
.hotel-info { background: #e8e4ff; padding: 10px; border-radius: 8px; border-left: 3px solid #6c5ce7; font-size: 13px; color: #6c5ce7; }
.wm-tips { background: #fff8e1; padding: 10px; border-radius: 8px; border-left: 3px solid #f39c12; font-size: 13px; color: #636e72; line-height: 1.5; }
.empty-state { text-align: center; padding: 40px 20px; color: #b2bec3; }
.empty-icon { font-size: 48px; display: block; margin-bottom: 10px; }
/* 右侧地图 */
.wb-right { flex: 1; position: relative; }
#map-container { width: 100%; height: 100%; position: relative; overflow: hidden; background: linear-gradient(135deg, #e8e4ff 0%, #d4c8ff 100%); }
#static-map { width: 100%; height: 100%; object-fit: contain; display: block; }
#map-markers { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
#map-markers > * { pointer-events: auto; }
/* 地图控制 */
.map-controls { position: absolute; top: 12px; right: 12px; z-index: 1000; display: flex; flex-direction: column; gap: 8px; }
.route-toggle { background: #fff; border: 1px solid #e9ecef; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 13px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: all 0.2s; }
.route-toggle:hover { border-color: #6c5ce7; color: #6c5ce7; }
.route-toggle.active { background: #6c5ce7; color: #fff; border-color: #6c5ce7; }
/* 自定义标记 */
.marker-pin { width: 36px; height: 36px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); display: flex; align-items: center; justify-content: center; box-shadow: 0 3px 8px rgba(0,0,0,.25); cursor: pointer; transition: transform .2s; }
.marker-pin:hover { transform: rotate(-45deg) scale(1.15); }
.marker-pin span { transform: rotate(45deg); color: #fff; font-weight: 800; font-size: 13px; }
${points.map((p, i) => `.marker-pin.d${i} { background: ${['#6c757d', '#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4', '#FFC107', '#795548', '#607D8B'][i % 10]}; }`).join('')}
.marker-label { background: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; box-shadow: 0 1px 4px rgba(0,0,0,.15); white-space: nowrap; margin-top: 6px; }
/* 汽车标记 */
.car-marker { z-index: 1000 !important; }
.car-marker .car-inner { font-size: 28px; filter: drop-shadow(0 2px 4px rgba(0,0,0,.3)); animation: carBounce 1.5s ease infinite; }
@keyframes carBounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
/* 加载遮罩 */
.loading-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background: #2d3436; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #fff; transition: opacity .5s; }
.loading-overlay.hidden { opacity: 0; pointer-events: none; }
.loading-spinner { width: 40px; height: 40px; border: 3px solid rgba(255,255,255,.2); border-top-color: #6c5ce7; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 16px; }
@keyframes spin { to { transform: rotate(360deg); } }
.popup-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; color: #2d3436; }
.popup-day { color: #6c5ce7; font-size: 12px; font-weight: 600; }
@media (max-width: 1000px) {
.wb-left { width: 180px; }
.wb-mid { width: 280px; }
}
@media (max-width: 800px) {
.wb-layout { flex-direction: column; }
.wb-left { width: 100%; height: 80px; display: flex; overflow-x: auto; overflow-y: hidden; padding: 8px; }
.wb-tl-header { display: none; }
.wb-tl-item { flex-direction: column; align-items: center; min-width: 70px; }
.wb-mid { width: 100%; height: 200px; border-right: none; border-top: 1px solid #e9ecef; }
.wb-right { height: calc(100vh - 280px); }
}
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
<div style="font-size:18px;margin-bottom:8px">正在加载地图...</div>
<div style="font-size:13px;opacity:.7">请稍候</div>
</div>
<div class="wb-layout">
<!-- 左侧时间线 -->
<div class="wb-left">
<div class="wb-tl-header">行程时间线</div>
<div id="timeline"></div>
</div>
<!-- 中间详情面板 -->
<div class="wb-mid">
<div class="wm-header">
<div class="wm-title" id="detail-title">${points[0]?.name || ''}</div>
<div class="wm-subtitle" id="detail-subtitle">${points[0]?.day || 'Day 1'} · ${points[0]?.km || '—'}km · 驾车${points[0]?.driveTime || '—'}</div>
</div>
<div class="wm-body" id="detail-body">
${generatePointDetail(points[0])}
</div>
</div>
<!-- 右侧地图 -->
<div class="wb-right">
<div id="map-container">
<img id="static-map" src="" alt="行程地图" />
<div id="map-markers"></div>
</div>
<div class="map-controls">
<button class="route-toggle" id="route-toggle" onclick="toggleRoute()">
🛣 显示路线
</button>
</div>
</div>
</div>
<script>
const points = ${JSON.stringify(points.map(p => ({
name: p.name,
day: p.day || 'Day 1',
lat: p.lat || 0,
lng: p.lng || 0,
km: p.km || '—',
driveTime: p.driveTime || '—',
desc: p.desc || '',
title: p.title || '',
content: p.content || '',
schedule: (p.schedule || []).map(s => ({
time: s.time || '',
title: s.title || s.content || '',
content: s.content || '',
desc: s.desc || ''
})),
waypoints: (p.waypoints || []).map(wp => ({
name: wp.name || '',
desc: wp.desc || '',
icon: wp.icon || '📍',
km: wp.km || '',
driveTime: wp.driveTime || ''
})),
foods: (p.foods || []).map(f => typeof f === 'string' ? { name: f, icon: '🍜', desc: '' } : {
name: f.name || '',
icon: f.icon || '🍜',
desc: f.desc || ''
}),
gallery: (p.gallery || []).map(g => ({
url: g.url || '',
title: g.title || ''
})),
hotel: p.hotel || '',
tips: p.tips || ''
})))};
let currentStep = 0;
let routeVisible = false;
let routeSvg = null;
let mapImageLoaded = false;
// 计算地图边界
function calcMapBounds() {
const validPoints = points.filter(p => p.lat && p.lng);
if (validPoints.length === 0) return { minLat: 25, maxLat: 25, minLng: 102, maxLng: 102 };
let minLat = validPoints[0].lat, maxLat = validPoints[0].lat;
let minLng = validPoints[0].lng, maxLng = validPoints[0].lng;
validPoints.forEach(p => {
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
if (p.lng < minLng) minLng = p.lng;
if (p.lng > maxLng) maxLng = p.lng;
});
// 添加 10% 边距
const latPadding = (maxLat - minLat) * 0.1 || 0.5;
const lngPadding = (maxLng - minLng) * 0.1 || 0.5;
return {
minLat: minLat - latPadding,
maxLat: maxLat + latPadding,
minLng: minLng - lngPadding,
maxLng: maxLng + lngPadding
};
}
// 将经纬度转换为图片坐标
function latLngToPixel(lat, lng, imgWidth, imgHeight, bounds) {
const x = ((lng - bounds.minLng) / (bounds.maxLng - bounds.minLng)) * imgWidth;
const y = ((bounds.maxLat - lat) / (bounds.maxLat - bounds.minLat)) * imgHeight;
return { x, y };
}
// 生成静态地图 URL使用静态地图 API
function generateStaticMapUrl() {
const bounds = calcMapBounds();
const centerLat = (bounds.minLat + bounds.maxLat) / 2;
const centerLng = (bounds.minLng + bounds.maxLng) / 2;
// 计算缩放级别
const latDiff = bounds.maxLat - bounds.minLat;
const lngDiff = bounds.maxLng - bounds.minLng;
const maxDiff = Math.max(latDiff, lngDiff);
let zoom = 8;
if (maxDiff > 10) zoom = 6;
else if (maxDiff > 5) zoom = 7;
else if (maxDiff > 2) zoom = 8;
else if (maxDiff > 1) zoom = 9;
else zoom = 10;
// 使用静态地图 API (HTTPS)
return 'https://staticmap.openstreetmap.de/staticmap.php?' +
'center=' + centerLat.toFixed(4) + ',' + centerLng.toFixed(4) +
'&zoom=' + zoom +
'&size=800x600' +
'&maptype=mapnik';
}
// 检查 URL 是否安全
function isSafeUrl(url) {
if (!url) return false;
// 只允许 http/https 协议的 URL
return url.startsWith('http://') || url.startsWith('https://');
}
// 过滤安全的图片 URL
function getSafeGallery(gallery) {
return (gallery || []).filter(g => isSafeUrl(g.url));
}
// 渲染地图标记
function renderMarkers() {
const container = document.getElementById('map-container');
const img = document.getElementById('static-map');
const markersDiv = document.getElementById('map-markers');
if (!mapImageLoaded) return;
const imgWidth = img.naturalWidth || img.offsetWidth;
const imgHeight = img.naturalHeight || img.offsetHeight;
if (imgWidth === 0 || imgHeight === 0) return;
const bounds = calcMapBounds();
markersDiv.innerHTML = '';
points.forEach((p, i) => {
if (!p.lat || !p.lng) return;
const pos = latLngToPixel(p.lat, p.lng, imgWidth, imgHeight, bounds);
const marker = document.createElement('div');
marker.className = 'marker-pin d' + i;
marker.style.position = 'absolute';
marker.style.left = (pos.x - 18) + 'px';
marker.style.top = (pos.y - 36) + 'px';
marker.style.cursor = 'pointer';
marker.style.zIndex = '100';
marker.innerHTML = '<span>' + i + '</span>';
marker.onclick = function() { selectPoint(i); };
const label = document.createElement('div');
label.className = 'marker-label';
label.style.position = 'absolute';
label.style.left = (pos.x - 30) + 'px';
label.style.top = (pos.y + 5) + 'px';
label.style.textAlign = 'center';
label.textContent = p.name;
markersDiv.appendChild(marker);
markersDiv.appendChild(label);
});
}
// 绘制路线
function drawRoute() {
const container = document.getElementById('map-container');
const img = document.getElementById('static-map');
if (!mapImageLoaded) return;
const imgWidth = img.naturalWidth || img.offsetWidth;
const imgHeight = img.naturalHeight || img.offsetHeight;
if (imgWidth === 0 || imgHeight === 0) return;
const bounds = calcMapBounds();
const validPoints = points.filter(p => p.lat && p.lng);
if (validPoints.length < 2) return;
// 移除旧路线
if (routeSvg) routeSvg.remove();
// 创建 SVG 覆盖层
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.pointerEvents = 'none';
svg.style.zIndex = '50';
// 创建路线 polyline
const polylinePoints = validPoints.map(p => {
const pos = latLngToPixel(p.lat, p.lng, imgWidth, imgHeight, bounds);
return pos.x + ',' + pos.y;
}).join(' ');
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
polyline.setAttribute('points', polylinePoints);
polyline.setAttribute('fill', 'none');
polyline.setAttribute('stroke', '#6c5ce7');
polyline.setAttribute('stroke-width', '3');
polyline.setAttribute('stroke-dasharray', '8,6');
polyline.setAttribute('stroke-opacity', '0.8');
svg.appendChild(polyline);
container.appendChild(svg);
routeSvg = svg;
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.classList.add('hidden');
}
function initMap() {
const img = document.getElementById('static-map');
const mapUrl = generateStaticMapUrl();
img.onload = function() {
mapImageLoaded = true;
renderMarkers();
drawRoute();
hideLoading();
};
img.onerror = function() {
// 如果静态地图加载失败,使用备用方案
const container = document.getElementById('map-container');
container.innerHTML = '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e8e4ff,#d4c8ff);color:#6c5ce7;font-size:16px;">地图加载中...</div>';
hideLoading();
};
img.src = mapUrl;
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.classList.add('hidden');
}
function updateTimeline() {
const tl = document.getElementById('timeline');
let html = '';
points.forEach(function(loc, i) {
const cls = i === currentStep ? 'active' : (i < currentStep ? 'completed' : '');
html += '<div class="wb-tl-item ' + cls + '" data-idx="' + i + '">';
html += '<div class="wb-tl-dot"></div>';
html += '<div class="wb-tl-info">';
html += '<div class="wb-tl-name">' + loc.name + '</div>';
html += '<div class="wb-tl-day">' + (loc.km || '—') + 'km · ' + (loc.day || 'Day ' + (i + 1)) + '</div>';
html += '</div></div>';
});
tl.innerHTML = html;
tl.querySelectorAll('.wb-tl-item').forEach(function(el) {
el.addEventListener('click', function() {
selectPoint(parseInt(this.getAttribute('data-idx')));
});
});
}
function selectPoint(idx) {
currentStep = idx;
updateTimeline();
const p = points[idx];
document.getElementById('detail-title').textContent = p.name;
document.getElementById('detail-subtitle').textContent = p.day + ' · ' + p.km + 'km · 驾车' + p.driveTime;
document.getElementById('detail-body').innerHTML = generatePointDetail(p);
// 高亮对应标记
document.querySelectorAll('.marker-pin').forEach((m, i) => {
m.style.transform = i === idx ? 'rotate(-45deg) scale(1.3)' : 'rotate(-45deg)';
});
}
function toggleRoute() {
routeVisible = !routeVisible;
const btn = document.getElementById('route-toggle');
if (routeVisible) {
drawRoute();
btn.innerHTML = '🛣️ 隐藏路线';
} else {
if (routeSvg) {
routeSvg.remove();
routeSvg = null;
}
btn.innerHTML = '🛣️ 显示路线';
}
}
window.selectPoint = selectPoint;
window.toggleRoute = toggleRoute;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
initMap();
updateTimeline();
});
} else {
initMap();
updateTimeline();
}
</script>
</body>
</html>`
}

@ -0,0 +1,99 @@
// 行程导出功能
export function exportToMarkdown(scheme, filename = null) {
if (!scheme) return
const md = generateMarkdown(scheme)
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename || `${scheme.name || '行程规划'}.md`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function generateMarkdown(scheme) {
let md = `# ${scheme.name || '行程规划'}\n\n`
// 基本信息
md += `## 基本信息\n\n`
md += `- **路线**${scheme.route || '—'}\n`
md += `- **天数**${scheme.days || '—'}\n`
md += `- **总里程**~${scheme.totalKm || '—'}km\n`
md += `- **驾驶时间**~${scheme.totalDriveTime || '—'}小时\n`
md += `- **预算**${scheme.budget || '—'}\n\n`
// 亮点
if (scheme.highlights && scheme.highlights.length > 0) {
md += `## 行程亮点\n\n`
scheme.highlights.forEach(h => {
md += `- ${h}\n`
})
md += '\n'
}
// 每日行程
if (scheme.daysDetail && scheme.daysDetail.length > 0) {
md += `## 每日行程\n\n`
scheme.daysDetail.forEach((day, idx) => {
md += `### Day ${idx + 1}${day.location || '未知地点'}\n\n`
if (day.desc) md += `${day.desc}\n\n`
if (day.km) md += `- **里程**${day.km}km\n`
if (day.driveTime) md += `- **驾驶时间**${day.driveTime}h\n\n`
if (day.spots && day.spots.length > 0) {
md += `**途经景点**${day.spots.join('、')}\n\n`
}
// 详细日程
if (day.schedule && day.schedule.length > 0) {
md += `#### 日程安排\n\n`
md += `| 时间 | 行程 | 说明 |\n`
md += `|------|------|------|\n`
day.schedule.forEach(s => {
md += `| ${s.time || '—'} | ${s.title || s.content || '—'} | ${s.desc || '—'} |\n`
})
md += '\n'
}
// 途径推荐
if (day.waypoints && day.waypoints.length > 0) {
md += `#### 途径推荐\n\n`
day.waypoints.forEach(wp => {
md += `- **${wp.icon || '📍'} ${wp.name}**${wp.desc || '—'}\n`
})
md += '\n'
}
// 美食推荐
if (day.foods && day.foods.length > 0) {
md += `#### 美食推荐\n\n`
day.foods.forEach(f => {
const name = typeof f === 'string' ? f : (f.name || f)
const icon = typeof f === 'object' ? (f.icon || '🍜') : '🍜'
const desc = typeof f === 'object' ? (f.desc || '') : ''
md += `- ${icon} **${name}**${desc ? `${desc}` : ''}\n`
})
md += '\n'
}
// 住宿
if (day.hotel) {
md += `**住宿推荐**${day.hotel}\n\n`
}
md += '---\n\n'
})
}
// 旅行贴士
if (scheme.tips) {
md += `## 旅行贴士\n\n${scheme.tips}\n\n`
}
md += `> 由智能行程规划系统生成\n`
return md
}

@ -0,0 +1,52 @@
// 使用统计服务
import { useAuthStore } from '../stores/auth'
const API_BASE = 'http://localhost:3001/api'
export function trackEvent(eventType, eventData = {}) {
const authStore = useAuthStore()
const payload = {
userId: authStore.user?.id || null,
guestId: authStore.guestId || null,
eventType,
eventData
}
// 异步发送,不阻塞主流程
fetch(`${API_BASE}/stats/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).catch(err => console.error('统计记录失败:', err))
}
// 页面访问统计
export function trackPageView(pageName, extra = {}) {
trackEvent('page_view', { page: pageName, ...extra })
}
// 规划创建统计
export function trackPlanCreate(planName, mode) {
trackEvent('plan_create', { planName, mode })
}
// 规划加载统计
export function trackPlanLoad(planId) {
trackEvent('plan_load', { planId })
}
// 规划导出统计
export function trackPlanExport(planId, format) {
trackEvent('plan_export', { planId, format })
}
// 聊天开始统计
export function trackChatStart(mode) {
trackEvent('chat_start', { mode })
}
// 方案选择统计
export function trackSchemeSelect(schemeName, schemeIndex) {
trackEvent('scheme_select', { schemeName, schemeIndex })
}

@ -0,0 +1,218 @@
// 用户认证状态管理
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const API_BASE = 'http://localhost:3001/api'
export const useAuthStore = defineStore('auth', () => {
// 状态
const user = ref(null)
const token = ref(localStorage.getItem('auth_token') || null)
const guestId = ref(localStorage.getItem('guest_id') || null)
const guestExpiresAt = ref(localStorage.getItem('guest_expires_at') || null)
const loading = ref(false)
const error = ref(null)
// 计算属性
const isAuthenticated = computed(() => !!user.value && !!token.value)
const isGuest = computed(() => !!guestId.value && !user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const isGuestDataExpiring = computed(() => {
if (!guestExpiresAt.value) return false
const expires = new Date(guestExpiresAt.value)
const now = new Date()
const hoursLeft = (expires - now) / (1000 * 60 * 60)
return hoursLeft < 2 // 少于2小时提醒
})
const isGuestDataExpired = computed(() => {
if (!guestExpiresAt.value) return false
return new Date(guestExpiresAt.value) < new Date()
})
// 方法
function setAuthHeader() {
if (token.value) {
return { 'Authorization': `Bearer ${token.value}` }
}
return {}
}
async function login(username, password) {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '登录失败')
user.value = data.user
token.value = data.token
localStorage.setItem('auth_token', data.token)
// 如果有游客数据,提示迁移
return { success: true, user: data.user }
} catch (err) {
error.value = err.message
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
async function register(username, email, password) {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password })
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '注册失败')
user.value = data.user
token.value = data.token
localStorage.setItem('auth_token', data.token)
return { success: true, user: data.user }
} catch (err) {
error.value = err.message
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
function logout() {
user.value = null
token.value = null
localStorage.removeItem('auth_token')
}
async function initGuestSession() {
// 检查现有游客会话是否过期
if (guestId.value && guestExpiresAt.value) {
const expires = new Date(guestExpiresAt.value)
if (expires > new Date()) {
return guestId.value // 会话仍有效
}
// 过期了,清除
clearGuestSession()
}
// 创建新游客会话
loading.value = true
try {
const res = await fetch(`${API_BASE}/auth/guest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '创建游客会话失败')
guestId.value = data.guestId
guestExpiresAt.value = data.expiresAt
localStorage.setItem('guest_id', data.guestId)
localStorage.setItem('guest_expires_at', data.expiresAt)
return data.guestId
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
function clearGuestSession() {
guestId.value = null
guestExpiresAt.value = null
localStorage.removeItem('guest_id')
localStorage.removeItem('guest_expires_at')
}
async function migrateGuestData() {
if (!token.value || !guestId.value) return
loading.value = true
try {
const res = await fetch(`${API_BASE}/auth/migrate-guest-data`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...setAuthHeader()
},
body: JSON.stringify({ guestId: guestId.value })
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || '数据迁移失败')
clearGuestSession()
return data
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function loadCurrentUser() {
if (!token.value) return
loading.value = true
try {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: setAuthHeader()
})
if (res.ok) {
const data = await res.json()
user.value = data.user
} else {
// Token 无效,清除
logout()
}
} catch (err) {
console.error('加载用户信息失败:', err)
} finally {
loading.value = false
}
}
// 初始化时加载用户信息
async function init() {
if (token.value) {
await loadCurrentUser()
} else {
await initGuestSession()
}
}
return {
user,
token,
guestId,
guestExpiresAt,
loading,
error,
isAuthenticated,
isGuest,
isAdmin,
isGuestDataExpiring,
isGuestDataExpired,
login,
register,
logout,
initGuestSession,
clearGuestSession,
migrateGuestData,
loadCurrentUser,
init,
setAuthHeader
}
})

@ -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,14 +145,17 @@ export const useItineraryStore = defineStore('itinerary', () => {
points.value.splice(toIndex, 0, item)
currentStep.value = toIndex
routeSegments.value = []
recalcBadges()
checkRouteOptimization()
}
async function fetchRealRoutes() {
if (isFetchingRoutes.value) return
if (isFetchingRoutes.value || points.value.length < 2) return
isFetchingRoutes.value = true
try {
console.log('[fetchRealRoutes] 开始获取真实路线,站点数:', points.value.length)
routeSegments.value = await getMultiRoute(points.value)
console.log('[fetchRealRoutes] 获取到路线段数:', routeSegments.value.length)
detourWarnings.value = detectDetour(routeSegments.value)
if (detourWarnings.value.length > 0) {
showOptimizationAlert.value = true
@ -188,40 +225,121 @@ export const useItineraryStore = defineStore('itinerary', () => {
}
function loadFromAI(scheme) {
const icons = ['🏙️', '🌿', '🏘️', '🏔️', '🏯', '💧', '🌾', '⛰️', '🏖️', '🛕', '🌸', '🏝️', '✈️']
const newPoints = scheme.points.map((p, i) => ({
id: 'p_' + Date.now() + '_' + i,
name: p.name,
lat: p.lat,
lng: p.lng,
day: p.day || `Day ${i}`,
badge: p.badge || (i === 0 ? 'START' : (i === scheme.points.length - 1 ? 'END' : `D${i}`)),
km: p.km || (i === 0 ? '0km' : `${Math.round(Math.random() * 300 + 50)}km`),
driveTime: p.driveTime || (i === 0 ? '—' : `${Math.round(Math.random() * 5 + 1)}h`),
desc: p.desc || '',
icon: p.icon || icons[i % icons.length],
images: p.images || [
`https://picsum.photos/400/300?random=${Date.now() + i * 3}`,
`https://picsum.photos/400/300?random=${Date.now() + i * 3 + 1}`
],
heroImage: p.heroImage || `https://picsum.photos/800/400?random=${Date.now() + i * 3 + 2}`,
schedule: p.schedule || [
{ time: '上午', content: '抵达目的地,开始探索' },
{ time: '下午', content: '游览主要景点' },
{ time: '晚上', content: '品尝当地美食' }
],
foods: p.foods || ['当地特色美食'],
hotel: p.hotel || '推荐酒店',
tips: p.tips || '注意安全,提前预订'
}))
console.log('[loadFromAI] 开始加载方案:', scheme.name)
console.log('[loadFromAI] scheme.points:', scheme.points?.length, '个站点')
console.log('[loadFromAI] scheme.points 详情:', JSON.stringify(scheme.points?.map(p => ({ name: p.name, lat: p.lat, lng: p.lng }))))
points.value = newPoints
currentStep.value = 0
routeSegments.value = []
showOptimizationAlert.value = false
showReplacementPanel.value = false
selectedForReplace.value = null
hoveredReplacement.value = null
if (!scheme.points || !Array.isArray(scheme.points) || scheme.points.length === 0) {
console.error('[loadFromAI] 方案缺少 points 数据,使用示例数据')
resetToSample()
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) => {
const point = {
id: 'p_' + Date.now() + '_' + i,
name: p.name || '未知地点',
lat: p.lat || 0,
lng: p.lng || 0,
day: p.day || `Day ${i + 1}`,
badge: p.badge || (i === 0 ? 'START' : (i === scheme.points.length - 1 ? 'END' : `D${i}`)),
km: p.km || (i === 0 ? '0km' : `${Math.round(Math.random() * 300 + 50)}km`),
driveTime: p.driveTime || (i === 0 ? '—' : `${Math.round(Math.random() * 5 + 1)}h`),
desc: p.desc || '',
icon: p.icon || icons[i % icons.length],
images: p.images || [
`https://picsum.photos/400/300?random=${Date.now() + i * 3}`,
`https://picsum.photos/400/300?random=${Date.now() + i * 3 + 1}`
],
heroImage: p.heroImage || `https://picsum.photos/800/400?random=${Date.now() + i * 3 + 2}`,
schedule: (p.schedule || [
{ time: '上午', content: '抵达目的地,开始探索' },
{ time: '下午', content: '游览主要景点' },
{ time: '晚上', content: '品尝当地美食' }
]).map(s => ({
time: s.time,
title: s.title || s.content,
content: s.content,
desc: s.desc || ''
})),
foods: (p.foods || ['当地特色美食']).map(f => {
if (typeof f === 'string') return { name: f, icon: '🍜', desc: '' }
return { name: f.name || f, icon: f.icon || '🍜', desc: f.desc || '' }
}),
hotel: p.hotel || '推荐酒店',
tips: p.tips || '注意安全,提前预订',
}
// 安全生成 waypoints
try {
point.waypoints = p.waypoints || generateWaypoints(p.name, i)
} catch (e) {
console.warn(`[loadFromAI] 生成 waypoints 失败 for ${p.name}:`, e)
point.waypoints = []
}
// 安全生成 gallery
try {
point.gallery = p.gallery || generateGallery(p.name, p.lat, p.lng, i)
} catch (e) {
console.warn(`[loadFromAI] 生成 gallery 失败 for ${p.name}:`, e)
point.gallery = []
}
return point
})
console.log('[loadFromAI] 加载完成newPoints 长度:', newPoints.length)
console.log('[loadFromAI] newPoints 列表:', JSON.stringify(newPoints.map(p => ({ id: p.id, name: p.name }))))
points.value = newPoints
console.log('[loadFromAI] points.value 赋值后长度:', points.value.length)
console.log('[loadFromAI] points.value 站点列表:', points.value.map(p => p.name))
console.log('[loadFromAI] === loadFromAI 完成 ===')
currentStep.value = 0
routeSegments.value = []
showOptimizationAlert.value = false
showReplacementPanel.value = false
selectedForReplace.value = null
hoveredReplacement.value = null
} catch (error) {
console.error('[loadFromAI] 加载方案失败:', error)
resetToSample()
}
}
function loadSchemeByIndex(index) {
@ -231,8 +349,13 @@ export const useItineraryStore = defineStore('itinerary', () => {
}
function saveSchemesToStore(schemes, historyGroups) {
console.log('[saveSchemesToStore] 保存方案数:', schemes.length)
schemes.forEach((s, i) => {
console.log(`[saveSchemesToStore] 方案 ${i} - ${s.name}: points=${s.points?.length}`)
})
quickSchemes.value = JSON.parse(JSON.stringify(schemes))
historySchemeGroups.value = JSON.parse(JSON.stringify(historyGroups))
console.log('[saveSchemesToStore] quickSchemes.value 长度:', quickSchemes.value.length)
}
function setActiveSchemeIndex(index) {
@ -249,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
}
@ -262,3 +385,88 @@ function calcDist(lat1, lng1, lat2, lng2) {
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLng/2)**2
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
// 生成途径推荐(基于目的地名称)
function generateWaypoints(name, index) {
const waypointDB = {
'昆明': [
{ name: '滇池', desc: '云南最大淡水湖,冬季可观红嘴鸥', icon: '💧' },
{ name: '石林', desc: '世界自然遗产,喀斯特地貌奇观', icon: '⛰️' }
],
'大理': [
{ name: '洱海', desc: '高原明珠,骑行环湖绝佳路线', icon: '💧' },
{ name: '喜洲古镇', desc: '白族民居典范,体验扎染文化', icon: '🏘️' }
],
'丽江': [
{ name: '束河古镇', desc: '比大研古镇更安静,纳西文化', icon: '🏘️' },
{ name: '拉市海', desc: '湿地公园,骑马茶马古道体验', icon: '🌾' }
],
'香格里拉': [
{ name: '松赞林寺', desc: '云南最大藏传佛教寺庙', icon: '🛕' },
{ name: '纳帕海', desc: '季节性湖泊,草原风光', icon: '🌾' }
],
'成都': [
{ name: '锦里古街', desc: '三国文化与成都民俗', icon: '🏘️' },
{ name: '大熊猫基地', desc: '近距离观赏国宝', icon: '🐼' }
],
'重庆': [
{ name: '洪崖洞', desc: '巴渝传统建筑,夜景绝佳', icon: '🏙️' },
{ name: '长江索道', desc: '万里长江第一条跨江索道', icon: '🚠' }
]
}
return waypointDB[name] || []
}
// 生成风景照片画廊(使用 Unsplash API 获取当地景点图片)
function generateGallery(name, lat, lng, index) {
const landmarks = {
'昆明': [
{ title: '滇池风光', query: 'dianchi lake kunming' },
{ title: '翠湖公园', query: 'green lake park kunming' },
{ title: '石林奇观', query: 'stone forest yunnan' },
{ title: '昆明市区', query: 'kunming city china' }
],
'大理': [
{ title: '洱海日出', query: 'erhai lake dali sunrise' },
{ title: '大理古城', query: 'dali ancient town' },
{ title: '苍山雪景', query: 'cangshan mountain dali' },
{ title: '喜洲白族民居', query: 'xizhou bai architecture' }
],
'丽江': [
{ title: '丽江古城', query: 'lijiang old town' },
{ title: '玉龙雪山', query: 'jade dragon snow mountain' },
{ title: '束河古镇', query: 'shuhe ancient town' },
{ title: '泸沽湖', query: 'lugu lake yunnan' }
],
'香格里拉': [
{ title: '普达措国家公园', query: 'potatso national park' },
{ title: '松赞林寺', query: 'songzanlin monastery' },
{ title: '梅里雪山', query: 'meili snow mountain' },
{ title: '虎跳峡', query: 'tiger leaping gorge' }
],
'成都': [
{ title: '武侯祠', query: 'wuhou temple chengdu' },
{ title: '锦里古街', query: 'jinli street chengdu' },
{ title: '都江堰', query: 'dujiangyan irrigation' },
{ title: '青城山', query: 'qingcheng mountain' }
],
'重庆': [
{ title: '洪崖洞夜景', query: 'hongyadong chongqing night' },
{ title: '长江索道', query: 'yangtze river cableway' },
{ title: '解放碑', query: 'jiefangbei chongqing' },
{ title: '武隆天坑', query: 'wulong karst chongqing' }
]
}
const locationLandmarks = landmarks[name] || [
{ title: `${name}风光`, query: `${name} china travel` },
{ title: `${name}景点`, query: `${name} scenic` },
{ title: `${name}美食`, query: `${name} food china` },
{ title: `${name}文化`, query: `${name} culture` }
]
return locationLandmarks.map((landmark, i) => ({
title: landmark.title,
url: `https://source.unsplash.com/400x300/?${encodeURIComponent(landmark.query)}&sig=${index * 10 + i}`
}))
}

@ -0,0 +1,444 @@
<template>
<div class="admin-page">
<div class="admin-header">
<h1>📊 管理后台 <span class="header-slogan">说走就走剩下的交给我</span></h1>
<button class="back-btn" @click="$router.push('/')"></button>
</div>
<div v-if="loading" class="admin-loading">...</div>
<template v-else>
<!-- 概览卡片 -->
<div class="overview-grid">
<div class="stat-card">
<div class="stat-value">{{ overview.totalUsers }}</div>
<div class="stat-label">总用户数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.totalPlans }}</div>
<div class="stat-label">总行程数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayActiveUsers }}</div>
<div class="stat-label">今日活跃用户</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayActiveGuests }}</div>
<div class="stat-label">今日活跃游客</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.todayEvents }}</div>
<div class="stat-label">今日事件数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ overview.totalEvents }}</div>
<div class="stat-label">总事件数</div>
</div>
</div>
<div class="admin-content">
<!-- 事件类型统计 -->
<div class="admin-section">
<h2>事件类型统计近30天</h2>
<table class="data-table">
<thead>
<tr>
<th>事件类型</th>
<th>次数</th>
<th>占比</th>
</tr>
</thead>
<tbody>
<tr v-for="event in events" :key="event.event_type">
<td>{{ getEventLabel(event.event_type) }}</td>
<td>{{ event.count }}</td>
<td>{{ getPercentage(event.count) }}%</td>
</tr>
</tbody>
</table>
</div>
<!-- 用户列表 -->
<div class="admin-section">
<h2>用户列表</h2>
<table class="data-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>行程数</th>
<th>活跃次数</th>
<th>最后登录</th>
<th>注册时间</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.username }}</td>
<td>
<span class="role-badge" :class="user.role">{{ user.role === 'admin' ? '管理员' : '用户' }}</span>
</td>
<td>{{ user.plan_count }}</td>
<td>{{ user.event_count }}</td>
<td>{{ user.last_active ? formatDate(user.last_active) : '未活跃' }}</td>
<td>{{ formatDate(user.created_at) }}</td>
</tr>
</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>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
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)
onMounted(async () => {
//
if (!authStore.isAdmin) {
alert('需要管理员权限')
router.push('/')
return
}
await loadData()
})
async function loadData() {
loading.value = true
try {
const API_BASE = 'http://localhost:3001/api'
const headers = { 'Authorization': `Bearer ${authStore.token}` }
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`)
])
overview.value = overviewData.overview
events.value = eventsData.events
totalEventsCount.value = eventsData.events.reduce((sum, e) => sum + e.count, 0)
users.value = usersData.users
usageStats.value = usageData
modelStats.value = modelsData
} catch (err) {
console.error('加载数据失败:', err)
alert('加载数据失败')
} finally {
loading.value = false
}
}
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': '页面访问',
'plan_create': '创建行程',
'plan_load': '加载行程',
'plan_export': '导出行程',
'chat_start': '开始聊天',
'scheme_select': '选择方案',
'user_register': '用户注册',
'user_login': '用户登录',
'guest_data_migrated': '游客数据迁移',
'chat': 'AI 对话',
'gather': '需求收集',
'generate': '方案生成',
'quick_plan': '快速规划',
'custom_plan': '自定义规划',
'replace': 'AI 替换',
'enrich': 'AI 充实',
'reorganize': 'AI 重排'
}
return labels[type] || type
}
function getPercentage(count) {
if (totalEventsCount.value === 0) return 0
return ((count / totalEventsCount.value) * 100).toFixed(1)
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.admin-page {
min-height: 100vh;
background: #f5f7fa;
padding: 20px;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.admin-header h1 {
font-size: 28px;
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;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
}
.back-btn:hover {
opacity: 0.9;
}
.admin-loading {
text-align: center;
padding: 40px;
color: #636e72;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 24px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: #6c5ce7;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #636e72;
}
.admin-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.admin-section {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.admin-section h2 {
font-size: 18px;
color: #2d3436;
margin-bottom: 16px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
.data-table th {
font-size: 13px;
font-weight: 600;
color: #636e72;
background: #f5f7fa;
}
.data-table td {
font-size: 14px;
color: #2d3436;
}
.data-table tr:hover {
background: #f8f9ff;
}
.role-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.role-badge.admin {
background: #ffe0e0;
color: #d63031;
}
.role-badge.user {
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>

@ -1,176 +1,418 @@
<template>
<!-- Mode selection page -->
<div v-if="showModeSelection" class="mode-selection">
<div class="selection-header">
<h1>智能行程规划</h1>
<p>选择一种方式开始你的旅行规划</p>
</div>
<div class="mode-cards">
<div class="mode-card chat-mode" @click="selectMode('chat')">
<div class="mode-icon">💬</div>
<h3>聊天问答式</h3>
<p>通过对话交流我逐步了解你的需求为你量身定制完美行程</p>
<div class="mode-features">
<span>多轮对话</span>
<span>逐步收集信息</span>
<span>个性化规划</span>
</div>
<div class="home-page">
<!-- Top Navigation Bar -->
<nav class="top-bar">
<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">
<span class="user-avatar-small">{{ authStore.user?.username?.charAt(0)?.toUpperCase() }}</span>
<span class="user-name-btn">{{ authStore.user?.username }}</span>
<span class="toggle-arrow">{{ showUserProfile ? '▲' : '▼' }}</span>
</button>
<template v-else>
<button class="user-toggle-btn guest" @click="showHistory = true">
<span class="user-avatar-small">👤</span>
<span class="user-name-btn">游客</span>
</button>
<button class="login-small-btn" @click="showAuthModal = true">登录</button>
</template>
<button v-if="!showModeSelection" class="home-btn" @click="goHome"></button>
</div>
<div class="mode-card quick-mode" @click="selectMode('quick')">
<div class="mode-icon"></div>
<h3>快速规划</h3>
<p>输入目的地和天数一键生成多个旅行方案供你选择</p>
<div class="mode-features">
<span>快速生成</span>
<span>多方案对比</span>
<span>即输即得</span>
</nav>
<!-- Main Content -->
<div class="container">
<!-- Mode selection page -->
<div v-if="showModeSelection" class="home-section">
<div class="home-header">
<h1>智能行程规划</h1>
<p>选择一种方式开始你的旅行规划</p>
</div>
</div>
<div class="mode-card custom-mode" @click="selectMode('custom')">
<div class="mode-icon"></div>
<h3>自定义行程</h3>
<p>输入你已规划的行程AI 评估合理性并给出优化建议</p>
<div class="mode-features">
<span>行程评估</span>
<span>智能建议</span>
<span>优化方案</span>
<div class="mode-grid">
<div class="mode-card quick-card" @click="selectMode('quick')">
<div class="mc-icon"></div>
<h3>快速规划</h3>
<p>输入目的地和天数一键生成多个旅行方案供你选择</p>
<div class="mc-tags"><span class="mc-tag">快速生成</span><span class="mc-tag">多方案对比</span><span class="mc-tag">即输即得</span></div>
</div>
<div class="mode-card chat-card" @click="selectMode('chat')">
<div class="mc-icon"></div>
<h3>聊天问答式</h3>
<p>通过对话交流我逐步了解你的需求为你量身定制完美行程</p>
<div class="mc-tags"><span class="mc-tag">多轮对话</span><span class="mc-tag">逐步收集信息</span><span class="mc-tag">个性化规划</span></div>
</div>
<div class="mode-card custom-card" @click="selectMode('custom')">
<div class="mc-icon"></div>
<h3>自定义行程</h3>
<p>输入你已规划的行程AI 评估合理性并给出优化建议</p>
<div class="mc-tags"><span class="mc-tag">行程评估</span><span class="mc-tag">智能建议</span><span class="mc-tag">优化方案</span></div>
</div>
</div>
</div>
<!-- Planning mode components -->
<ChatInterface v-else-if="store.phase === 'chat' && store.planningMode === 'chat'" />
<QuickPlanPanel v-else-if="store.phase === 'chat' && store.planningMode === 'quick'" />
<CustomPlanPanel v-else-if="store.phase === 'chat' && store.planningMode === 'custom'" />
<ComparisonView v-else-if="store.phase === 'comparison'" />
<Workbench v-else />
</div>
</div>
<!-- Planning mode components -->
<ChatInterface v-else-if="store.phase === 'chat' && store.planningMode === 'chat'" />
<QuickPlanPanel v-else-if="store.phase === 'chat' && store.planningMode === 'quick'" />
<CustomPlanPanel v-else-if="store.phase === 'chat' && store.planningMode === 'custom'" />
<ComparisonView v-else-if="store.phase === 'comparison'" />
<Workbench v-else />
<!-- 用户管理面板已登录用户 -->
<UserProfilePanel v-if="authStore.isAuthenticated" v-model:visible="showUserProfile" @load="showUserProfile = false" />
<!-- 历史记录面板游客 -->
<HistoryPanel v-if="!authStore.isAuthenticated && showHistory" @close="showHistory = false" @load="showHistory = false" />
<!-- 登录/注册模态框 -->
<AuthModal v-model:visible="showAuthModal" @success="handleAuthSuccess" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { ref, computed } from 'vue'
import { useItineraryStore } from '../stores/itinerary'
import { useAuthStore } from '../stores/auth'
import ChatInterface from '../components/ChatInterface.vue'
import ComparisonView from '../components/ComparisonView.vue'
import Workbench from '../components/Workbench.vue'
import QuickPlanPanel from '../components/QuickPlanPanel.vue'
import CustomPlanPanel from '../components/CustomPlanPanel.vue'
import HistoryPanel from '../components/HistoryPanel.vue'
import AuthModal from '../components/AuthModal.vue'
import UserProfilePanel from '../components/UserProfilePanel.vue'
const store = useItineraryStore()
const authStore = useAuthStore()
const showHistory = ref(false)
const showUserProfile = ref(false)
const showAuthModal = ref(false)
// Show mode selection only when no planning has been started
const showModeSelection = computed(() => {
return store.phase === 'chat' && !store.planningMode
})
function handleAuthSuccess() {
if (authStore.guestId) {
if (confirm('检测到您有游客期间的行程数据,是否迁移到当前账户?')) {
authStore.migrateGuestData()
}
}
}
function toggleUserProfile() {
if (!showUserProfile.value) {
//
showUserProfile.value = true
} else {
//
showUserProfile.value = false
}
}
const selectMode = (mode) => {
store.setPlanningMode(mode)
}
const goHome = () => {
store.resetToModeSelection()
}
</script>
<style scoped>
.mode-selection {
.home-page {
min-height: 100vh;
background: #f5f7fa;
display: flex;
flex-direction: column;
min-height: 100vh;
background: #f5f5f5;
}
.selection-header {
text-align: center;
padding: 60px 20px 40px;
background: linear-gradient(135deg, #1b4332, #2d6a4f);
/* Top Bar */
.top-bar {
background: #fff;
padding: 12px 32px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
position: sticky;
top: 0;
z-index: 100;
}
.top-bar .brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
font-weight: 700;
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;
width: 24px;
height: 24px;
background: url('/favicon.svg') no-repeat center;
background-size: contain;
}
.top-bar-right {
display: flex;
align-items: center;
gap: 12px;
}
.user-toggle-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 20px;
border: 1.5px solid #6c5ce7;
background: #fff;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
font-weight: 500;
color: #2d3436;
}
.user-toggle-btn:hover {
background: #f8f9ff;
border-color: #5b4bd5;
}
.user-toggle-btn.guest {
border-color: #b2bec3;
color: #636e72;
}
.user-toggle-btn.guest:hover {
background: #f5f7fa;
}
.login-small-btn {
padding: 6px 14px;
border-radius: 20px;
border: 1.5px solid #6c5ce7;
background: #6c5ce7;
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.login-small-btn:hover {
background: #5b4bd5;
border-color: #5b4bd5;
}
.user-avatar-small {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.user-toggle-btn.guest .user-avatar-small {
background: #dfe6e9;
font-size: 14px;
}
.user-name-btn {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toggle-arrow {
font-size: 10px;
color: #b2bec3;
margin-left: 4px;
}
.admin-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1.5px solid #f39c12;
background: #fff;
color: #f39c12;
transition: all 0.2s;
font-weight: 500;
}
.admin-btn:hover {
background: #f39c12;
color: #fff;
}
.selection-header h1 {
margin: 0 0 10px;
font-size: 32px;
.home-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1.5px solid #6c5ce7;
background: #fff;
color: #6c5ce7;
transition: all 0.2s;
font-weight: 500;
}
.home-btn:hover {
background: #6c5ce7;
color: #fff;
}
/* Container */
.container {
flex: 1;
}
/* Home Section */
.home-section {
max-width: 1100px;
margin: 0 auto;
padding: 24px 32px;
}
.home-header {
text-align: center;
padding: 48px 0 36px;
}
.selection-header p {
margin: 0;
opacity: 0.85;
font-size: 16px;
.home-header h1 {
font-size: 28px;
font-weight: 800;
color: #2d3436;
margin-bottom: 8px;
}
.mode-cards {
.home-header p {
color: #636e72;
font-size: 15px;
}
/* Mode Grid */
.mode-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
max-width: 1000px;
margin: -40px auto 40px;
padding: 0 20px;
gap: 20px;
margin-top: 28px;
}
.mode-card {
background: #fff;
border-radius: 16px;
padding: 30px 24px;
border-radius: 14px;
padding: 28px 22px;
cursor: pointer;
transition: all 0.3s;
transition: all 0.25s;
border: 2px solid transparent;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.mode-card:hover {
transform: translateY(-6px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(108,92,231,0.12);
border-color: #6c5ce7;
}
.chat-mode:hover { border-color: #2a9d8f; }
.quick-mode:hover { border-color: #e76f51; }
.custom-mode:hover { border-color: #264653; }
.mc-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
margin-bottom: 14px;
}
.mode-icon {
font-size: 48px;
margin-bottom: 16px;
.quick-card .mc-icon {
background: #e8e4ff;
}
.mode-card h3 {
margin: 0 0 10px;
font-size: 20px;
color: #333;
.quick-card .mc-icon::before {
content: '⚡';
}
.mode-card > p {
margin: 0 0 16px;
font-size: 14px;
color: #666;
line-height: 1.6;
.chat-card .mc-icon {
background: #fff3e0;
}
.mode-features {
display: flex;
flex-wrap: wrap;
gap: 8px;
.chat-card .mc-icon::before {
content: '💬';
}
.mode-features span {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
.custom-card .mc-icon {
background: #e0f7fa;
}
.chat-mode .mode-features span {
background: #e8f5e9;
color: #2a9d8f;
.custom-card .mc-icon::before {
content: '✏️';
}
.quick-mode .mode-features span {
background: #fff3e0;
color: #e76f51;
.mode-card h3 {
font-size: 17px;
font-weight: 700;
margin-bottom: 6px;
color: #2d3436;
}
.custom-mode .mode-features span {
background: #e0f2f1;
color: #264653;
.mode-card p {
font-size: 13px;
color: #636e72;
margin-bottom: 14px;
min-height: 36px;
}
.mc-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mc-tag {
font-size: 11px;
padding: 3px 10px;
border-radius: 12px;
background: #f0f0f5;
color: #636e72;
}
@media (max-width: 768px) {
.mode-cards {
.mode-grid {
grid-template-columns: 1fr;
}
.top-bar {
padding: 12px 16px;
}
.home-section {
padding: 24px 16px;
}
}
</style>

@ -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>
@ -220,28 +221,38 @@ onMounted(async () => {
display: flex;
align-items: center;
padding: 12px 20px;
background: #1b4332;
color: #fff;
background: #fff;
color: #2d3436;
gap: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.back-btn {
background: rgba(255,255,255,0.1);
background: #f5f7fa;
border: none;
color: #fff;
color: #636e72;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.back-btn:hover { background: rgba(255,255,255,0.2); }
.back-btn:hover { background: #e8e8e8; }
.settings-header h1 {
margin: 0;
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 {
@ -278,9 +289,9 @@ onMounted(async () => {
align-items: center;
gap: 12px;
padding: 16px;
border: 2px solid #2a9d8f;
border: 2px solid #6c5ce7;
border-radius: 10px;
background: #f0f7f4;
background: #f0f0f5;
flex: 1;
}
@ -288,7 +299,7 @@ onMounted(async () => {
.provider-info { flex: 1; }
.provider-name { display: block; font-weight: 600; color: #333; }
.provider-desc { display: block; font-size: 12px; color: #888; }
.check-mark { color: #2a9d8f; font-size: 20px; font-weight: bold; }
.check-mark { color: #6c5ce7; font-size: 20px; font-weight: bold; }
/* Input */
.input-group { display: flex; flex-direction: column; gap: 8px; }
@ -302,7 +313,7 @@ onMounted(async () => {
transition: border-color 0.2s;
}
.input-wrapper:focus-within { border-color: #2a9d8f; }
.input-wrapper:focus-within { border-color: #6c5ce7; }
.api-input, .text-input {
flex: 1;
@ -328,7 +339,7 @@ onMounted(async () => {
}
.input-hint a {
color: #2a9d8f;
color: #6c5ce7;
text-decoration: none;
}
@ -349,11 +360,11 @@ onMounted(async () => {
transition: all 0.2s;
}
.model-card:hover { border-color: #b0d4c8; }
.model-card:hover { border-color: #b8b0f0; }
.model-card.selected {
border-color: #2a9d8f;
background: #f0f7f4;
border-color: #6c5ce7;
background: #f0f0f5;
}
.model-header {
@ -374,7 +385,7 @@ onMounted(async () => {
}
.model-radio.checked {
border-color: #2a9d8f;
border-color: #6c5ce7;
}
.model-radio.checked::after {
@ -382,7 +393,7 @@ onMounted(async () => {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2a9d8f;
background: #6c5ce7;
}
.model-name { font-weight: 500; color: #333; }
@ -434,7 +445,7 @@ onMounted(async () => {
width: 16px;
height: 16px;
border-radius: 50%;
background: #2a9d8f;
background: #6c5ce7;
cursor: pointer;
}
@ -442,7 +453,7 @@ onMounted(async () => {
min-width: 40px;
text-align: right;
font-weight: 600;
color: #2a9d8f;
color: #6c5ce7;
}
.item-desc { font-size: 11px; color: #aaa; }
@ -471,8 +482,8 @@ onMounted(async () => {
.test-btn:hover:not(:disabled) { background: #bbdefb; }
.test-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.save-btn { background: #2a9d8f; color: #fff; }
.save-btn:hover { background: #21867a; }
.save-btn { background: #6c5ce7; color: #fff; }
.save-btn:hover { background: #5b4cdb; }
.reset-btn { background: #f5f5f5; color: #666; }
.reset-btn:hover { background: #e0e0e0; }

@ -0,0 +1,649 @@
<template>
<div class="shared-plan-view">
<!-- Top bar (same as workbench) -->
<div class="wb-top">
<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>
<!-- Loading -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner"></div>
<p>加载分享行程中...</p>
</div>
<!-- Error -->
<div v-else-if="error" class="error-container">
<div class="error-icon"></div>
<h2>分享链接无效</h2>
<p>{{ error }}</p>
</div>
<!-- Workbench (read-only) -->
<div v-else-if="sharedData" class="workbench">
<div class="wb-wrap">
<!-- Left: Timeline -->
<div class="wb-left">
<div class="wl-title">📋 行程时间线</div>
<div
v-for="(point, idx) in store.points"
:key="point.id"
:class="['wl-item', { active: idx === store.currentStep }]"
@click="goToStep(idx)"
>
<div class="wl-dot"></div>
<div>
<div class="wl-name">{{ point.icon }} {{ point.name }}</div>
<div class="wl-day">{{ point.day }} · {{ point.km }}</div>
</div>
</div>
<div class="wl-nav">
<button class="prev" :disabled="store.currentStep <= 0" @click="goToStep(store.currentStep - 1)"> 上一站</button>
<button class="next" :disabled="store.currentStep >= store.points.length - 1" @click="goToStep(store.currentStep + 1)">下一站 </button>
</div>
</div>
<!-- Middle: Detail -->
<div class="wb-mid">
<template v-if="point">
<div class="wm-hero" v-if="point.heroImage">
<img :src="point.heroImage" :alt="point.name" @error="handleImageError" />
<div class="wm-ov">
<h3>{{ point.icon }} {{ point.name }}</h3>
<p>{{ point.desc }}</p>
</div>
</div>
<div v-if="point.gallery && point.gallery.length > 0" class="wm-gallery">
<h4>📸 风景照片</h4>
<div class="gallery-grid">
<div v-for="(img, i) in point.gallery" :key="i" class="gallery-item">
<img :src="img.url" :alt="img.title" @error="handleGalleryImageError" />
<div class="gallery-caption">{{ img.title }}</div>
</div>
</div>
</div>
<div class="wm-stats">
<div class="wm-stat"><div class="ws-val">{{ point.km || '—' }}</div><div class="ws-lbl">行驶里程</div></div>
<div class="wm-stat"><div class="ws-val">{{ point.driveTime || '—' }}</div><div class="ws-lbl">驾驶时间</div></div>
<div class="wm-stat"><div class="ws-val">{{ point.day }}</div><div class="ws-lbl">第几天</div></div>
</div>
<div v-if="point.schedule?.length" class="wm-sec">
<h4>📅 行程安排</h4>
<div v-for="(s, i) in point.schedule" :key="i" class="wm-si">
<span class="si-time">{{ s.time }}</span>
<div class="si-content">
<div class="si-title">{{ s.title || s.content }}</div>
<div v-if="s.desc" class="si-desc">{{ s.desc }}</div>
</div>
</div>
</div>
<div v-if="point.foods?.length" class="wm-sec">
<h4>🍽 美食推荐</h4>
<div class="foods-grid">
<div v-for="(f, i) in point.foods" :key="i" class="food-item">
<div class="food-icon">{{ f.icon }}</div>
<div class="food-info">
<div class="food-name">{{ f.name }}</div>
<div v-if="f.desc" class="food-desc">{{ f.desc }}</div>
</div>
</div>
</div>
</div>
<div v-if="point.waypoints?.length" class="wm-sec">
<h4>🗺 途径推荐</h4>
<div class="waypoint-list">
<div v-for="(wp, i) in point.waypoints" :key="i" class="waypoint-item">
<div class="wp-icon">{{ wp.icon }}</div>
<div class="wp-info">
<div class="wp-name">{{ wp.name }}</div>
<div v-if="wp.desc" class="wp-desc">{{ wp.desc }}</div>
</div>
</div>
</div>
</div>
<div class="wm-sec" v-if="point.hotel">
<h4>🏨 住宿推荐</h4>
<div class="hotel-info">{{ point.hotel }}</div>
</div>
<div class="wm-sec" v-if="point.tips">
<h4>💡 注意事项</h4>
<div class="wm-tips">{{ point.tips }}</div>
</div>
</template>
</div>
<!-- Right: Map -->
<div class="wb-right">
<MapView ref="mapView" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import MapView from '../components/MapView.vue'
import { useItineraryStore } from '../stores/itinerary'
const route = useRoute()
const store = useItineraryStore()
const mapView = ref(null)
const loading = ref(true)
const error = ref(null)
const sharedData = ref(null)
const point = computed(() => store.currentPoint)
onMounted(async () => {
const shareCode = route.params.shareCode
if (!shareCode) {
error.value = '无效的分享链接'
loading.value = false
return
}
try {
const res = await fetch(`http://localhost:3001/api/shares/${shareCode}`)
const data = await res.json()
if (!res.ok) {
error.value = data.error || '分享链接无效或已过期'
return
}
sharedData.value = data
if (data.data?.points) {
store.loadFromAI(data.data)
store.setPhase('workbench')
}
} catch (err) {
error.value = '加载分享行程失败'
console.error(err)
} finally {
loading.value = false
}
})
function handleImageError(e) {
e.target.parentElement.style.background = 'linear-gradient(135deg, #6c5ce7, #a29bfe)'
e.target.style.display = 'none'
}
function handleGalleryImageError(e) {
e.target.parentElement.style.display = 'none'
}
function copyShareLink() {
navigator.clipboard.writeText(window.location.href).then(() => {
alert('链接已复制')
}).catch(() => {
alert('复制失败')
})
}
// 线/
function goToStep(idx) {
store.setCurrentStep(idx)
}
// currentStep
watch(() => store.currentStep, (newIdx, oldIdx) => {
if (mapView.value && oldIdx !== undefined) {
mapView.value.updateMarkers()
const center = store.points[newIdx]
if (center?.lat && center?.lng) {
mapView.value.flyTo(center.lat, center.lng, 10)
}
}
})
</script>
<style scoped>
.shared-plan-view {
min-height: 100vh;
background: #f5f7fa;
}
/* Top bar - same as workbench */
.wb-top {
height: 60px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
position: fixed;
top: 0;
left: 0;
right: 0;
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;
gap: 8px;
}
.shared-badge {
background: #f0e6ff;
color: #6c5ce7;
padding: 6px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
}
.wb-copy-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 2px solid #6c5ce7;
background: #fff;
color: #6c5ce7;
transition: all 0.2s;
}
.wb-copy-btn:hover {
background: #6c5ce7;
color: #fff;
}
/* Loading */
.loading-container {
margin-top: 80px;
text-align: center;
padding: 80px 20px;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #e8e4ff;
border-top-color: #6c5ce7;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error */
.error-container {
margin-top: 80px;
text-align: center;
padding: 80px 20px;
}
.error-icon {
font-size: 64px;
margin-bottom: 16px;
}
.error-container h2 {
color: #d63031;
margin-bottom: 12px;
}
.error-container p {
color: #636e72;
}
/* Workbench - exact same styles */
.workbench {
display: flex;
flex-direction: column;
height: 100vh;
}
.wb-wrap {
display: grid;
grid-template-columns: 220px 300px 1fr;
gap: 14px;
height: calc(100vh - 74px);
padding: 74px 16px 16px;
}
/* Left sidebar */
.wb-left {
background: #2d3436;
border-radius: 12px;
color: #dfe6e9;
padding: 14px;
overflow-y: auto;
flex-shrink: 0;
}
.wl-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.wl-item {
display: flex;
gap: 10px;
padding: 10px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 6px;
}
.wl-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.wl-item.active {
background: rgba(108, 92, 231, 0.25);
box-shadow: 0 0 0 1px rgba(108, 92, 231, 0.5);
}
.wl-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6c5ce7;
margin-top: 5px;
flex-shrink: 0;
}
.wl-item.active .wl-dot {
background: #a29bfe;
box-shadow: 0 0 0 3px rgba(162, 155, 254, 0.3);
}
.wl-name {
font-size: 13px;
font-weight: 600;
color: #dfe6e9;
}
.wl-day {
font-size: 11px;
color: #b2bec3;
margin-top: 2px;
}
.wl-nav {
display: flex;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.wl-nav button {
flex: 1;
padding: 8px;
border: none;
border-radius: 6px;
background: rgba(108, 92, 231, 0.3);
color: #dfe6e9;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.wl-nav button:hover:not(:disabled) {
background: rgba(108, 92, 231, 0.5);
}
.wl-nav button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Middle detail */
.wb-mid {
background: #fff;
border-radius: 12px;
overflow-y: auto;
flex-shrink: 0;
}
.wm-hero {
position: relative;
height: 140px;
overflow: hidden;
border-radius: 12px 12px 0 0;
}
.wm-hero img {
width: 100%;
height: 100%;
object-fit: cover;
}
.wm-ov {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
color: #fff;
}
.wm-ov h3 {
margin: 0;
font-size: 16px;
}
.wm-ov p {
margin: 4px 0 0;
font-size: 11px;
opacity: 0.9;
}
.wm-gallery {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
.wm-gallery h4 {
font-size: 13px;
margin: 0 0 8px;
}
.gallery-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.gallery-item {
position: relative;
border-radius: 6px;
overflow: hidden;
aspect-ratio: 4/3;
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.gallery-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 4px 6px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 10px;
}
.wm-stats {
display: flex;
padding: 12px;
gap: 8px;
background: #f8f9ff;
border-bottom: 1px solid #e9ecef;
}
.wm-stat {
flex: 1;
text-align: center;
}
.ws-val {
font-size: 18px;
font-weight: 700;
color: #6c5ce7;
}
.ws-lbl {
font-size: 11px;
color: #636e72;
margin-top: 2px;
}
.wm-sec {
padding: 14px;
border-bottom: 1px solid #e9ecef;
}
.wm-sec h4 {
font-size: 14px;
margin: 0 0 10px;
color: #2d3436;
}
.wm-si {
display: flex;
gap: 10px;
padding: 10px 0;
border-top: 1px solid #f0f0f0;
}
.si-time {
font-size: 11px;
color: #6c5ce7;
font-weight: 600;
white-space: nowrap;
}
.si-title {
font-size: 13px;
font-weight: 600;
}
.si-desc {
font-size: 12px;
color: #636e72;
margin-top: 2px;
}
.foods-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.food-item {
background: #f8f9ff;
padding: 8px;
border-radius: 8px;
display: flex;
gap: 8px;
}
.food-icon {
font-size: 20px;
}
.food-name {
font-size: 12px;
font-weight: 600;
}
.food-desc {
font-size: 10px;
color: #636e72;
margin-top: 2px;
}
.waypoint-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.waypoint-item {
display: flex;
gap: 10px;
padding: 10px;
background: #f8f9ff;
border-radius: 8px;
}
.wp-icon {
font-size: 20px;
}
.wp-name {
font-size: 13px;
font-weight: 600;
}
.wp-desc {
font-size: 11px;
color: #636e72;
margin-top: 2px;
}
.hotel-info {
font-size: 13px;
color: #636e72;
line-height: 1.5;
}
.wm-tips {
font-size: 13px;
color: #636e72;
line-height: 1.6;
background: #fff8e1;
padding: 10px;
border-radius: 8px;
border-left: 3px solid #f4a261;
}
/* Right map */
.wb-right {
border-radius: 12px;
overflow: hidden;
position: relative;
}
</style>

@ -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