Files
tamakyi c5c2ffae68 TripSense 行知 · 周边出行顾问:首版
零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。

- 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底
- 区县级「排除同城」判定(adname),过滤发生在轮转取样之前
- 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图
- 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造
- 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存
- 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token
2026-11-05 20:25:03 +08:00

325 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="行知 TripSense:按时间与所在地分析周边适合出行的目的地,叠加天气与交通,并由 AI 生成逐日行程规划。">
<title>行知 · 周边出行顾问 | TripSense</title>
<link rel="stylesheet" href="styles.css?v=18">
</head>
<body>
<header class="topbar">
<div class="wrap topbar-inner">
<div class="brand">
<span class="brand-mark">◈</span>
<div class="brand-text"><strong>行知</strong><span>TripSense · 周边出行顾问</span></div>
</div>
<div class="topbar-meta">
<span class="status-pill" id="aiStatus"><i></i> 读取配置中…</span>
<button class="btn-ghost" id="openSettings">设置</button>
</div>
</div>
</header>
<main class="wrap">
<section class="hero">
<div class="hero-text">
<div class="eyebrow">周边出行 · 按时间 · 按位置 · 按天气与交通</div>
<h1>这个时间,去哪儿最合适?</h1>
<p>填入出行时间与出发地,系统检索周边目的地,叠加逐日天气、空气质量、公路与铁路可达性,给出可解释的备选清单。再补充你的具体需求,由 AI 生成可执行的逐日行程。</p>
</div>
<ol class="stepper" id="stepper">
<li class="active" data-step="1"><b>1</b><span>出行条件</span></li>
<li data-step="2"><b>2</b><span>备选清单</span></li>
<li data-step="3"><b>3</b><span>行程规划</span></li>
</ol>
</section>
<!-- ============================ STEP 1 ============================ -->
<section class="card step-panel active" id="panel1">
<div class="card-head">
<h2><span class="num">①</span> 出行条件</h2>
<p>出发地支持输入联想或浏览器定位;时间范围决定天气查询窗口与行程节奏。</p>
</div>
<div class="form-grid">
<div class="field span2 origin-field">
<label for="originInput">出发地 <em>*</em></label>
<div class="origin-row">
<input id="originInput" type="text" placeholder="输入城市 / 地名,如:杭州、成都、上海" autocomplete="off" spellcheck="false">
<button class="btn-outline" id="geoBtn" type="button">📍 使用当前位置</button>
</div>
<div class="suggest hidden" id="suggestBox"></div>
<p class="hint" id="originHint">可直接输入地名并从下拉中选择,或点击定位(浏览器会请求位置权限)。</p>
</div>
<div class="field">
<label for="startDate">开始日期 <em>*</em></label>
<input type="date" id="startDate">
</div>
<div class="field">
<label for="endDate">结束日期 <em>*</em></label>
<input type="date" id="endDate">
</div>
<div class="field span2 window-hint hidden" id="windowHint"></div>
<div class="field">
<label for="maxHours">可接受单程时长 <em id="hoursVal">2.5 小时</em></label>
<input type="range" id="maxHours" min="0.5" max="6" step="0.5" value="2.5">
<div class="range-scale"><span>0.5h</span><span>6h</span></div>
</div>
<div class="field">
<label for="radius">搜索半径</label>
<select id="radius">
<option value="50">50 km</option>
<option value="100">100 km</option>
<option value="150" selected>150 km</option>
<option value="250">250 km</option>
<option value="300">300 km</option>
</select>
</div>
<div class="field span2">
<label class="check inline-check">
<input type="checkbox" id="excludeLocal" checked>
排除出发地同城地点
<span class="label-note">剔除出发地所在城区的点位(同市下辖的其它县仍会作为周边目的地保留);想连本地一日游一起看就取消勾选</span>
</label>
</div>
<div class="field span2">
<label>交通方式 <span class="label-note">可多选,选「不限」会清空其他</span></label>
<div class="chips" id="transportChips"></div>
</div>
<div class="field span2">
<label>兴趣偏好 <span class="label-note">用于排序加成,可多选</span></label>
<div class="chips" id="interestChips"></div>
</div>
</div>
<div class="form-actions">
<button class="btn-primary" id="analyzeBtn" type="button">分析周边目的地 <span>→</span></button>
<span class="hint" id="analyzeHint">首次分析需检索多个数据源,通常 5~15 秒。</span>
</div>
<div class="progress-wrap hidden" id="progressWrap">
<div class="progress-head"><span id="progressText">准备中…</span><b id="progressPct">0%</b></div>
<div class="progress-bar"><i id="progressBar"></i></div>
<div class="progress-stages" id="progressStages"></div>
</div>
</section>
<!-- ============================ STEP 2 ============================ -->
<section class="card step-panel" id="panel2">
<div class="card-head row">
<div>
<h2><span class="num">②</span> 备选目的地清单</h2>
<p id="resultSub">按适配分排序,标注每一项的数据来源与估算内容。</p>
</div>
<div class="head-actions">
<button class="btn-outline sm" id="backBtn1" type="button">← 改条件</button>
<button class="btn-primary sm" id="toStep3" type="button">用已选目的地生成规划 →</button>
</div>
</div>
<div class="hidden" id="alertBox"></div>
<!-- 分析结果缓存提示:命中缓存时告知,并给一个强刷出口 -->
<div class="cache-bar hidden" id="cacheBar"></div>
<div class="result-top">
<div class="compass-panel">
<h3>出行方位罗盘 <span>点击圆点定位卡片</span></h3>
<div id="compass"></div>
<div class="legend">
<span><i class="lg good"></i>适配分 ≥ 60</span>
<span><i class="lg mid"></i>45 ~ 59</span>
<span><i class="lg low"></i>&lt; 45</span>
<span class="legend-note">方位=相对出发地,半径=距离</span>
</div>
</div>
<div class="summary-panel" id="summaryPanel"></div>
</div>
<div class="toolbar">
<div class="seg" id="sortSeg">
<button class="active" data-sort="score">综合评分</button>
<button data-sort="weather">天气最好</button>
<button data-sort="distance">距离最近</button>
<button data-sort="time">单程最短</button>
</div>
<label class="check"><input type="checkbox" id="onlyRec"> 仅显示「推荐」及以上</label>
<label class="check"><input type="checkbox" id="onlyNiche"> 只看小众</label>
<button class="btn-outline sm" id="compareBtn" type="button">对比已选</button>
<span class="selected-count" id="selectedCount">已选 0 个</span>
</div>
<div class="preselect-hint hidden" id="preselectHint">已自动预选评分最高的 3 个目的地,可点击卡片按钮取消或改选</div>
<div class="compare-panel hidden" id="comparePanel"></div>
<div class="cards" id="cards"></div>
<div class="notes" id="dataNotes"></div>
</section>
<!-- ============================ STEP 3 ============================ -->
<section class="card step-panel" id="panel3">
<div class="card-head row">
<div>
<h2><span class="num">③</span> 具体需求与 AI 行程规划</h2>
<p>清单数据 + 你的需求 + 本站预设提示词 → 任意 OpenAI 兼容接口(未配置时使用演示模式)。</p>
</div>
<button class="btn-outline sm" id="backBtn2" type="button">← 返回清单</button>
</div>
<div class="plan-grid">
<div class="plan-left">
<label>已选目的地 <span class="label-note" id="selHint">返回上一步可调整</span></label>
<div class="selected-chips" id="selectedChips"></div>
<label for="needs">你的具体需求</label>
<textarea id="needs" rows="9" placeholder="例如:带 6 岁孩子和一位老人,两天一晚,总预算 1500 元以内;不想走太多路;希望有室内备选;不自驾,优先高铁;想吃当地特色但别太辣……"></textarea>
<div class="lodging-row">
<span class="lodging-label">住宿价位</span>
<div class="chips" id="lodgingChips"></div>
</div>
<div class="lodging-custom hidden" id="lodgingCustom">
<span class="cny">¥</span>
<input id="lodgingMin" type="number" min="0" step="50" inputmode="numeric" placeholder="最低">
<span class="to">–</span>
<input id="lodgingMax" type="number" min="0" step="50" inputmode="numeric" placeholder="最高">
<span class="unit">元 / 晚</span>
</div>
<p class="lodging-tip" id="lodgingTip"></p>
<div class="quick-needs" id="quickNeeds"></div>
<div class="ai-inline" id="aiInline"></div>
<button class="btn-primary full" id="planBtn" type="button">生成行程规划 →</button>
<p class="hint">生成方案后,会据此给出住宿建议与路线示意图。</p>
</div>
<div class="plan-right">
<div class="output-head">
<span id="outputMeta">等待生成</span>
<div class="head-tools">
<select id="versionSelect" class="version-select hidden" title="切换版本 / 载入已保存方案"></select>
<button class="btn-ghost sm hidden" id="comparePlan" type="button">对比上一版</button>
<button class="btn-ghost sm hidden" id="savePlan" type="button">保存</button>
<button class="btn-ghost sm" id="copyBtn" type="button">复制</button>
<button class="btn-ghost sm" id="downloadBtn" type="button">下载 .md</button>
</div>
</div>
<div class="compare-wrap hidden" id="compareWrap">
<div class="compare-col"><div class="compare-col-head" id="compareLeftHead">上一版</div><div class="markdown" id="compareLeft"></div></div>
<div class="compare-col"><div class="compare-col-head" id="compareRightHead">当前版</div><div class="markdown" id="compareRight"></div></div>
</div>
<div class="markdown empty" id="planOutput">
<div class="empty-state">
<div class="empty-icon">✦</div>
<h4>方案会显示在这里</h4>
<p>包含方案总览、逐日安排表、天气与装备、交通方案、预算粗估、出发前清单和风险备选。</p>
</div>
</div>
<div class="followup hidden" id="planFollowup">
<div class="followup-row">
<input id="followupInput" type="text" placeholder="继续调整,例如:第二天改成室内为主;总预算压到 1200;换一家离景区更近的住处" autocomplete="off">
<button class="btn-primary sm" id="followupBtn" type="button">继续优化</button>
</div>
<div class="followup-hint">
<span id="followupCount">会基于当前方案重写完整版本</span>
<button class="btn-ghost sm" id="resetPlan" type="button">回到初版</button>
</div>
</div>
</div>
</div>
<!-- 方案定稿后才出现:路线示意 + 按行程与价位给的住宿建议 -->
<div class="plan-extra hidden" id="planExtra">
<div class="extra-card">
<div class="extra-head">
<span>行程路线示意</span>
<span class="extra-note" id="routeNote"></span>
</div>
<div class="route-wrap" id="routeMap"></div>
</div>
<div class="extra-card">
<div class="extra-head">
<span>住宿建议</span>
<span class="extra-note" id="stayNote"></span>
</div>
<div class="hotel-box" id="hotelBox"></div>
</div>
</div>
</section>
<footer class="footer">
<div><strong>行知 · TripSense</strong> — 周边出行决策原型</div>
<div>数据:Open-Meteo(天气/空气质量/地理编码)· OpenStreetMap / Photon(周边地点)· OSRM(公路)· 内置知名 + 小众地点库 · 可选高德 / 铁路接口 · OpenAI 兼容模型</div>
<div class="disclaimer">天气为预报或历史气候参考,铁路与部分路网耗时为估算;票价、班次、门票、开放时间请临近出发前在官方渠道核对。本工具不构成出行安全承诺。</div>
</footer>
</main>
<!-- ============================ 设置弹窗 ============================ -->
<div class="modal hidden" id="settingsModal">
<div class="modal-mask" id="modalMask"></div>
<div class="modal-box">
<div class="modal-head">
<h3>设置 <span>AI 接口 · 数据源</span></h3>
<button class="icon-btn" id="closeSettings" type="button">✕</button>
</div>
<p class="modal-desc">任何提供 <code>/chat/completions</code> 的服务都可接入。此处填写的值仅保存在本机浏览器,并随请求传给本地服务代理;生产环境建议改用服务端环境变量,不要在前端放密钥。留空 API Key 即使用演示模式。</p>
<label for="aiBase">接口地址 Base URL</label>
<input id="aiBase" type="text" placeholder="https://api.openai.com/v1" spellcheck="false">
<div class="presets" id="aiPresets"></div>
<label for="aiKey">API Key</label>
<input id="aiKey" type="password" placeholder="sk-…(留空 = 演示模式)" autocomplete="off">
<label for="aiModel">模型名称</label>
<input id="aiModel" type="text" placeholder="gpt-4o-mini" spellcheck="false">
<div class="field-row-inline">
<button class="btn-ghost sm" id="testAi" type="button">测试 AI 接口</button>
<span class="field-hint inline" id="aiTestResult"></span>
</div>
<div class="modal-divider"><span>数据源增强(可选)</span></div>
<label for="amapKey">高德 Web 服务 Key
<a class="field-link" href="https://console.amap.com/dev/key/app" target="_blank" rel="noopener">去申请 ↗</a>
</label>
<input id="amapKey" type="text" placeholder="留空 = 只用免密钥数据源" spellcheck="false" autocomplete="off">
<div class="field-row-inline">
<button class="btn-ghost sm" id="testAmap" type="button">测试这个 Key</button>
<span class="field-hint inline" id="amapTestResult"></span>
</div>
<p class="field-hint">用于实时周边 POI、国内驾车路径与中文地名检索,须选「Web服务」类型的 Key(不是「Web端(JS API)」)。填好后重新分析,清单里会出现更多实时地点,顶部「实时周边 POI 未取到」的提示也会消失。此值同样只存在本机浏览器。</p>
<label>住宿数据源</label>
<div class="field-row-inline">
<button class="btn-ghost sm" id="checkProviders" type="button">检测</button>
<span class="field-hint inline" id="providerResult">未检测</span>
</div>
<p class="field-hint">装了 <code>RollingGo</code> 的 <code>rgh</code> CLI 并执行 <code>rgh login</code> 后,住宿会显示实时报价与可预订链接;否则退回高德 POI(多数没有房价)。本项目只调用 <code>status</code> / <code>search-hotels</code> / <code>hotel-detail</code> 三个只读命令,<b>不会下单、不会取消订单</b>。</p>
<details class="prompt-details">
<summary>查看 / 复制本站预设提示词</summary>
<div class="prompt-actions"><button class="btn-ghost sm" id="copyPrompt" type="button">复制提示词</button></div>
<pre id="promptPreview"></pre>
</details>
<div class="modal-actions">
<button class="btn-primary" id="saveSettings" type="button">保存设置</button>
<button class="btn-outline" id="cancelSettings" type="button">取消</button>
<button class="btn-ghost sm" id="demoMode" type="button">强制演示模式</button>
</div>
</div>
</div>
<div class="toast hidden" id="toast"></div>
<script src="app.js?v=18"></script>
</body>
</html>