commit c5c2ffae68ed4dc9dab46d11674487d1aa5d86d5 Author: tamakyi Date: Thu Nov 5 20:25:03 2026 +0800 TripSense 行知 · 周边出行顾问:首版 零依赖 Node 服务,三步流程(选出发地 → 分析候选 → 生成方案 + 路线图 + 住宿参考)。 - 候选发现:高德 place/text 类型词跨城检索 + 内置景区/城市/小众库 + Photon 兜底 - 区县级「排除同城」判定(adname),过滤发生在轮转取样之前 - 路线图画的是 AI 方案里实际安排的每个地点,按正文顺序编号 + 虚线动线 + 真实高德底图 - 住宿:RollingGo 只读 CLI 取实时报价,无价格源时如实标注不编造 - 服务端内存缓存(指纹 + TTL + LRU),数据源层与 HTTP 层分别缓存 - 上下文瘦身:逐日天气只给 rank1,紧凑 JSON,实测约 1335 token diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23eb3ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +npm-debug.log* +*.log + +# 本地环境变量(含密钥),不要提交 +.env +.env.* +!.env.example + +# 本地缓存 / 临时产物 +.cache/ +tmp/ +*.tmp +*.sse + +# 编辑器与系统文件 +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..37c82d4 --- /dev/null +++ b/README.md @@ -0,0 +1,654 @@ +# 行知 · 周边出行顾问(TripSense) + +按「时间 + 所在地」分析周边适合出行的目的地,叠加逐日天气、空气质量、公路与铁路可达性,给出可解释的备选清单;再由用户补充具体需求,交给任意 **OpenAI 兼容接口**生成可执行的逐日行程规划。 + +零 npm 依赖,仅使用 Node.js 内置模块(`http` / `fs` / `path` + 全局 `fetch`)。 + +--- + +## 快速开始 + +要求 Node.js >= 18。 + +```bash +cd tripsense +node server.js # 或 npm start +``` + +服务默认绑定 `0.0.0.0`,启动后会打印本机与局域网的全部访问地址,例如: + +```text + ➜ 本机访问: http://127.0.0.1:8788 + ➜ 局域网访问:http://192.168.1.100:8788 (WLAN 2) + ➜ 局域网访问:http://100.121.122.41:8788 (Tailscale) +``` + +手机、平板或同网段电脑直接用上面**局域网访问**的地址即可打开。启动日志同时会打印当前的 AI 模式、公路与铁路数据来源。 + +> 端口被占用时会自动往后找(最多 10 个),所以实际端口以日志为准。若只想自己用, +> 用 `HOST=127.0.0.1 node server.js` 启动,就只监听本机。 + +--- + +## 三步流程 + +| 步骤 | 用户操作 | 系统行为 | +| --- | --- | --- | +| ① 出行条件 | 输入地名联想 / 浏览器定位、选日期区间、单程时长上限、搜索半径、是否排除同城、交通与兴趣偏好 | 地理编码 → 检索周边候选 → 批量取天气与 AQI → 公路矩阵 + 铁路可达性 → 评分排序 | +| ② 备选清单 | 查看方位罗盘、评分环、逐日天气、推荐理由、数据来源说明;勾选目的地 | 默认预选 Top 3 并给出提示;支持按评分/天气/距离/单程时长排序;可「只看小众」、可「对比已选」并排看关键指标 | +| ③ 行程规划 | 填写具体需求(同行人、预算、体力、禁忌等),点击生成;生成后可继续提要求迭代 | 清单数据 + 需求 + 本站预设提示词 → OpenAI 兼容接口流式输出 Markdown 方案 | + +页面右上角「设置」分两块:**AI 接口**(地址、密钥、模型,含常用供应商预设、预设提示词查看/复制、强制演示模式)与 **数据源增强**(高德 Web 服务 Key)。两者都只存在浏览器 `localStorage`,随请求传给本地服务代理。 + +### 界面 + +没有引入任何第三方 UI 框架(React / Vue / Tailwind / Bootstrap 均无),全部是手写 CSS 与原生 DOM。视觉为**简约主题**:纯白底、中性灰分隔、单一蓝色强调(`#2f6fed`)、细边框 + 轻阴影 + 圆角,标题与正文统一无衬线、数据用等宽。所有颜色集中在 `styles.css` 顶部 `:root`,想换主色改一个 `--accent` 即可。 + +### 表单记忆 + +出发地、起止日期、单程时长、搜索半径、是否排除同城、交通方式、兴趣偏好都会存进 `localStorage`(键 `tripsense.form`),刷新页面或从手机切回来不必重填。 + +- 日期会校验:存过的日期若已早于今天,自动回落到「今天 + 3 天」。 +- 只输入地名、还没从下拉里选中时,输入框文字会保留,但不会被当成已选出发地(分析时会提示先选)。 +- 完全重置:清站点数据,或控制台执行 `localStorage.removeItem('tripsense.form')`。 + +### 地名联想 + +输入出发地时按 340ms 防抖调用 `/api/geocode`。**配了高德 Key 时高德优先**,否则走 Open-Meteo。 + +为什么优先高德:Open-Meteo 基于 GeoNames,中文中小城市 / 区县覆盖很差——实测连「河池」都返回空,「金城江」更是查不到;高德能直接定位到区县中心。 + +几个兜底: + +- **结构化长地址**:「杭州市西湖区」会按行政后缀截成「杭州」再查一次,命中后下拉顶部注明「按「杭州」为你找到这些」。**只认「市 / 地区 / 州 / 区 / 县 / 镇 / 乡 / 街道 / 路 / 街 / 号」等后缀,不做无脑截字**——否则「金城江」会被截成「金城」,命中台湾金门、美国加州的同名地点,比查不到更糟。 +- **完全查不到**:不静默隐藏,而是显示「没找到「xxx」」,并按原因给建议(没配 Key 时提示去配、Key 无效时说明、网络超时时提示重试)。 + +设置里的「测试这个 Key」会实际请求一次高德,当场告诉你能不能用、为什么不能用。 + +移动端下拉紧贴输入框(间距 4px)、限高 46vh 可滚动,避免被软键盘顶出视口;窄屏下定位按钮收成 📍 图标,与输入框保持同一行——否则纵向排列会把下拉推到按钮下方,用户会以为没有联想。 + +### 排除同城 + +配了高德 Key 之后,周边检索会顺带返回大量本市城区的 POI(美术馆、寺庙、商场),清单很容易变成「本地一日游」。所以第一步提供 **「排除出发地同城地点」** 开关,**默认开启**: + +> 语义要说清楚:**排除的是「出发地所在的城市/城区」**。 +> 目的地本身所在的城不该被排除——比如出发地在河池市金城江区, +> 同市下辖的凤山、罗城、南丹(50–110km)恰恰是最该推荐的周边目的地。 + +判定分三层: + +1. **距离硬排除**:`< 8km` 一律排除。「周边出行」不该包含步行 10 分钟就能到的地方——这一条同时挡掉了「窑埠古镇」(离市中心 1km)这类城郊点位。 +2. **区县级判定**:出发地与候选点都有区县信息时,**同区县才排除,跨县保留**。高德 POI 的 `cityname` 是地级市(如「柳州市」),`adname` 才是区县(如「融水苗族自治县」「鱼峰区」),所以必须用 `adname` 判同城区。 +3. **距离兜底**:没有区县信息时(内置库 / Photon),退回按 `localKm`(默认 30km)判定。 + +- 过滤发生在**轮转取样之前**,不是对结果做后处理——否则同城点会先占满 `maxCandidates` 名额,把真正周边的地点挤掉。 +- `localKm` 会被压到不超过搜索半径的 50%,所以选 50km 半径时实际排除约 25km 内,不至于把清单清空。 +- 被排除了多少个会写进 `stats.localExcluded` 与 `dataNotes`,副标题也会显示「已排除出发地所在城区」。 +- 想连本地一日游一起看,取消勾选即可(`excludeLocal: false`)。 + +**实测(河池市金城江区出发,250km 半径)** + +候选 28 个,覆盖河池下辖县(东兰红水河第一湾 72km、大化八里九弯 82km、巴马百魔洞 109km、凤山三门海 111km、环江月亮山 16km)、柳州方向(柳州古城 141km、窑埠古镇 142km、融水双龙沟 125km、柳州博物馆 141km)以及邻省(荔波小七孔 77km、西江千户苗寨 199km)。`localExcluded` 13 个。 + +### 候选池检索策略 + +候选来自四路,按「覆盖度从高到低、可信度从高到低」合并去重: + +| 来源 | 覆盖 | 说明 | +| --- | --- | --- | +| 内置知名景区库 + 城市库 | 全国 | 无密钥、必定有坐标,兜底主干 | +| 内置小众地点库 | 全国 206 条 | 带 `niche` 标记,约占候选配额 30% | +| 高德 `place/text` | 跨城 | **主力**:一组目的地类型词(风景区 / 古镇 / 森林公园 / 地质公园 / 湿地公园 / 瀑布 / 溶洞 / 峡谷 / 梯田 / 温泉 / 博物馆 / 古村落 / 山水景区 / 草原 / 湖)在省级范围内检索 | +| 高德 `place/around` | 50km 内 | 只补近处遗漏 | +| Photon(OSM) | 有限 | 公共实例限流严格,仅按分类 + bbox 取少量,失败即降级 | + +关键约束: + +- **不能只查出发地一圈**。高德 `place/around` 的 `radius` 上限 50km、返回按距离排序、`offset` 只有 25 条——以出发地为圆心连翻页,拿到的 150 条几乎全是市区的游园、亭子、码头,50km 外一个都搜不到。这是接口覆盖边界,不是参数问题。因此主检索改走 `place/text` **类型词跨城检索**,再用直线距离过滤。 +- `place/text` 用 `city=<省级或目的地市>` + `citylimit=false`,才能拿到跨城目的地;结果每条都带 `city`(地级市)与 `adname`(区县),正好支撑「排除同城」的区县级判定。 +- Photon 分类检索用 `bbox + 分类` 作为稳定缓存键(公共实例限流,重复分析必须复用缓存)。 +- 候选上限 28 个,按「类型轮转取样」抽取,保证小众点位不会被热门点位挤掉。 + +### 评分口径 + +总分 0~100,由六项加权相加:天气舒适度 `0~40`、时间适配 `0~26`、兴趣匹配 `0~14`、距离 `0~7`、目的地辨识度 `0~8`、空气质量 `0~6`。整段行程都是坏天气时会触发硬门槛,不允许给出「强烈推荐」。每张卡片都带 `breakdown`、`reasons`、`warnings`,可解释。 + +> 耗时统一按人类可读格式输出(`1小时38分` / `4分钟`),不出现 `1.07h` 这类裸小数。AQI 按六档标注:优 / 良 / 轻度污染 / 中度污染 / 重度污染 / 严重污染。 + +### 天气数据的四种模式 + +| 模式 | 触发条件 | 数据来源 | +| --- | --- | --- | +| `forecast` | 未来 16 天内 | Open-Meteo 实时预报 | +| `forecast-past` | 区间含刚过去的日期 | `past_days` 预报 | +| `archive` | 更早的历史日期 | Open-Meteo 历史再分析 | +| `climatology` | 超出 16 天预报窗口 | 取去年同期气候,映射回所选日期,**结果明确标注「历史气候参考」** | + +`/api/best-window` 会在未来 16 天预报中寻找连续平稳天气窗口,前端选择出发地后自动提示并可一键应用。 + +--- + +## 数据源 + +| 数据源 | 用途 | 密钥 | 不可用时 | +| --- | --- | --- | --- | +| Open-Meteo `geocoding` / `forecast` / `archive` / `air-quality` | 地名检索、天气预报、历史气候、美标 AQI | 免 | 重试 2 次后报错并提示 | +| Photon (OpenStreetMap) | 周边景点/公园/博物馆/山峰/古迹/城市检索 | 免 | 4 秒快速失败,切内置地名库 | +| OSRM `table/v1/driving` | 1→N 公路里程与耗时矩阵 | 免 | 降级为里程-速度估算并标注 | +| BigDataCloud / Photon Reverse | 浏览器定位后的反向地理编码 | 免 | 提示手动输入地名 | +| 内置 `lib/places-cn.js` | 全国知名景区 + 主要城市坐标库 | — | 始终参与,保证任何城市都有可用清单 | +| 内置 `lib/places-niche.js` | 206 条小众地点:古村、遗址、湿地、地质景观、洞穴、小馆、徒步线 | — | 始终参与,并保证约 30% 的候选名额 | +| 高德 Web 服务 `geocode/geo` / `place/text` / `place/around`(含 `types=100000` 住宿)/ `direction/driving` / `staticmap`(路线图真实底图) | 中文地名检索、实时周边 POI、周边住宿参考、国内驾车路径、地图图层 | `AMAP_KEY` 或页面「设置 → 数据源增强」 | 静默跳过并退回 Open-Meteo / OSRM / 内置库 / 方位示意图;Key 无效会在页面顶部或设置里直接报错 | +| RollingGo `rgh` CLI(本机安装) | 酒店实时参考价、星级、距离、可预订链接 | `npm i -g @rollinggo/hotel` + `rgh login` | 跳过,退回高德 POI;只调用只读子命令 | +| 铁路接口 | 真实高铁/城际耗时与票价 | `RAIL_API_URL` | 降级为里程-速度估算并标注 | +| OpenAI 兼容接口 | 生成行程规划 | `AI_API_KEY` | 自动进入本地演示模式 | + +> Photon 公共实例常被限流,因此 `lib/sources.js` 采用「每类单独请求 + 并发限制 + 快速失败 + 内置库兜底」。名称过滤分三层:`JUNK_NAME` 去掉停车场/出入口等设施,`POOR_NAME` 去掉「土堆」「观景台」等无辨识度条目,`LOW_VALUE_NAME` 去掉「名字像景点但不是独立目的地」的点位(燕京八景中的小碑、外国使馆旧址等)——这三类过去会把真正值得去的地方挤出榜单。当实时 POI 完全不可用时,结果会带 `stats.fallbackOnly`,页面提示会明确写出「天气、空气质量与路况仍是实时数据,不受影响」,避免用户误以为整条链路都挂了。 + +> 小众地点库的坐标是近似 WGS-84,只用于天气与路网估算;结果里 `source` 会写「内置小众地点库」,不会伪装成实时检索。候选带 `niche: true` 与 `region` 字段,前端据此显示「小众」徽章并提供「只看小众」筛选。 + +--- + +## 配置 + +所有配置在 `config.js`,**环境变量优先**。 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `PORT` / `HOST` | `8787` / `0.0.0.0` | 监听地址;端口被占用时自动往后顺延,实际端口见启动日志 | +| `AMAP_KEY` | 空 | 高德 Web 服务 Key,开启国内驾车路径、实时周边 POI、中文地名检索与**真实地图底图** | +| `HOTEL_PRICE_API_URL` | 空 | 可选价格源模板,支持 `{name} {lat} {lon}`,期望返回 `[{name, price}]`;用于补高德缺失的酒店价格 | +| `RAIL_API_URL` | 空 | 铁路接口模板,支持 `{fromLat} {fromLon} {toLat} {toLon} {date} {fromName} {toName}`;期望返回 `{available, durationMinutes, distanceKm, fareCny, note}` | +| `RAIL_API_KEY` | 空 | 铁路接口鉴权 | +| `AI_BASE_URL` | `https://api.openai.com/v1` | 任意兼容 `/chat/completions` 的服务 | +| `AI_API_KEY` | 空 | 留空即演示模式 | +| `AI_MODEL` | `gpt-4o-mini` | 模型名 | +| `AI_TEMPERATURE` / `AI_MAX_TOKENS` / `AI_TIMEOUT_MS` | `0.7` / `8000` / `120000` | 生成参数;推理模型思考很吃 token,额度不够会自动放大重试 | + +`SEARCH` 段为检索调参(`maxRadiusKm` / `photonConcurrency` / `photonTimeoutMs` / `maxCandidates` 等),一般不改。 + +### 临时覆盖(仅原型用途) + +前端通过自定义请求头把设置交给本地服务代理: + +| 请求头 | 含义 | +| --- | --- | +| `X-AI-Base-Url` | 覆盖 `AI_BASE_URL` | +| `X-AI-Model` | 覆盖 `AI_MODEL` | +| `X-AI-Api-Key` | 覆盖密钥;`__server__` = 用服务端配置;`__demo__` = 强制演示模式 | +| `X-AMAP-Key` | 覆盖 `AMAP_KEY`,只影响 `/api/analyze` 的周边 POI 与驾车路径 | + +前端「设置 → 数据源增强」填的高德 Key 就是通过 `X-AMAP-Key` 生效的,优先级高于服务端环境变量;清除后即回到免密钥数据源。Key 无效(如 `INVALID_USER_KEY`)时不会静默失败,`stats.amapError` 会带回原因并在页面顶部提示,同时公路自动退回 OSRM。 + +示例: + +```bash +AI_BASE_URL="https://api.deepseek.com/v1" \ +AI_API_KEY="sk-..." \ +AI_MODEL="deepseek-chat" \ +node server.js +``` + +--- + +## OpenAI 兼容接口与预设提示词 + +服务只要求对方提供 `POST {BASE_URL}/chat/completions`,请求体为标准格式(`model` / `messages` / `stream: true` / `temperature` / `max_tokens`),解析 `choices[0].delta.content`,同时兼容非流式 JSON 响应与 `reasoning_content`。前端设置里内置以下预设: + +| 供应商 | Base URL | 示例模型 | +| --- | --- | --- | +| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` | +| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat` | +| 通义千问 | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-plus` | +| Kimi | `https://api.moonshot.cn/v1` | `moonshot-v1-8k` | +| 智谱 GLM | `https://open.bigmodel.cn/api/paas/v4` | `glm-4-flash` | +| 本地 Ollama | `http://localhost:11434/v1` | `qwen2.5:7b` | + +**预设提示词**写死在 `config.js` 的 `SYSTEM_PROMPT`,页面设置弹窗可查看/复制。它向模型说明产品背景与输入 JSON 结构,并规定硬性规则: + +1. 只用输入给出的候选地与数据,**禁止编造**景点、票价、班次、营业时间; +2. 估算值与历史气候必须显式标注「估算」/「历史气候参考」; +3. `userNeeds` 与数据冲突时必须直接指出并给替代方案,不许假装满足; +4. 先扣掉往返交通再安排游玩,节奏与同行人体力匹配; +5. 安全优先,预算用 `¥` 并注明粗估; +6. 输出固定 Markdown 结构:方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选。 + +`dataNotes` 会一并送入模型上下文,避免它把估算数据当作事实。未配置密钥时走本地 `demoPlan()` 规则引擎,输出同结构的示例方案并标注「演示模式」,因此整条链路无需任何密钥即可完整体验。 + +### 版本对比与保存 + +输出头部有一个版本下拉,把**本次会话的每一版**和**本地保存的方案**放在一起: + +- 选任一会话版本即可回看,`对比上一版` 会把当前版与它前一版**左右并排**展示(各自独立滚动),再点一次退出。 +- `保存` 把当前版写进 `localStorage`(`tripsense.saved`,最多 20 条),下拉里显示成 `11/5 15:02 · 北京市`,选中即可载入并继续调整。 +- 下拉末尾的「清空已保存」可以一次性清掉本地记录。 +- 复制 / 下载都跟随当前查看的那一版,不会永远只导出最新版。 + +### 让 AI 能点名具体地点(nearby POI) + +只给住宿 POI 时,模型只能说「附近有很多小吃」——因为它手里确实没有餐厅、景点数据,而提示词又禁止编造。所以生成方案前会**多查一次目的地周边 POI**: + +- `GET /api/nearby-pois?lat=&lon=&radius=8` → 高德 `place/around`,`types=050000|110000|140100`(餐饮 / 风景名胜 / 博物馆),一次请求拿三类 +- 结果整理成 `nearby = { area, radiusKm, food[], sights[] }`,每项含 `name / type / distanceKm / rating / cost` +- 随 `/api/plan` 一起交给模型,`SYSTEM_PROMPT` 第 10、11 条要求:**必须从 nearby 点名**,写成「名称(类型,距目的地区域 X km,评分 Y,人均 ¥Z)」;只有某一类确实缺失时才允许退回「片区 + 筛选方法」,并且必须说明缺的是哪类数据 + +所以现在方案里会出现具体店名/馆名,而不是「某某古镇附近」。查不到时(没配高德 Key)行为与之前一致,只是没有点名依据。 + +### 上下文体积(省 token) + +每次生成都要把 `SYSTEM_PROMPT` + 上下文 JSON 发给模型,所以两者都做了裁剪: + +| 项 | 之前 | 现在 | +| --- | --- | --- | +| `SYSTEM_PROMPT` | 2799 字 | **1028 字**(规则合并,输出结构只留标题与要点) | +| 每个候选的逐日天气 | 31 天 × 8 字段 | **只带行程覆盖的天数** × 7 字段 | +| 候选里附带的字段 | `road` / `rail` / `options` / `reasons` / `warnings` 全带 | 只要 `travel.bestHours/bestMode` + 结论性字段 | +| `dataNotes` | 12 条全文 | 4 条 × 80 字 | +| 实测单次请求 | 约 2 万字符 | **约 4000 字符**(system 1028 + 上下文 2956) | + +`weather.summary` 保留为对象而不是压成字符串——演示模式的规则引擎要用它的 `avgHigh/avgLow` 等统计值。 + +### 住宿建议与路线示意 + +两者都在**方案定稿之后**才出现——先有行程,再谈住哪、怎么走。 + +**住宿建议** + +- 落脚点取「已选里评分最高的目的地」,围绕它查 10km 内住宿;用户选的价位既用于筛选,也随需求传给模型。 +- 价位支持 **五档预设 + 自定义区间**(自定义可填「最低–最高」,如 ¥200–800)。自定义值存 `localStorage`,切换后会自动按新区间重查住宿,不用重新生成方案。 +- 数据源是高德 POI「住宿服务」(`types=100000`)。价格取 `biz_ext.cost`,**缺失时明确显示「无标注价」,绝不编造**;整片区域都没价格时会直接说明"无法按价位筛选,需自行核对"。每行带「查房价」外链(高德 URI API),方便直接核对。 +- POI 里没有房态和实时房价,预设提示词第 7–9 条要求模型不得编造房型、空房、精确价格,且住宿要靠近当天主要活动地、跨片区要说明是否换住处。 +- 未配置高德 Key 时只提示配置,方案里的住宿预算仍按所选价位给出。 +- **数据源降级要如实说明**:实时报价源(RollingGo)没命中而退回到只有位置的高德 POI 时,界面会多写一句「实时报价源这次没命中,已退回到只有位置的酒店数据。」,不会让人以为这就是完整报价。 + +**关于「酒店都没价格」** + +这是数据源的硬限制,不是解析 bug:高德 POI 的 `biz_ext.cost` 对住宿类目覆盖很差,多数酒店干脆不返回该字段(`extensions=all` 也已经带上)。处理方式: + +1. UI 坦白标注「无标注价」,并说明原因,不猜价; +2. 每行给「查房价」外链,一键到地图/预订平台核对; +3. 预留可插拔价格源:配置 `HOTEL_PRICE_API_URL`(模板支持 `{name}` / `{lat}` / `{lon}`,返回 `[{name, price}]`)后,后端会按酒店名匹配合并价格,命中时列表会显示 `¥380 起` 且脚注标注「价格来自第三方价格源」。未配置时保持 null,不影响主流程。 + +**住宿数据源:RollingGo(可选,能拿到真实报价)** + +[rollinggo-hotel-skill-CN](https://github.com/RollingGo-AI/rollinggo-hotel-skill-CN) 提供的是 **Claude Skill + 本机 CLI**,不是 MCP Server:底层是 npm 包 `@rollinggo/hotel`,可执行文件 `rgh`,认证走 OAuth 2.1(`rgh login`),没有 API Key。它能给出酒店的真实参考价、星级、距离与可预订链接,正好补上高德缺失的价格。 + +接入方式(默认开启 `auto`,装了才生效): + +```bash +npm i -g @rollinggo/hotel # 装 CLI +rgh login # 浏览器 OAuth 授权(只有你自己能做这一步) +rgh status # 确认已登录 +``` + +然后在网页「设置 → 数据源增强 → 住宿数据源」点「检测」,会显示 rgh 是否安装、是否已登录。 + +- **只读白名单**:`lib/hotels-rollinggo.js` 里 `ALLOWED_SUBCOMMANDS` 只允许 `status` / `search-hotels` / `hotel-detail`。`price-confirm` / `book` / `pre-cancel` / `confirm-cancel` 会触发真实资金操作,本适配器**永不调用**,也没有对应接口对外暴露。 +- **降级链**:RollingGo(已登录)→ 高德 POI → 明确提示不可用。任一环节失败都不影响方案生成。 +- **价位直接下推**:自定义/预设价位的上限会作为 `--max-price-per-night` 传给 CLI,由数据源侧筛选,而不是拿回来再过滤。 +- **Windows 编码坑**:全局安装得到的是 `rgh.cmd`,经 `cmd.exe` 转发时中文参数与本地化报错会乱码(实测「不是内部或外部命令」被解成 GBK 乱码,导致"是否安装"判断失效)。适配器会从 bin 脚本位置反查 `@rollinggo/hotel/dist/index.js`,直接用 `node` 执行,绕开 shell;同时对自由文本做白名单校验,杜绝命令注入。 + +| 变量 | 默认 | 说明 | +| --- | --- | --- | +| `HOTEL_PROVIDER` | `auto` | `auto` = 装了 rgh 且已登录就用 RollingGo;`amap` = 只用高德;`rollinggo` = 只试 RollingGo | +| `RHG_BIN` | `rgh` | rgh 可执行文件路径,也可指向 `.../dist/index.js` | +| `ROLLINGGO_TIMEOUT_MS` | `25000` | 单次 CLI 调用超时 | + +⚠️ **使用边界(重要)**:`rgh login` 把 OAuth 凭据写在**本机配置文件**(`~/.hotel-cli/.env`),一台机器只有一份登录态,CLI 也没有「传 token」这类多租户参数。所以: + +- **它天生是单账号工具**,不存在「每个访客填自己的」这种用法。给多人用只有两种模式:① 站长统一配置一个账号(所有人共用);② 每个人在自己机器上跑一份服务、各自 `rgh login`。 +- 站长统一配置时,所有访客的查询都算在站长账号上:额度、计费、风控都在站长身上;把账号提供给第三方查询也可能触及服务条款。**只适合自己 / 家人朋友这种可信小范围使用。** +- 要做公开站点,别用 CLI 兜:应走官方 API 或商务合作,或者干脆只保留「跳转到预订平台」的链接。 +- 想彻底关掉它:`HOTEL_PROVIDER=amap`,服务端就完全不会去碰 `rgh`。 + +**实测数据(北京 · 故宫周边 5km · 2026-11-09)** + +| 查询 | 结果 | +| --- | --- | +| 1 晚,不限价 | 8 家,最低 ¥521/晚(北京福庭酒店 3.6km)、¥547/晚(国际艺苑大酒店 1.3km) | +| 3 晚 | 6 家,最低 ¥456/晚(秋果酒店前门店,3 晚总价 1368) | +| 1 晚 + `--max-price-per-night 600` | 3 家,全部 ≤ ¥600(521 / 524 / 456) | + +- `price.lowestPrice` 是**每晚均价**(CLI 原文:「3晚总价:1,563CNY(约521CNY/晚)」),所以界面显示 `¥521/晚`,不是总价。 +- 每条结果带 `bookingUrl`(`https://rollinggo.cn/pages/hotel/detail/...`),界面直接做成「去预订」。 + +**踩到的解析坑**:`rgh` 用 dotenvx 加载配置,启动时会往 stdout 打一行 +`◇ injected env (1) from ... // tip: ... { path: '/custom/path/.env' }` +——这行**自带花括号**。原先「取第一个 `{` 到最后一个 `}`」会把日志和 JSON 粘在一起,`JSON.parse` 必然失败,表现为「明明有结果却 0 家」。现在 `extractJSON()` 先从 `"success"` / `"hotelInformationList"` 等业务字段回推对象起点,再按括号配对(跳过字符串内的括号)精确切分,已用 6 个用例覆盖(含字符串里带 `}` 的情况)。 + +**路线图** + +路线图画的是**方案里实际安排的每个地点**,不是用户勾选的候选清单。 + +> 旧实现只取「已选候选」,而勾选一个城市目的地时清单往往只有 1 项, +> 于是图上就一个点——这不是数据问题,是取错了源。 + +现在的流程: + +1. **提取地点**(`public/app.js` 的 `extractPlanPlaces`):从方案正文里收集三类地点——① `nearby` 里模型被强制点名的餐饮/景点(名字必定出现在正文);② 已选目的地;③ 正文里加粗的词。加粗词必须**以场所类后缀结尾**(景区/公园/博物馆/古镇/寺/山/岛/酒店…)才算地点,否则「演示模式」「自驾」「合计」「若遇降雨或大风」这类说明性词会被误当地点(黑名单永远补不全,所以用白名单)。 +2. **按行程顺序排列**:按地点名在正文中首次出现的位置排序,得到先后次序,而不是清单顺序。 +3. **定位坐标**:已有坐标的直接用;缺的走 `POST /api/route-points`,服务端用高德 `place/text` 按名字检索(`city` 传目的地所在市、`citylimit=false`、不限 `types`,否则会漏掉餐饮/酒店/场馆)。 +4. **画线路**:真实高德底图 + 自绘编号圆点 + 虚线动线;下方附完整行程点清单(含类型),图上只画 9 个代表点。 + +**定位的两道护栏**(都很重要): + +- **距离护栏**:名字里带外地地名会让高德匹配到同名 POI——实测「马鞍山马尾坡野生红花林场」被定位到 **1211km 外的安徽马鞍山市**。参考点超过 150km 一律不认,宁可返回 null 让界面标注「未能定位」,也不能在地图上画一个完全错误的位置。 +- **城市加分**:查询「柳州马鞍山马尾坡」这类名字时,命中 `cityname` 与目的地一致的那条才加分,避免默认命中外地同名 POI。 + +**其它约束**: + +- 地图只画目的地,不画出发地:出发地往往在 100km 外,塞进取景范围会把目的地压成一个点。起点改用文字标注(方位 + 直线距离,如「出发地河池市金城江区在东方 142km」),降级示意图里才画出起点。 +- 静态地图最多承载 9 个点(服务端 `staticMapMeta` 同样按 9 点截取)。超出的按行程顺序均匀抽样,并保证首尾都在;否则图上只画得出前 9 个、图例却写着 18 个,对不上。 +- 相距 80m 内的点会被合并(同一片街区常见),避免叠在一起连不出线。 +- 标注做了**避让**:按 y 排序依次分配 6 个标签槽位(上/下/左/右),并记录已占用的矩形,冲突就换槽;全部冲突时只画编号不画名字——宁可少标也不能糊成一团。 +- 取景的 `metersPerPx` 下限为 6:行程点往往集中在城市几公里内,按真实跨度算会 zoom 到 16 级、几十个点全叠成一坨,反而看不出线路。 +- 有高德 Key 时显示**真实地图底图**:服务端代理高德静态地图(`/v3/staticmap`),**Key 只在服务端使用,不出现在前端 URL 或 DOM 里**。取图失败时自动降级为方位示意,并在图下说明原因。 +- 无 Key 时降级为按真实经纬度等距投影(经度按 `cos(lat)` 修正)的示意图。 +- 地图服务商只使用合规白名单(高德 / 腾讯 / 百度 / 天地图),不使用 Google / Apple / Mapbox / OSM 直连瓦片。 + +### 多轮调整 + +方案不是一次性的。生成完成后,输出框下方会出现输入条,写一句要求(「第二天改成室内为主」「总预算压到 1200」)点「继续优化」即可迭代。 + +- 后端把**上一版方案**作为 `assistant` 消息、**新要求**作为 `user` 消息一起发给模型,并要求输出**完整的新版**(不是 diff 片段),免得用户还得自己拼。 +- 输出区顶部显示「第 N 版」,右侧「回到初版」可退回第一版做对比。 +- 演示模式下规则引擎无法真正理解增量需求,会在开头注明并重新生成通用方案,不会假装已经改好。 +- 上一版超过 12000 字会被截断,避免把上下文撑爆;`previous` 只用于多轮消息,不会重复塞进上下文 JSON。 + +### AI 接口自检 + +设置里的「测试 AI 接口」会真的发一次最小请求(非流式、`max_tokens: 32`),把失败定位到具体环节,而不是只丢一句"没有输出": + +| 显示 | 含义 | +| --- | --- | +| `可用 · 320ms · gpt-4o-mini` | 地址、密钥、模型都通 | +| `HTTP 401:Invalid API key` | 密钥无效或已过期 | +| `HTTP 404:model not found` | 模型名写错 | +| `接口通了但没返回内容(finish_reason=length)` | `max_tokens` 太小,或命中内容策略 | +| `无法连接:fetch failed` | 地址不可达 / 少了 `/v1` / 需要代理 | +| `没有可用的 API Key` | 当前等同演示模式,不会发网络请求 | + +### 为什么会出现「未获得任何输出」 + +这条提示只在**流正常结束、但一个字的正文都没拿到**时出现,常见原因: + +1. **模型只返回了思考内容**(`reasoning_content`),正文为空。推理模型(DeepSeek 系、o1/o3/gpt-5 等)思考很耗 token,`max_tokens` 不够时正文一个字都剩不下。默认额度已提到 `8000`,并且**检测到「只有思考没有正文」会自动放大到 16384 重试一次**(顶部会显示「已把上限 8000 → 16384 自动重试」),不需要手动改配置。两次都不够才会报错并建议换非推理模型。 +2. **参数不被支持**:新版模型会拒绝 `temperature` / `max_tokens`,返回 400 `Unsupported parameter`。代码会自动去掉这两个参数重试。 +3. **额度超过模型上限**:重试到 16384 时若模型只支持 8192,会先降到 `8192`,再退到服务端默认值,避免"重试反而把请求撑爆"。 +4. **地址少了 `/v1`**:请求打到 404 或返回 HTML,流里没有 `data:` 事件。 +5. **网关返回 200 但 body 为空**(部分代理在鉴权失败时这样)。 + +这几种情况都会在输出区给出具体原因(含 `finish_reason`、收到的事件数、原始片段),推理模型思考时会显示「模型思考中…已 N 字」。 + +--- + +## HTTP API + +| 接口 | 方法 | 说明 | +| --- | --- | --- | +| `/api/health` | GET | 健康检查 | +| `/api/config` | GET | 当前 AI 状态、数据源、限制、`systemPrompt` | +| `/api/geocode?name=杭州` | GET | 地名联想 | +| `/api/reverse?lat=..&lon=..` | GET | 反向地理编码 | +| `/api/best-window?lat=..&lon=..&days=2` | GET | 未来 16 天内连续好天气窗口 | +| `/api/analyze` | POST | **SSE**:`progress` → `result` → `done` | +| `/api/plan` | POST | **SSE**:`meta` → `delta` → `done` | +| `/api/hotels?lat=..&lon=..&radius=10` | GET | 主目的地周边住宿参考(高德 POI,不含房态与实时房价;配了 `HOTEL_PRICE_API_URL` 会补价格) | +| `/api/nearby-pois?lat=..&lon=..&radius=8` | GET | 目的地周边可点名地点(餐饮 / 景点 / 博物馆),供 AI 规划使用 | +| `/api/route-points` | POST | 按地点名列表定位真实坐标(串行 + 120ms 间隔调高德),返回 `points` 与 `missing`,供路线图绘制 | +| `/api/hotel-providers` | GET | 住宿数据源可用性:高德 Key 是否配置、rgh 是否安装/已登录(只读) | +| `/api/staticmap?pts=lat,lon;lat,lon&w=680&h=400` | GET | 代理高德静态地图,返回 `image/png`;失败时返回 `{error}`。Key 走 `X-AMAP-Key` 头,不进 URL | +| `/api/ai-test` | POST | 用最小的非流式请求探测 AI 接口,返回各环节诊断(`stage` / `status` / `ms` / `message`) | +| `/api/cache` | GET / DELETE | 查看缓存条目数与上限;DELETE 清空全部缓存 | + +### 缓存 + +服务端有一层内存缓存(`lib/cache.js`),带 TTL 与 LRU(上限 300 条)。命中时**不再打外部数据源**,这是高德 QPS 限流与接口抖动的主要缓冲。 + +| 数据 | TTL | 理由 | +| --- | --- | --- | +| 分析结果(`/api/analyze`) | 30 分钟 | 天气/路况按小时更新,短时间内重复分析没必要重算 | +| 地理编码 / 反向地理编码 | 7 天 | 行政区划与地名几乎不变 | +| 候选发现(Photon + 高德 POI) | 6 小时 | 商家与景点会变,但不至于几分钟一变 | +| 周边 POI / 住宿 POI / 静态地图底图 | 6 小时 / 30 分钟 / 7 天 | 房价波动快,底图几乎不变 | +| 天气 / 空气质量 / 路网 / 铁路 | 3 小时 | 对应上游数据的刷新节奏 | + +缓存键 = 规范化请求指纹。**高德 Key 的后 8 位参与指纹**——换 Key 会改变 POI 与路网结果,不能串味。地理编码缓存键把坐标取整到 3 位(约 100 米),避免浮点尾数导致永远不命中。 + +前端行为:命中缓存时页面顶部出现提示条「这份清单来自 N 分钟前的缓存」,并提供「重新获取最新数据」按钮(请求体带 `forceRefresh: true` 跳过缓存)。若改了条件但结果看着像旧的,直接点它。 + +```bash +# 查看缓存状态 / 清空 +curl http://127.0.0.1:8787/api/cache +curl -X DELETE http://127.0.0.1:8787/api/cache +``` + +> 缓存只在内存里,重启服务即清空。多实例部署时各实例独立,不共享。 + +请求体示例: + +```bash +# 分析(SSE 流) +curl -N -X POST http://127.0.0.1:8787/api/analyze \ + -H 'Content-Type: application/json' \ + -d '{"origin":{"label":"北京市","lat":39.9042,"lon":116.4074}, + "startDate":"2026-11-09","endDate":"2026-11-10", + "maxOneWayHours":2.5,"radiusKm":150, + "excludeLocal":true,"localKm":30, + "transportPrefs":["自驾","高铁/铁路"],"interests":["自然风光","拍照"]}' + +# 规划(SSE 流) +curl -N -X POST http://127.0.0.1:8787/api/plan \ + -H 'Content-Type: application/json' \ + -H 'X-AI-Api-Key: __demo__' \ + -d '{"origin":{...},"timeRange":{...},"constraints":{...}, + "shortlist":[...],"userNeeds":"带老人和小孩,两天一晚","dataNotes":[...]}' +``` + +`/api/analyze` 返回体含 `origin` / `timeRange` / `constraints`(含 `excludeLocal`、`localKm`)/ `summary` / `candidates[]` / `dataNotes[]` / `stats`(含 `localExcluded`、`amapError`);每个候选带 `score` / `verdict` / `weather` / `road` / `rail` / `aqi` / `breakdown` / `reasons`。 + +--- + +## 已知限制与数据口径 + +- Photon 公共实例不稳定,实时 POI 可能整体降级到内置地名库(坐标为近似值),页面会明确提示。 +- 高德 Key 必须是「Web服务」类型(不是「Web端(JS API)」),且需在控制台启用;填错或配额用尽会在页面顶部报出原因,不会静默失败。未配置时周边 POI 与驾车路径自动使用免密钥数据源。 +- 未配置 `RAIL_API_URL` 时,铁路耗时与票价均为「里程-速度」估算;`road.source` 也可能为估算。 +- 超出 16 天预报窗口的日期使用去年同期气候,仅作参考,不可当作实际天气。 +- 所有估算、降级、历史气候替代都会写进 `dataNotes` 并展示在页面;票价、班次、门票、开放时间请临近出发前在官方渠道核对。 +- 本工具是决策辅助原型,**不构成出行安全承诺**。 + +--- + +## 开放访问(局域网) + +默认绑定 `0.0.0.0`,同网段设备可直接访问。要让它真正可达,注意三件事: + +1. **防火墙**:Windows 首次让 Node 监听端口时会弹窗询问,选「允许访问」。若当时点了取消,需要到「Windows Defender 防火墙 → 允许应用通过防火墙」里给 `node.exe` 勾上专用/公用网络。可用 `Get-NetFirewallRule -DisplayName 'node.exe'` 检查是否已有 Inbound Allow 规则。 +2. **网络类型**:家用/公司 Wi-Fi 通常是「专用」或「公用」,两种都默认放行;若被识别为「域」网络而规则未覆盖,要单独放行。 +3. **同一网段**:手机不能用 `127.0.0.1`(那是手机自己),要用启动日志里打印的局域网 IP。 + +### 安全边界(重要) + +这个原型**没有任何登录鉴权**,绑定 `0.0.0.0` 意味着同网段任何人都能打开页面并调用 `/api/*`: + +- 若服务端配置了 `AI_API_KEY`,同一网络的人可以通过本服务消耗你的额度(`X-AI-Api-Key: __server__` 就是走服务端密钥);启动日志在这种情况下会额外警告。 +- CORS 当前是 `*`,任意网页都能在浏览器里调用你本机的接口。 +- 没有限流,`/api/analyze` 每次都会打到 Open-Meteo / Photon / OSRM,反复调用可能让这些公共实例限流。 + +只在**可信网络**(家里、自己的热点)开放。要暴露到公网,先做:登录或 token、把 CORS 收紧到具体来源、加限流与超时、上 HTTPS 反向代理。 + +只给自己用:`HOST=127.0.0.1 node server.js`。 + +### 定位与 HTTPS + +浏览器的 `navigator.geolocation` 只在**安全上下文**里可用:HTTPS,或 `localhost` / `127.0.0.1`。 + +| 访问方式 | 定位 | +| --- | --- | +| `http://127.0.0.1:8788` | ✅ 可用(localhost 属安全上下文) | +| `http://localhost:8788` | ✅ 可用 | +| `http://192.168.1.100:8788`(手机 / 其他电脑) | ❌ 浏览器拒绝,控制台报 `Only secure origins are allowed` | + +这是浏览器规范,不是代码问题。页面会提前把定位按钮置灰并给出中文说明,不会把英文底层报错弹给用户。手机上想用定位有三条路: + +1. **直接输入地名**(最省事):地名联想走的是服务端地理编码,与定位无关。 +2. **套一层 HTTPS**:用 `tailscale serve`、`cloudflared tunnel` 或 `caddy` 反代到本地端口,拿到可信 HTTPS 域名后用手机访问。 +3. **自签证书**:需要在手机上安装并信任根证书,日常用比较麻烦。 + +局域网 http 下只是**定位**不可用,分析、天气、清单、AI 规划全部正常。 + +--- + +## Linux / 服务器部署 + +**可以直接跑,不需要改代码。** 项目是纯 Node、零 npm 依赖,没有任何 Windows 专有调用;平台差异只集中在住宿适配器里,并且都有 Linux 分支。 + +已在 WSL Debian(内核 6.6,Node v22.11.0)实测通过: + +| 项目 | 结果 | +| --- | --- | +| `node server.js` 启动 | 正常,banner / 端口回退 / 网卡枚举均正常 | +| `/`、`/styles.css`、`/app.js` | 200 | +| `/api/config`、`/api/hotel-providers` | 正常(无 rgh 时正确报「未安装」) | +| `/api/geocode?name=柳州` | 命中「柳州 · 广西」,中文无乱码 | +| `/api/staticmap` | 无 Key 时正确返回 `{error:"未配置高德 Key"}` | +| `/api/analyze`(柳州市 / 150km) | 24 个候选、`roadProvider=OSRM`、6.3 秒完成 | + +```bash +# 服务器上 +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs +git clone /opt/tripsense && cd /opt/tripsense +HOST=0.0.0.0 PORT=8787 node server.js +``` + +systemd 示例(`/etc/systemd/system/tripsense.service`): + +```ini +[Unit] +Description=TripSense +After=network.target + +[Service] +WorkingDirectory=/opt/tripsense +ExecStart=/usr/bin/node server.js +Environment=HOST=0.0.0.0 +Environment=PORT=8787 +Restart=always +User=www-data + +[Install] +WantedBy=multi-user.target +``` + +注意事项: + +- 静态资源用 `path.join(__dirname, 'public')`,所以**与启动目录无关**,systemd 里 `WorkingDirectory` 写对即可。 +- 不要把 `PORT` 改成 80/443:用 Nginx / Caddy 反代,顺带解决 HTTPS(定位功能需要安全上下文)。 +- `HOST=0.0.0.0` 后记得开放防火墙(`ufw allow 8787`),生产环境还应先补上鉴权与限流(见下节)。 +- 平台相关代码只有 `lib/hotels-rollinggo.js` 里的 rgh 调用:Linux 用 `which`、不需要 shell、全局安装在 `/usr/local/bin` 或 `~/.local/bin`。**WSL 特有**:`which rgh` 可能命中 `/mnt/c/...` 下的 Windows 版(那是 shell 脚本,Linux 跑不起来),适配器已主动排除 `/mnt/` 路径。 +- 服务器上装 RollingGo:`npm i -g @rollinggo/hotel` 后执行 `rgh login`。该命令是 OAuth 授权、没有「手动粘贴 code」的选项,**无图形界面的服务器可能完成不了**;兜底是在自己电脑上登录一次,然后把 `~/.hotel-cli/` 整个目录复制到服务器的同一路径。 + +--- + +## 生产环境建议 + +- 密钥只放在服务端环境变量;前端 `X-AI-*` 覆盖仅为原型调试用途,上线前应移除或改为服务端会话鉴权。 +- 为 `/api/*` 增加认证、限流与超时保护,不要直接把 `8787` 暴露到公网。 +- 换用有 SLA 的地理编码 / POI / 路网服务,或自建 Photon / OSRM 实例;内置地名库只应作为兜底。 +- 对天气与路网结果加缓存(按坐标 + 日期),避免重复请求第三方。 +- `SYSTEM_PROMPT` 若需调整,改 `config.js` 即可,前后端无需同步(弹窗从 `/api/config` 读取)。 + +--- + +## 项目结构 + +``` +tripsense/ +├── server.js 零依赖 HTTP 服务、路由、SSE、静态托管 +├── config.js 端点 / 环境变量 / 检索参数 / SYSTEM_PROMPT +├── package.json +├── lib/ +│ ├── util.js fetch 重试、并发限制、haversine、坐标转换、日期工具 +│ ├── data.js WMO 天气码、兴趣标签、三层名称过滤、各地点库汇总 +│ ├── places-cn.js 内置全国景区 + 主要城市坐标库 +│ ├── places-niche.js 内置小众地点库(206 条,古村/遗址/湿地/地质/洞穴等) +│ ├── sources.js 地理编码 / POI 发现 / 天气 / AQI / 路网 / 铁路 / 天气窗口 / 静态地图 +│ ├── hotels-rollinggo.js RollingGo 住宿适配器(本机 rgh CLI,只读白名单) +│ ├── analyze.js 校验 → 编排 → 评分 → 汇总 dataNotes +│ └── planner.js OpenAI 兼容流式代理 + 演示模式规划 +├── public/ +│ ├── index.html 三步流程 + 设置弹窗 +│ ├── styles.css FIELD NOTES 设计语言、响应式、罗盘、Markdown 输出 +│ └── app.js 表单 / 定位 / SSE / 渲染 / 筛选 / 对比 / 复制下载 +└── preview/ 三步页面的实际渲染截图 +``` + +--- + +## 验证记录(本次实测) + +- 全部 JS 文件 `node --check` 通过;静态资源 `/`、`styles.css`、`app.js` 均 200。 +- `/api/health` / `/api/config` / `/api/geocode` / `/api/reverse` / `/api/best-window` 正常。 +- `/api/analyze` SSE:北京市 `2026-11-09 ~ 2026-11-10`,`total=28`、`avgScore≈69`、`rainyCount=0`、`weatherMode=forecast`、`roadProvider=OSRM`;Top 5 为坡峰岭 78 / 故宫博物院 76 / 天坛公园 76 / 798艺术区 76 / 香山公园 76。 +- 小众地点链路:`stats.nicheCount=67`(150km 半径内命中数)、候选里 `niche=true` 占 10/28、`dataNotes` 写明「内置小众地点 67 个」、`region` 正常透传到卡片。 +- 逐日天气展开:展开后首卡 455px → 548px(+93px),**同一行邻卡 486px → 486px(变化 0)**,布局不再被连带拉伸;勾选触发重渲染后 `details[open]` 数量保持不变。 +- 筛选与对比:「只看小众」28 → 10 且全部带徽章;「对比已选」面板 7 行 × 3 列正常渲染。 +- `/api/plan` 演示模式 SSE:约 2.7k 字符,7 个 Markdown 章节、3 张表格;另用本地 mock OpenAI 兼容服务验证过真实流式代理(`stream=true`、`sysLen=1950`)。 +- 无头 Chrome 完整走通三步流程并截图:Step 2 渲染 28 张卡片 / 28 个罗盘点 / 6 条数据说明,Step 3 渲染 7 个章节 / 3 张表格。 +- 排除同城(北京 / 150km):默认开启时 28 个候选、最小距离 30.8km、`stats.localExcluded=27`、副标题显示「已排除 30km 内同城」,Top 5 全是 60km 以上的异地;关闭后最小距离 0.5km、12 个候选落在 30km 内。50km 半径时 `localKm` 自动压到 25km,候选从 11 个回升到 16 个。 +- 开放访问:`HOST=0.0.0.0` 启动后实际监听 `0.0.0.0:8788`(8787 被占用,自动顺延),`http://127.0.0.1:8788` 与 `http://192.168.1.100:8788` 均返回 200,用局域网地址在无头 Chrome 打开页面渲染正常(title / eyebrow / Step 1 / 排除同城开关全部就位)。防火墙已有 `node.exe` 的 Inbound Allow 规则,覆盖 Private 与 Public。 +- 非安全上下文定位:`http://192.168.1.100:8788` 下 `isSecureContext=false`,定位按钮自动置灰(`.geo-off`)、`#originHint` 转为 `.hint-warn` 并给中文说明,点击 toast 为「局域网 http 页面不能调用定位:请直接输入地名,或改用 HTTPS 访问」;`http://127.0.0.1:8788` 下 `isSecureContext=true`、按钮与提示保持原样(回归通过)。 +- 地名联想(iPhone 视口 390×844 仿真,走局域网地址):`杭州` 2 条、`西湖` 4 条、`杭州市西湖区` 宽松命中并显示「按「杭州」为你找到这些」、`zzzqqqxxx` 显示「没找到…」空态;下拉与输入框间距恒为 4px、宽度 328px 与容器右边缘对齐(溢出 0)。 +- 中文地名覆盖:Open-Meteo 实测对「金城江」「金城江区」「河池」「河池市金城江区」全部返回空(GeoNames 中文小城市覆盖不足),「金城」只返回台湾金门 / 美国加州等同名地点。已接入高德 `geocode/geo` + `place/text` 作为有 Key 时的首选源;无 Key 或有 Key 但取不到时退回 Open-Meteo。 +- 宽松重试不再乱猜:只对含「市/区/县/镇/路」等后缀的结构化长地址截断(`杭州市西湖区` → `杭州`),「金城江」这种纯区县名不再被截成「金城」,避免返回同名异地。 +- 设置里「测试这个 Key」:空 Key → 「先填 Key 再测」;无效 Key → 「不可用:高德地理编码:INVALID_USER_KEY」。 +- AI 接口自检(本地 mock 服务,端口 9998):正常 → `ok:true, 74ms, sample="可用"`;401 → `HTTP 401:Invalid API key provided`;空正文 → `接口通了但没返回内容(finish_reason=length)`;地址不可达 → `无法连接:fetch failed`;拒绝 `temperature` 的模型 → 自动去掉参数重试后 `ok:true` 并附 `note`。 +- `/api/plan` 失败诊断(同一 mock):空正文 → 「接口返回了流但没有正文(收到 2 个事件,finish_reason=length)」并附原始片段;只有推理内容 → 「模型只输出了 N 字思考过程,没有正文」;401 → 带状态码与上游错误体。设置弹窗实测显示 `可用 · 14ms · mock-gpt`(绿)/ `没有可用的 API Key`(红)。 +- 表单记忆:改单程时长 4h、半径 250km、取消「排除同城」、去掉「自然风光」、设出发地「河池 · 广西」→ 刷新后全部原样恢复(`hoursLabel` 也是「4 小时」,出发地提示「已定位:河池 · 广西(24.693, 108.085)」)。 +- 多轮调整:第一轮 `messages` 为 `system,user`;追加轮变成 `system,user,assistant,user`,assistant 携带上一版正文,末条 user 含新要求且要求「输出完整方案」。UI 实测:初次生成后出现输入条,追加后 meta 变「第 2 版 · 生成完成」、提示「当前是第 2 版,可以继续改」,点「回到初版」恢复第 1 版且 `planText === basePlan`。 +- 简约主题:实测 `body` 背景 `rgb(255,255,255)`、`--accent=#2f6fed`、顶栏白底、标题字体不含 Songti(已统一无衬线)、正文 `rgb(23,26,31)`。 +- 需求输入框:原来不在 `.field` 里所以没吃到样式,实测高度只有几十像素;修复后 225px、`1px rgb(213,217,222)` 边框、聚焦有蓝色描边。 +- 版本与保存:生成两版后下拉出现「第 1 版 / 第 2 版 · 第二天改成室内为主」,选「对比上一版」左右并排渲染(左「第 1 版(初版)」右「第 2 版 …」),点「保存」写入 `tripsense.saved` 并在下拉里出现 `11/5 15:02 · 北京市`。 +- 住宿接口:无 Key → `{hotels:[],available:false,reason:"未配置高德 Key"}`;无效 Key → `{available:true,error:"高德住宿检索:INVALID_USER_KEY"}`;进入第三步时左侧显示对应提示。 +- 住宿时机:进入第三步时 `#planExtra` 仍为隐藏(不再提前列酒店),生成方案后同时出现「行程路线示意」与「住宿建议」;`#stayNote` 显示「围绕「坡峰岭」· ¥200–800」(自定义区间),切换价位后同步更新。 +- 路线图只含目的地:无 Key 时渲染 3 个编号节点(坡峰岭 / 石林峡 / 野鸭湖湿地)、`circle` 共 6 个(每点两个环),**不含出发地圆心与「出发地」图例**,图例为「已选目的地(编号为推荐顺序,虚线为行程动线)」;`#routeNote` 为「3 个目的地 · 方位示意」。 +- 真实地图图层:`/api/staticmap` 无 Key → `{error:"未配置高德 Key"}`;非法 pts → `{error:"pts 不是合法坐标"}`;无效 Key → `{error:"高德静态地图:{\"status\":\"0\",\"info\":\"INVALID_USER_KEY\"...}"}`,前端自动降级为方位示意,并在图下写明原因(标题保持简短,不换行)。 +- 静态地图 URL 结构核对:三点 74km 跨度 → `zoom=9`、`size=680*400&scale=2`、逐点 `markers=medium,0x2f6fed,:lng,lat`、`paths=4,0x2f6fed,0.35,,:...`;单点 → `zoom=16`;5km → `13`;200km → `8`。坐标均为 GCJ-02(`wgs84ToGcj02`)。 +- 自定义价位:点「自定义」展开两个输入框 → 填 200 / 800 → `currentLodging()` 返回 `{min:200,max:800,label:"¥200–800"}`、chip 高亮「自定义」、提示「当前:每晚 ¥200–800」、`localStorage.tripsense.form.lodgingCustom` 正确落库;`/api/plan` 请求体里 `lodging` 为 `{key:"custom",min:200,max:800}`。 +- 住宿价格渲染(CDP 拦截 `/api/hotels` 造数据):三家全部无价格 → 顶部写明「无法按 ¥200–800 筛选」,出现 `.price-warn` 说明 `biz_ext.cost` 覆盖问题,每行「查房价」外链指向 `uri.amap.com/search?keyword=...`;改为 380/168/760/无价 四条 → 「命中 2 家 · 另有 1 家无价格标注」,168 被过滤、760 与 380 保留、无价照常列出并标「无标注价」,脚注变「价格来自第三方价格源」。 +- **演示模式修复**:演示方案开头是「> ⚠️ 演示模式…」,原先用 `startsWith('> ⚠️')` 判断生成失败,导致**演示模式下永远看不到路线图与住宿**;改为按 `errMsg` 与「未获得任何输出」判定后恢复正常。 +- RollingGo 适配器(实测 `@rollinggo/hotel@1.2.25`,装在临时目录、未登录): + - `rgh --help` / `search-hotels --help` 均可执行,确认参数含 `--max-price-per-night` / `--required-tag` / `--star-ratings` / `--distance-in-meter`,**没有** `--json` 开关(成功时直接输出 JSON)。 + - 未安装 → `{installed:false, reason:"未安装 rgh(npm i -g @rollinggo/hotel)"}`;已安装未登录 → `{installed:true, ready:false, reason:"rgh 已安装但未登录(执行 rgh login)"}`。 + - 未登录时调用 `search-hotels` 不崩,返回可读错误「RollingGo 返回无法解析:搜索失败: 未登录,请先执行 rgh login」。 + - 白名单校验:`book` / `price-confirm` 均不在 `ALLOWED_SUBCOMMANDS` 内。 + - JSON 提取:在输出前混入「📦 有新版本可用!」时仍能正确捞出 `hotelInformationList`。 + - 注入防护:地点名含 `& del /f /q C:\` 时直接拒绝,不进入子进程。 + - 服务端链路:`/api/hotels` 在无 Key 时返回「未配置高德 Key,且 rgh 已安装但未登录(执行 rgh login)」;设置里「检测」按钮实测显示「rgh 已安装但未登录(执行 rgh login)· 未配高德 Key」。 +- RollingGo 登录后真实数据(`rgh status` → `ready:true`): + - `/api/hotels?name=故宫&nights=1` → `provider=rollinggo`、8 家、带 `cost` / `distanceKm` / `bookingUrl`;`nights=3` → 6 家;`maxPrice=700&nights=3` → 4 家且全部 ≤ ¥700/晚(证明 `--max-price-per-night` 下推生效)。 + - 完整三步流程(demo 方案 + 真实住宿):住宿区显示「价位 ¥300–600 · 命中 3 家」,列表为 `北京福庭酒店 ★4 · ¥521/晚 · 3.5km 去预订` / `国际艺苑大酒店 ¥547/晚 1.2km` / `北京新侨饭店 ¥524/晚 2.3km`,脚注「价格为 RollingGo 返回的每晚参考价,实际金额以下单页锁价为准」,无「无标注价」警告。 + - 设置里先填 Key 再点「检测」→「RollingGo 可用(实时报价)· 高德 Key 已配置(页面填写)」,修掉了「只读环境变量、误报未配 Key」的问题(前端用输入框当前值 + `X-AMAP-Key` 头)。 +- 两个环境相关修复:① 服务进程的 PATH 可能不含 npm 全局目录,`resolveBin` 补查 `%APPDATA%\npm` 等位置;② Windows 下 `where rgh` 首个结果是**无扩展名的 sh 脚本**(spawn 不起来),改为按 `.exe > .cmd > .bat` 排序后再取。 +- 静态地图 20003 修复:`markers` 原先写成多个同名参数(`&markers=..&markers=..`),高德判为非法并返回 `{"status":"0","info":"UNKNOWN_ERROR","infocode":"20003"}`(**这不是 Key 失效**)。改为单个 `markers=` 参数、组间用 `|` 分隔;同时 `size` 从 680×400 降到 640×360,因为 `scale=2` 会返回双倍像素,680×400×4 会超过高德 1024×1024 的上限。 +- 路线图单点场景:只有 1 个目的地时补出发点(`origin` 参数 → `markers=small,0x6b7280,S`),SVG 降级图同样画出发地并连线;多目的地时不含出发地。实测 1 个目的地 → 1 个编号节点 + 出发点 + polyline;3 个目的地 → 3 个节点、无出发点。 +- 住宿地点类型:`柳州市` 这类行政名原先按「景点」查 → RollingGo 返回 0 家 → 误降级到高德(于是又看到「价格未标注」)。新增 `guessPlaceType()`(市/地区/自治州→城市,区/县→区-县,其余→景点),并支持查空后换一种类型重试一次。实测 `柳州市` → 城市类型 6 家;`柳州` → 景点类型也能查到 4 家;`坡峰岭` 换城市类型虽返回重庆酒店,但被距离过滤全部丢弃,最终 0 家(正确)。 +- 住宿距离修正:数据源返回的 `distanceInMeters` **是错的**(柳州城中区的酒店被标 64527 米,按坐标算实际 2.8km),改为优先用坐标自己算 haversine,并按查询半径过滤(城市类放宽到至少 20km),避免「去柳州却推荐重庆酒店」。 +- 住宿城市兜底:景区名在酒店库里常查不到(酒店按城市建库),查空后用反向地理编码取所属城市再查一次,再按坐标过滤。 +- 地图 20003 的两轮排查:① `markers` 写成多个同名参数 → 改为单个 + `|` 分组;② 手工拼 query 会留下**裸 `|`**,高德判为非法 → 改用 `URL` + `URLSearchParams` 构造,`|` 编码为 `%7C`。同时服务端做了渐进降级(`full` → `markers` → `base`),并通过 `X-Map-Mode` 告诉前端用了哪一档;`/api/staticmap?probe=1` 会依次试 **编码版 / 未编码版 / 只标记 / 只底图** 四种组合,设置页「测试这个 Key」直接调用它,一次就能定位是哪个参数被拒(返回里带脱敏后的完整 URL)。 +- **地图 20003 的第三个原因(已改)**:对照高德官方文档,`markers` 的 size 只接受 `small / mid / large`——原来写的是 `medium`,属于非法值;同时官方所有示例都用**裸分隔符**(`,` `;` `|`),所以默认改回裸分隔符拼接,编码版仅作对照。今 URL 形如 `markers=mid,0x2f6fed,1:109.41,24.30|mid,0x2f6fed,2:...`,`paths=4,0x2f6fed,0.35,,:...`(weight 4 在 [2,15] 内)。 +- nearby POI:`/api/nearby-pois` 无 Key 时返回 `{available:false, reason:"未配置高德 Key"}`;有 Key 时按 `biz_ext.rating/cost` 补齐评分与人均,按距离排序、按名称去重,餐饮与景点分开各取前 12 条。 +- 推理模型 token 不足的自动重试(mock 四个用例):正常模型 1 次请求即成功;「只有思考没有正文」→ 请求序列 `[8000, 16384]`,顶部出现「已把上限 8000 → 16384 自动重试」并最终拿到正文;两次都只有思考 → 报「已把 max_tokens 提到 16384 仍不够,建议换非推理模型」;额度超限 → 序列 `[8000, <无 max_tokens>]`,降级后成功。 +- 高德 Key 链路(用故意无效的 Key 验证):设置弹窗能填写并存入 `localStorage` → 请求带 `X-AMAP-Key` → 服务端覆盖 `cfg.AMAP_KEY` → 高德真实返回 `INVALID_USER_KEY` → `stats.amapError` 透传 → 页面顶部红边提示「高德接口拒绝请求:INVALID_USER_KEY」并给出「修改 Key」入口;同时公路正确回退为 `roadProvider=OSRM`(不再因"配了 Key"就谎称走高德)。清除 Key 后提示切换为「设置已更新,请重新分析」。 +- **路线图消失的根因**:`routeMapBlob` 是从未声明过的模块级变量,`'use strict'` 下抛 `ReferenceError`;`drawRouteMap()` 是 async 且调用处没有 catch,异常变成未处理的 Promise 拒绝 → DOM 保持为空、尺寸 0×0,页面看起来"路线图不显示"。改为 `state.routeMapBlob`(在 state 里声明)并在调用处补 `.catch()`,失败时降级为方位示意并写明原因,杜绝静默失败。修复后实测 `#routeMap` 尺寸 563×326、`img` 与 `svg` 各 1 个、3 个编号标记 + 折线。 +- **缓存失效的根因(隐蔽)**:`$('#analyzeBtn').addEventListener('click', runAnalyze)` 会把 **Event 对象**传进 `runAnalyze(force)`,而 `Boolean(event)` 恒为 `true` —— 于是每一次点击都是「强制刷新」,缓存永远不命中。改为只认严格的 `force === true`,并把监听器包成 `() => runAnalyze()`。实测:冷启动 12.8s → 第二次 518ms(24 倍),提示条正确显示;点「重新获取最新数据」后提示条正确隐藏。 +- **反向地理编码的假地名**:`reverseGeocode` 彻底失败时兜底返回「当前位置」这个**假地名**,拿它去查酒店库必然落空,还会被误判成"该数据源没数据"。新增 `resolveCityName()` 把假地名视为失败并重试一次;数据源层缓存也**不缓存**假地名。 +- **缓存分层实测**:`/api/analyze` 层缓存 30 分钟;数据源层(`lib/sources.js`)另有 Photon 分类检索 / 高德 POI / 天气 / AQI / 路网 / 铁路各自的缓存。清空后重建:`discoverCandidates` 首次 8137ms → 第二次 **0ms**;`roadMatrix` 首次 127ms → 第二次 **0ms**。 + +预览图见 `preview/preview-step1.png`、`preview/preview-step2.png`、`preview/preview-step2-detail.png`(展开逐日天气 + 对比面板)、`preview/preview-step3.png`、`preview/preview-settings.png`(设置弹窗含高德 Key)、`preview/preview-amap-error.png`(Key 无效时的提示)、`preview/preview-map-fallback.png` 与 `preview/preview-map-price.png`(只含目的地的路线图 + 自定义价位 + 住宿价格缺失说明)。 + +本轮(缓存 + 路线图修复 + 真实浏览器验收)新增:`preview/preview-diag-route.png`(高德真实底图 + 3 个编号标记 + 折线连线)、`preview/preview-diag-hotel.png`(RollingGo 真实报价 ¥2416/晚)、`preview/preview-cache.png`(缓存命中提示条)。 + +> 说明:上面这组截图恰好是在 Photon 公共实例全部超时(`stats.fallbackOnly=true`)时拍的,因此 Step 2 顶部能看到降级提示条——这正好是「实时源挂掉时页面怎么表现」的真实记录;天气、AQI、路况仍为实时数据。 diff --git a/config.js b/config.js new file mode 100644 index 0000000..a9e8c2c --- /dev/null +++ b/config.js @@ -0,0 +1,122 @@ +'use strict'; + +/** + * TripSense · 周边出行顾问 —— 全局配置 + * + * 所有密钥优先从环境变量读取;也可以在网页「AI 接口设置」里临时覆盖 + * (覆盖值只保存在浏览器 localStorage,并由前端通过请求头传给本服务代理)。 + * 生产环境请只用环境变量,不要把密钥写进前端。 + */ + +const env = process.env; + +module.exports = { + PORT: Number(env.PORT || 8787), + // 默认绑定 0.0.0.0,方便手机 / 同网段设备直接访问。 + // 只在本机用就把 HOST 设成 127.0.0.1(`HOST=127.0.0.1 node server.js`)。 + HOST: env.HOST || '0.0.0.0', + // 端口被占用时,server.js 会自动往后找可用端口,启动日志会打印实际地址。 + + // ---------------------------------------------------------------- 数据源 + ENDPOINTS: { + geocoding: 'https://geocoding-api.open-meteo.com/v1/search', + forecast: 'https://api.open-meteo.com/v1/forecast', + archive: 'https://archive-api.open-meteo.com/v1/archive', + airQuality: 'https://air-quality-api.open-meteo.com/v1/air-quality', + photon: 'https://photon.komoot.io/api', + reverse: 'https://api.bigdatacloud.net/data/reverse-geocode-client', + osrmTable: 'https://router.project-osrm.org/table/v1/driving', + amapDriving: 'https://restapi.amap.com/v3/direction/driving', + amapAround: 'https://restapi.amap.com/v3/place/around', + amapGeocode: 'https://restapi.amap.com/v3/geocode/geo', + amapPlaceText: 'https://restapi.amap.com/v3/place/text', + // 反向地理编码(坐标 → 地名):BigDataCloud / Photon reverse 在国内常被限流或返回空, + // 高德 regeo 能稳定拿到「城市 + 区县 + 街道」,是住宿城市兜底的关键 + amapRegeo: 'https://restapi.amap.com/v3/geocode/regeo', + // 静态地图(真实地图底图):仅使用合规白名单内的地图服务商,Key 由后端代理,不下发前端 + amapStaticMap: 'https://restapi.amap.com/v3/staticmap', + }, + + // 选填:高德 Web 服务 Key。配置后公路耗时改用高德(国内路网/限行更贴近实际) + AMAP_KEY: env.AMAP_KEY || '', + + // 选填:铁路/高铁接口模板(留给 12306 或第三方服务)。 + // 支持的占位符:{fromLat} {fromLon} {toLat} {toLon} {date} {fromName} {toName} + // 期望返回 JSON:{ available:boolean, durationMinutes:number, distanceKm:number, fareCny:number, note:string } + // 未配置或调用失败时,自动降级为「里程-速度」估算,并在结果里明确标注「估算」。 + RAIL_API_URL: env.RAIL_API_URL || '', + RAIL_API_KEY: env.RAIL_API_KEY || '', + + // 选填:住宿价格数据源(高德 POI 的价格字段覆盖很差,多数酒店没有参考价)。 + // 支持占位符:{name} {lat} {lon} {city} + // 期望返回 JSON:[{ name:string, price:number, currency?:string, url?:string }] + // 按酒店名模糊匹配后合并;未配置或失败时不影响主流程,只是没有价格。 + HOTEL_PRICE_API_URL: env.HOTEL_PRICE_API_URL || '', + HOTEL_PRICE_API_KEY: env.HOTEL_PRICE_API_KEY || '', + + // 选填:住宿数据来源。auto = 装了 rgh 且已登录就用 RollingGo,否则用高德 POI + // amap 只用高德 POI(无实时房价,但零配置) + // rollinggo 只尝试本机 rgh CLI(OAuth 登录后才有实时报价) + // 注意:rgh 只以只读方式调用(status / search-hotels / hotel-detail), + // 绝不调用 price-confirm / book / cancel 这类会产生真实交易的子命令。 + HOTEL_PROVIDER: (env.HOTEL_PROVIDER || 'auto').toLowerCase(), + RHG_BIN: env.RHG_BIN || 'rgh', + ROLLINGGO_TIMEOUT_MS: Number(env.ROLLINGGO_TIMEOUT_MS || 25000), + + // 检索参数 + SEARCH: { + maxRadiusKm: 300, // 最大检索半径 + photonConcurrency: 5, // 对公共 Photon 服务保持克制 + photonPerCategory: 8, // 每个类别取多少条 + photonTimeoutMs: 4000, // Photon 公共实例易被限流,快速失败并走兜底 + photonRetries: 0, + maxCandidates: 28, // 进入天气/路网计算的候选上限 + requestTimeoutMs: 15000, // 天气/地理编码等主数据源 + osrmTimeoutMs: 12000, + amapAroundRadius: 50000, // 高德 around 检索上限 50km + }, + + // ------------------------------------------------- OpenAI 兼容接口(预留) + AI: { + // 任何兼容 /chat/completions 的服务都可以直接填这里 + BASE_URL: env.AI_BASE_URL || 'https://api.openai.com/v1', + API_KEY: env.AI_API_KEY || '', + MODEL: env.AI_MODEL || 'gpt-4o-mini', + TEMPERATURE: Number(env.AI_TEMPERATURE || 0.7), + MAX_TOKENS: Number(env.AI_MAX_TOKENS || 8000), + TIMEOUT_MS: Number(env.AI_TIMEOUT_MS || 120000), + }, + + // ------------------------------------------------------------ 预设提示词 + // 这段提示词把「本站能做什么、数据从哪来、哪些是估算」写死给模型, + // 避免模型编造票价/班次/景点,也保证输出结构稳定。 + SYSTEM_PROMPT: `你是「行知 · 周边出行顾问」的行程规划师。只使用输入 JSON 中的数据,禁止编造。 + +【输入】 +origin 出发地;timeRange 起止/天数;constraints 单程上限与兴趣; +shortlist 候选(name/kind/tags/distanceKm/direction/score/verdict/travel/weather/aqi); + 只有 rank1 带 weather.daily 与 road/rail 明细,其余候选只有区间摘要与 bestHours; +nearby 目的地周边可点名地点(food 餐饮、sights 景点,含 name/type/distanceKm/rating/cost); +hotels 住宿 POI;lodging 用户价位区间;userNeeds 用户需求;dataNotes 数据口径。 + +【规则】 +1. 不编造未提供的景点、餐厅、票价、班次、营业时间;需要用户核实的写「出发前核对」。 +2. 估算数据(铁路耗时票价、历史气候替代、路网兜底)必须标注「估算」,不得当事实。 +3. 有 nearby 时,吃和玩必须从中点名:「名称(类型,距目的地 X km,评分 Y,人均 ¥Z)」,cost 没有就不写。 + 禁止用「附近有很多小吃」这类空话代替点名。某一类确实缺失,才退回「片区 + 筛选方法」,并说明缺的是哪类。 +4. 住宿从 hotels 里挑,写名称 + 距离 + 参考价,注明房态与实时价需在平台核实; + lodging 有 min/max 就必须落在区间内,与 hotels 价格冲突时直说并建议放宽,不要硬塞进预算。 +5. 住宿靠近当天主要活动地;跨片区要说明是否换住处、换的成本。 +6. 发现现实冲突(时间不够、天气差、闭馆、预算不足)直接指出,不要讨好式输出。 + +【输出】Markdown,直接从「## 方案总览」开始,不要开场白、不要复述规则。 +篇幅硬约束:全文 ≤ 900 字;表格单元格 ≤ 18 字;只写可执行结论,不复述输入数据。 +## 方案总览(结论 + 主推理由 + 备选及原因 + 总节奏) +## 逐日安排(### D1(日期 星期)+ 表格:时段|安排|交通与耗时|天气应对;雨天给室内替换) +## 天气与装备(逐日摘要 + 穿衣/雨具 + 降雨大风兜底动作) +## 交通方案(按允许方式写耗时/里程/来源标注/停车换乘/末班提醒,车次余票需自行核对) +## 预算粗估(表格:交通/门票/餐饮/住宿/其他 + 合计,注明粗略、不含购物) +## 出发前清单(预订、证件、装备、需复核的信息) +## 风险与备选(2-4 条,每条「触发条件 → 替换动作」) +`, +}; diff --git a/lib/analyze.js b/lib/analyze.js new file mode 100644 index 0000000..0e1c5d5 --- /dev/null +++ b/lib/analyze.js @@ -0,0 +1,326 @@ +'use strict'; + +/** + * 分析编排:候选发现 → 天气 → 空气质量 → 公路 → 铁路 → 综合评分 → 备选清单 + * progress(stage, pct, text) 用于 SSE 进度推送。 + */ + +const U = require('./util'); +const S = require('./sources'); +const { aqiLabel } = S; + +/** 小时数 → 人类可读时长(0.07h 读起来毫无意义,转成 4分钟) */ +function humanHours(h) { + if (h == null || !Number.isFinite(h)) return '—'; + const total = Math.round(h * 60); + const hh = Math.floor(total / 60); + const mm = total % 60; + return hh ? `${hh}小时${mm ? mm + '分' : ''}` : `${mm}分钟`; +} + +function validate(input) { + const start = String(input.startDate || ''); + const end = String(input.endDate || ''); + if (!U.isValidISO(start) || !U.isValidISO(end)) throw new Error('请提供合法的出行起止日期(YYYY-MM-DD)'); + const days = U.diffDays(start, end) + 1; + if (days < 1) throw new Error('结束日期不能早于开始日期'); + if (days > 31) throw new Error('单次分析最多支持 31 天,请缩小时间范围'); + const maxOneWayHours = U.clamp(Number(input.maxOneWayHours) || 2.5, 0.5, 8); + const radiusKm = U.clamp(Number(input.radiusKm) || 150, 20, 300); + // 排除同城:填了高德 Key 后 POI 会带回大量本市城区点位,和「周边出行」的定位冲突。 + // 默认开启;localKm 不超过搜索半径的六成,避免小半径时把清单清空。 + const excludeLocal = input.excludeLocal !== false; + const localKm = Math.min( + U.clamp(Number(input.localKm) || 30, 10, 80), + Math.max(8, Math.round(radiusKm * 0.5)), + ); + const o = input.origin || {}; + if (!o.lat || !o.lon) { + if (!String(o.name || '').trim()) throw new Error('请填写出发地或允许网页定位'); + } + return { + start, end, days, maxOneWayHours, radiusKm, excludeLocal, localKm, + transportPrefs: Array.isArray(input.transportPrefs) ? input.transportPrefs : [], + interests: Array.isArray(input.interests) ? input.interests : [], + origin: { + name: o.name || '当前位置', + label: o.label || o.name || '当前位置', + lat: Number(o.lat), lon: Number(o.lon), + admin1: o.admin1 || '', country: o.country || '', + }, + }; +} + +function travelOptions(c, road, rail, prefs) { + const allowAll = prefs.length === 0 || prefs.includes('不限'); + const allowRoad = allowAll || prefs.includes('自驾'); + const allowRail = allowAll || prefs.some((p) => /铁路|高铁|火车/.test(p)); + const allowTransit = allowAll || prefs.includes('公共交通'); + const options = []; + if (allowRoad && Number.isFinite(road?.durationMinutes)) { + options.push({ mode: '自驾', hours: U.round(road.durationMinutes / 60, 2), source: road.source, estimate: road.source === '估算' }); + } + if (allowRail && rail?.available && Number.isFinite(rail.durationMinutes)) { + options.push({ mode: '铁路/高铁', hours: U.round(rail.durationMinutes / 60, 2), source: rail.source, estimate: rail.source === '估算' }); + } + if (allowTransit && Number.isFinite(road?.durationMinutes)) { + options.push({ mode: '公共交通', hours: U.round((road.durationMinutes * 1.28) / 60, 2), source: '估算', estimate: true }); + } + options.sort((a, b) => a.hours - b.hours); + const best = options[0] || null; + return { options, bestHours: best ? best.hours : null, bestMode: best ? best.mode : null }; +} + +function scoreOne(c, ctx) { + const { weather, aqi, road, rail, constraints, days } = ctx; + const s = weather?.summary || null; + const travel = travelOptions(c, road, rail, constraints.transportPrefs); + + // 1) 天气舒适度 0~40 + let weatherScore = 20; + if (s) { + const tempPenalty = U.clamp(Math.max(0, 12 - s.avgLow) * 0.9 + Math.max(0, s.avgHigh - 28) * 1.1, 0, 16); + const precipPenalty = U.clamp(s.totalPrecip * 1.1 + s.maxPrecipProb * 0.09, 0, 16); + const windPenalty = U.clamp(Math.max(0, s.maxWind - 25) * 0.22, 0, 7); + const severePenalty = U.clamp(s.badDays * 3, 0, 10); + weatherScore = U.clamp(40 - tempPenalty - precipPenalty - windPenalty - severePenalty, 0, 40); + } + + // 2) 时间适配 0~26 + let timeScore = 2; + let overLimit = false; + if (travel.bestHours != null) { + if (travel.bestHours > constraints.maxOneWayHours) { + timeScore = 4; + overLimit = true; + } else { + timeScore = U.clamp( + 26 - (travel.bestHours / constraints.maxOneWayHours) * 15 - (travel.bestHours > 3 ? 4 : 0) + (days >= 2 && travel.bestHours <= 1.5 ? 2 : 0), + 0, 26, + ); + } + } + + // 3) 距离 0~7(不鼓励「越近越好」,只做轻微惩罚) + const distanceScore = U.clamp(7 - (c.distanceKm / constraints.radiusKm) * 4.5, 2.5, 7); + + // 4) 兴趣匹配 0~14 + let interestScore = 8; + const matched = constraints.interests.filter((i) => c.tags.includes(i)); + if (constraints.interests.length) interestScore = U.clamp(4 + matched.length * 4, 0, 14); + + // 5) 空气质量 0~6 + let aqiScore = 3; + if (aqi != null) aqiScore = aqi <= 50 ? 6 : aqi <= 100 ? 4 : aqi <= 150 ? 2 : 0; + + // 6) 目的地辨识度 0~8(知名景区 > 小众精选 > OSM 无名点位) + const qualityScore = U.clamp( + (c.featured ? 6 : 0) + + (c.hasExtent ? 1 : 0) + + (/高德/.test(c.source || '') ? 1 : 0) + + (c.niche ? 1 : 0), + 0, 8, + ); + + const score = U.round(weatherScore + timeScore + distanceScore + interestScore + aqiScore + qualityScore, 0); + const breakdown = { + weather: U.round(weatherScore, 1), time: U.round(timeScore, 1), + distance: U.round(distanceScore, 1), interest: U.round(interestScore, 1), + aqi: U.round(aqiScore, 1), quality: U.round(qualityScore, 1), + }; + + let verdict = score >= 74 ? '强烈推荐' : score >= 60 ? '推荐' : score >= 45 ? '可考虑' : '不推荐'; + // 天气硬门槛:整段行程都是坏天气时,不因交通/距离优势给出「推荐」 + if (s && s.badDays >= s.days && verdict === '强烈推荐') verdict = '推荐'; + if (s && s.badDays >= s.days && verdict === '推荐') verdict = '可考虑'; + return { score, breakdown, verdict, travel, overLimit, matched }; +} + +function buildReasons(c, ctx, scored) { + const { weather, aqi, road, rail, constraints, days, weatherMode } = ctx; + const s = weather?.summary; + const reasons = []; + const warnings = []; + + if (s) { + if (s.badDays === 0 && s.maxPrecipProb < 40) { + reasons.push(`${s.days} 天以${s.dominant.label}为主,平均 ${s.avgLow}~${s.avgHigh}℃,降水概率最高 ${s.maxPrecipProb}%,天气窗口友好`); + } else { + reasons.push(`${s.days} 天内有 ${s.badDays} 天需注意天气(最差:${s.dominant.label}),累计降水 ${s.totalPrecip}mm、最大风速 ${s.maxWind}km/h`); + warnings.push('期间有明显降水/大风,建议准备室内备选'); + } + if (s.avgHigh >= 30) warnings.push('高温,注意防晒补水并避开正午户外'); + if (s.avgLow <= 3) warnings.push('气温偏低,需保暖并留意路面结冰'); + } else { + warnings.push('未取到该地天气数据,天气维度按中性计分'); + } + + const optionsText = scored.travel.options + .slice(0, 2) + .map((o) => `${o.mode} ${humanHours(o.hours)}${o.estimate ? '(估算)' : ''}`) + .join(' / '); + if (optionsText) reasons.push(`${optionsText};公路里程约 ${road.distanceKm}km`); + if (scored.travel.bestHours == null) warnings.push('按所选交通方式无可达方案'); + else if (scored.overLimit) warnings.push(`最快单程 ${humanHours(scored.travel.bestHours)},超出你设定的 ${constraints.maxOneWayHours} 小时`); + else reasons.push(`最快单程约 ${humanHours(scored.travel.bestHours)}(${scored.travel.bestMode}),${days} 天行程节奏${days >= 2 && scored.travel.bestHours <= 1.5 ? '宽松' : '适中'}`); + + if (scored.matched.length) reasons.push(`匹配你的兴趣:${scored.matched.join('、')}`); + if (aqi != null) { + const a = aqiLabel(aqi); + reasons.push(`空气质量${a.label}(AQI ${aqi})`); + if (a.level === 'bad') warnings.push('空气质量较差,敏感人群减少户外停留'); + } + + if (road.source === '估算') warnings.push('公路耗时为路网兜底估算,请出发前用地图复核'); + if (rail.source === '估算') warnings.push('铁路耗时/票价为估算,车次与余票需在 12306 核对'); + if (weatherMode === 'climatology') warnings.push('超出 16 天预报范围,天气为去年同期历史气候参考,非实时预报'); + + return { reasons: reasons.slice(0, 5), warnings: warnings.slice(0, 4) }; +} + +async function analyze(input, cfg, progress = () => {}) { + const c = validate(input); + const t0 = Date.now(); + + // ---- 0. 解析出发地 + progress('origin', 6, '解析出发地坐标'); + let origin = { ...c.origin }; + if (!Number.isFinite(origin.lat) || !Number.isFinite(origin.lon)) { + const hits = await S.geocode(origin.name, cfg, 1); + if (!hits.length) throw new Error(`没找到「${origin.name}」,换个地名试试`); + origin = { ...hits[0], name: origin.name, label: hits[0].label }; + } + origin.lat = U.round(origin.lat, 5); + origin.lon = U.round(origin.lon, 5); + + // 出发地的行政区信息(省/市/区县)。 + // 用途:① 高德 place/text 检索时限定在同一个省;② 「排除同城」按**区县**判定—— + // 同市下辖的其它县(融水、三江、鹿寨…)是真正的周边目的地,不该被排除。 + if (!origin.district || !origin.province) { + try { + const rev = await S.reverseGeocode(origin.lat, origin.lon, cfg); + origin.province = origin.province || (rev.admin1 || ''); + origin.city = origin.city || rev.name || ''; + origin.district = origin.district || (rev.label || '').split(' · ').pop() || ''; + } catch { /* 拿不到就退回距离判定 */ } + } + // 高德检索限定在同一个省(周边出行通常不出省) + const searchCfg = origin.province ? { ...cfg, AMAP_PROVINCE: origin.province.replace(/省$/, '') } : cfg; + + // ---- 1. 周边候选 + progress('discover', 18, `检索 ${c.radiusKm}km 内的景点、公园、博物馆与邻近城市`); + const { candidates, stats } = await S.discoverCandidates(origin, c.radiusKm, searchCfg, { + excludeLocal: c.excludeLocal, + localKm: c.localKm, + homeDistrict: origin.district || '', + homeCity: origin.city || '', + }); + if (!candidates.length) { + throw new Error('这个范围内没有检索到可用目的地,试试调大搜索半径'); + } + + // ---- 2. 天气 + 空气质量(并行) + progress('weather', 42, `获取 ${candidates.length} 个候选地的逐日天气与空气质量`); + const [weatherRes, aqiRes] = await Promise.all([ + S.fetchWeather(candidates, c.start, c.end, cfg), + S.fetchAQI(candidates, c.start, c.end, cfg), + ]); + + // ---- 3. 公路 + 铁路(并行) + progress('traffic', 66, '计算公路路网耗时与铁路可达性'); + const [roadRes, railRes] = await Promise.all([ + S.roadMatrix(origin, candidates, cfg), + S.railMatrix(origin, candidates, c.start, cfg), + ]); + + // ---- 4. 评分 + progress('score', 86, '综合天气、交通、距离与偏好评分'); + const ctxBase = { constraints: c, days: c.days, weatherMode: weatherRes.mode }; + const scored = candidates.map((cd, i) => { + const ctx = { + ...ctxBase, + weather: weatherRes.series[i], + aqi: aqiRes.values[i], + road: roadRes.matrix[i], + rail: railRes[i], + }; + const s = scoreOne(cd, ctx); + const { reasons, warnings } = buildReasons(cd, ctx, s); + return { + ...cd, + score: s.score, + breakdown: s.breakdown, + verdict: s.verdict, + travel: s.travel, + weather: ctx.weather ? { + summary: ctx.weather.summary, + daily: ctx.weather.daily, + } : null, + aqi: ctx.aqi, + aqiInfo: aqiLabel(ctx.aqi), + road: ctx.road, + rail: ctx.rail, + reasons, + warnings, + }; + }); + scored.sort((a, b) => b.score - a.score || a.distanceKm - b.distanceKm); + scored.forEach((x, i) => { x.rank = i + 1; }); + + // ---- 5. 汇总 + const withWeather = scored.filter((x) => x.weather?.summary); + const avgHigh = withWeather.length + ? U.round(withWeather.reduce((s, x) => s + x.weather.summary.avgHigh, 0) / withWeather.length, 1) : null; + const rainy = withWeather.filter((x) => x.weather.summary.badDays > 0).length; + const weatherAlert = withWeather.length > 0 && rainy / withWeather.length >= 0.7 + ? '所选时间窗口内多数候选地有降水/大风等不利天气,建议关注逐日天气并准备室内备选' + : null; + + const dataNotes = [ + `周边目的地:Photon / OpenStreetMap(成功 ${stats.categoryHits}/${stats.requested} 类)` + + `${stats.amapCount ? ` + 高德周边 ${stats.amapCount} 条` : ''}` + + ` + 内置知名景区 ${stats.curatedCount} 个 + 内置城市 ${stats.cityCount} 个 + 内置小众地点 ${stats.nicheCount} 个` + + `${stats.fallbackOnly ? ';⚠️ 实时 POI 服务不可用,本次清单完全来自内置地名库(坐标为近似值)' : ''}` + + `${c.excludeLocal && stats.localExcluded ? `;已排除出发地所在城区的同城点位 ${stats.localExcluded} 个(同市下辖的其它县保留为周边目的地)` : ''}`, + `天气:${weatherRes.note}`, + `空气质量:${aqiRes.note}`, + `公路:${roadRes.provider === '估算' ? '路网服务不可用,全部为里程-速度估算' : roadRes.provider === '高德' ? '高德 Web 服务驾车路径(WGS-84→GCJ-02)' : 'OSRM 公开路网矩阵'}`, + `铁路:${cfg.RAIL_API_URL ? '优先调用配置的铁路接口,失败时降级估算' : '未配置铁路接口,高铁/城际耗时与票价均为估算'}`, + '所有标注「估算」的数据不得视为实时信息;车次、票价、门票、营业时间需临近出发前在官方渠道核对', + ]; + + const result = { + origin, + timeRange: { + start: c.start, end: c.end, days: c.days, + weekdayHint: `${U.weekdayCN(c.start)} ~ ${U.weekdayCN(c.end)}`, + }, + constraints: { + maxOneWayHours: c.maxOneWayHours, + radiusKm: c.radiusKm, + excludeLocal: c.excludeLocal, + localKm: c.localKm, + transportPrefs: c.transportPrefs, + interests: c.interests, + }, + summary: { + total: scored.length, + recommended: scored.filter((x) => x.score >= 60).length, + avgScore: U.round(scored.reduce((s, x) => s + x.score, 0) / scored.length, 1), + avgHigh, + rainyCount: rainy, + weatherAlert, + weatherMode: weatherRes.mode, + weatherNote: weatherRes.note, + roadProvider: roadRes.provider, + elapsedMs: Date.now() - t0, + }, + candidates: scored, + dataNotes, + stats, + }; + progress('done', 100, '分析完成'); + return result; +} + +module.exports = { analyze, validate }; diff --git a/lib/cache.js b/lib/cache.js new file mode 100644 index 0000000..da20001 --- /dev/null +++ b/lib/cache.js @@ -0,0 +1,119 @@ +'use strict'; + +const U = require('./util'); + +/** + * 分析结果缓存(服务端内存) + * + * 为什么需要:一次 analyze 要打 Photon / Open-Meteo / AQI / OSRM / 高德 + * 十几次外部请求,耗时 6–15 秒。同样的出发地 + 日期 + 半径 + 兴趣, + * 用户往往会反复点「分析」(换个兴趣再点、看到结果想再刷一次), + * 而这些数据在几十分钟内基本不变(天气预报按小时更新)。 + * + * 设计要点: + * 1. 键 = 规范化后的请求指纹(origin 坐标取整到 3 位 ≈ 100m,其余字段精确)。 + * 高德 Key 也参与指纹——换 Key 会改变 POI 结果,不能串味。 + * 2. 缓存的是**完整分析结果**,命中时直接复用,不重打外部请求。 + * 3. TTL 默认 30 分钟:超过就重算。天气/路况本来就按小时更新。 + * 4. 带 LRU 上限(默认 40 条),避免长时间运行把内存吃满。 + * 5. 命中时在结果里写 cacheHit / cachedAt,前端要如实告诉用户"这是缓存"。 + */ + +const DEFAULT_TTL_MS = 30 * 60 * 1000; +const DEFAULT_MAX = 300; + +const store = new Map(); // key -> { at, result } + +/** 把请求归一化成稳定指纹 */ +function fingerprint(body, cfg, extra = {}) { + const o = body.origin || {}; + const lat = Number(o.lat); + const lon = Number(o.lon); + const key = { + // 坐标取整到 3 位小数(约 100 米),避免浮点尾数造成每次都不命中 + lat: Number.isFinite(lat) ? Math.round(lat * 1000) / 1000 : null, + lon: Number.isFinite(lon) ? Math.round(lon * 1000) / 1000 : null, + name: String(o.name || '').trim().slice(0, 40), + start: String(body.startDate || ''), + end: String(body.endDate || ''), + maxHours: body.maxOneWayHours, + radiusKm: body.radiusKm, + excludeLocal: body.excludeLocal !== false, + localKm: body.localKm, + transport: (body.transportPrefs || []).slice().sort().join(','), + interests: (body.interests || []).slice().sort().join(','), + // 高德 Key 参与指纹:换了 Key,POI 与路网结果会变,不能复用 + amap: cfg && cfg.AMAP_KEY ? String(cfg.AMAP_KEY).slice(-8) : '', + ...extra, + }; + return JSON.stringify(key); +} + +function get(key) { + const hit = store.get(key); + if (!hit) return null; + const ttl = hit.ttlMs || DEFAULT_TTL_MS; + if (Date.now() - hit.at > ttl) { + store.delete(key); + return null; + } + // LRU:命中后移到末尾 + store.delete(key); + store.set(key, hit); + return { result: hit.result, cachedAt: hit.at, ageMs: Date.now() - hit.at }; +} + +function set(key, result, ttlMs) { + if (store.size >= DEFAULT_MAX) { + // 删最旧的一条 + const oldest = store.keys().next().value; + if (oldest !== undefined) store.delete(oldest); + } + store.set(key, { at: Date.now(), result, ttlMs: ttlMs || DEFAULT_TTL_MS }); +} + +/** + * 通用包装:命中缓存直接返回,否则执行 fn 并写回。 + * @param {string} key 缓存键 + * @param {Function} fn 实际取数逻辑 + * @param {object} opt { ttlMs, force } + */ +async function wrap(key, fn, opt = {}) { + if (!opt.force) { + const hit = get(key); + if (hit) return { ...hit.result, cache: { hit: true, ageMs: hit.ageMs, cachedAt: hit.cachedAt } }; + } + const result = await fn(); + set(key, result, opt.ttlMs); + return { ...result, cache: { hit: false } }; +} + +/** + * 裸缓存:命中直接返回原对象,**不注入 cache 字段**。 + * + * 用于数据源层(lib/sources.js)。这些函数的返回值会被原样塞进 AI 上下文, + * 多一个 cache 字段既污染上下文又白白吃 token,所以这里保持对象干净。 + * 「是否命中」由调用方按需通过 peek() 判断,或者让上层接口自己汇报。 + */ +async function memo(key, fn, ttlMs) { + const hit = get(key); + if (hit) return hit.result; + const result = await fn(); + set(key, result, ttlMs); + return result; +} + +/** 只看不取:用于判断某条底层数据是否已有缓存(不触发网络请求) */ +function peek(key) { + return get(key); +} + +function clear() { + store.clear(); +} + +function stats() { + return { entries: store.size, max: DEFAULT_MAX, ttlMs: DEFAULT_TTL_MS }; +} + +module.exports = { fingerprint, get, set, wrap, memo, peek, clear, stats, DEFAULT_TTL_MS }; diff --git a/lib/data.js b/lib/data.js new file mode 100644 index 0000000..3985bc4 --- /dev/null +++ b/lib/data.js @@ -0,0 +1,152 @@ +'use strict'; + +const { EXTRA_SCENIC, CN_CITIES } = require('./places-cn'); +const { NICHE_SPOTS } = require('./places-niche'); + +// ------------------------------------------------------- WMO 天气现象编码表 +// code -> { label, icon, severity } severity: 0晴好 1一般 2小雨/雾 3中雨以上 4雪 5雷暴 +const WMO = { + 0: { label: '晴', icon: '☀️', severity: 0 }, + 1: { label: '晴间多云', icon: '🌤️', severity: 0 }, + 2: { label: '多云', icon: '⛅', severity: 1 }, + 3: { label: '阴', icon: '☁️', severity: 1 }, + 45: { label: '有雾', icon: '🌫️', severity: 2 }, + 48: { label: '冻雾', icon: '🌫️', severity: 2 }, + 51: { label: '轻微毛毛雨', icon: '🌦️', severity: 2 }, + 53: { label: '毛毛雨', icon: '🌦️', severity: 2 }, + 55: { label: '较强毛毛雨', icon: '🌧️', severity: 3 }, + 56: { label: '冻毛毛雨', icon: '🌧️', severity: 4 }, + 57: { label: '强冻毛毛雨', icon: '🌧️', severity: 4 }, + 61: { label: '小雨', icon: '🌦️', severity: 2 }, + 63: { label: '中雨', icon: '🌧️', severity: 3 }, + 65: { label: '大雨', icon: '🌧️', severity: 3 }, + 66: { label: '冻雨', icon: '🌧️', severity: 4 }, + 67: { label: '强冻雨', icon: '🌧️', severity: 4 }, + 71: { label: '小雪', icon: '🌨️', severity: 4 }, + 73: { label: '中雪', icon: '🌨️', severity: 4 }, + 75: { label: '大雪', icon: '❄️', severity: 4 }, + 77: { label: '米雪', icon: '🌨️', severity: 4 }, + 80: { label: '阵雨', icon: '🌦️', severity: 2 }, + 81: { label: '较强阵雨', icon: '🌧️', severity: 3 }, + 82: { label: '强阵雨', icon: '⛈️', severity: 4 }, + 85: { label: '阵雪', icon: '🌨️', severity: 4 }, + 86: { label: '强阵雪', icon: '❄️', severity: 4 }, + 95: { label: '雷阵雨', icon: '⛈️', severity: 5 }, + 96: { label: '雷暴伴冰雹', icon: '⛈️', severity: 5 }, + 99: { label: '强雷暴伴冰雹', icon: '⛈️', severity: 5 }, +}; +function wmo(code) { + return WMO[Number(code)] || { label: '未知', icon: '🌡️', severity: 1 }; +} + +// --------------------------------------------- Photon 类别 -> 目的地类型/标签 +// 每一项对应一次 include=osm.. 查询(不需要 q) +const CATEGORIES = [ + { include: 'osm.tourism.attraction', kind: '景点', tags: ['人文历史', '拍照', '观光'] }, + { include: 'osm.tourism.museum', kind: '博物馆', tags: ['人文历史', '亲子', '室内避雨'] }, + { include: 'osm.tourism.gallery', kind: '展馆', tags: ['人文历史', '拍照', '室内避雨'] }, + { include: 'osm.tourism.viewpoint', kind: '观景地', tags: ['自然风光', '拍照', '徒步登山'] }, + { include: 'osm.tourism.theme_park', kind: '主题乐园', tags: ['亲子', '主题乐园'] }, + { include: 'osm.tourism.zoo', kind: '动物园', tags: ['亲子', '自然风光'] }, + { include: 'osm.leisure.park', kind: '公园', tags: ['自然风光', '休闲', '亲子'] }, + { include: 'osm.leisure.garden', kind: '园林', tags: ['人文历史', '休闲', '拍照'] }, + { include: 'osm.leisure.nature_reserve', kind: '自然保护地', tags: ['自然风光', '徒步登山'] }, + { include: 'osm.natural.peak', kind: '山峰', tags: ['自然风光', '徒步登山', '拍照'] }, + { include: 'osm.natural.beach', kind: '海滨', tags: ['亲水海滨', '自然风光', '拍照'] }, + { include: 'osm.historic.castle', kind: '古迹', tags: ['人文历史', '古镇', '拍照'] }, + { include: 'osm.amenity.place_of_worship', kind: '寺观', tags: ['人文历史', '静心', '拍照'] }, + { include: 'osm.boundary.national_park', kind: '风景区', tags: ['自然风光', '徒步登山', '拍照'] }, +]; + +const INTERESTS = [ + '自然风光', '人文历史', '亲子', '拍照', '美食', + '徒步登山', '室内避雨', '亲水海滨', '古镇', '主题乐园', '休闲', '小众秘境', +]; + +// 明显不是「目的地」的设施名,检索结果里剔除 +const JUNK_NAME = /(停车场|厕所|卫生间|售票|游客中心|管理处|管理中心|出入口|入口|出口|公交站|地铁站|加油站|派出所|医院|学校|办公|宿舍|仓库|变电站|公厕|ATM|自动售货|候车|服务区|收费站|bridge|Bridge)/; + +// OSM 里常见的无辨识度地名(土堆、观景台、未命名山峰等),不适合作为推荐目的地 +const POOR_NAME = /^(土堆|土丘|山丘|小土包|无名|未命名|无名称|观景台|瞭望台|望台|平台|山顶|山峰|山头|碑|石碑|界碑|标记|标记点|点|路口|桥|桥头|河|沟|渠|村|庄|地|块|遗址|旧址|某处|unknown|unnamed|viewpoint|peak|summit|mountain)$/i; + +// 「名字像景点、实际不是独立出行目的地」的点位。 +// OSM 里这类条目辨识度很高(有名字、有范围),会把真正值得去的地方挤下去, +// 例如燕京八景中的几处只是小公园或一块碑,外国使馆旧址多为办公建筑。 +// 仅用于实时检索来源(OSM / 高德),不影响内置景区与内置小众地点库。 +const LOW_VALUE_NAME = /(金台夕照|蓟门烟树|卢沟晓月|太液秋风|琼岛春阴|玉泉趵突|西山晴雪|居庸叠翠|使馆旧址|使馆$|墓和祠|纪念碑$|纪念塔$|界桩$|上马石$|下马石$)/; + +// ------------------------------------------ 兜底数据:国内知名目的地(经纬度) +// 用途:① Photon 不可用时保证仍有结果;② 始终补入若干知名目的地,提升清单可读性。 +// 字段:名称, 纬度, 经度, 类型, 标签 +const CURATED_BASE = [ + ['故宫博物院', 39.9163, 116.3972, '人文历史', '人文历史,拍照,室内避雨'], + ['天坛公园', 39.8822, 116.4066, '公园', '人文历史,拍照,休闲'], + ['颐和园', 39.9999, 116.2755, '园林', '人文历史,拍照,休闲'], + ['八达岭长城', 40.3564, 116.0169, '古迹', '人文历史,徒步登山,拍照'], + ['慕田峪长城', 40.4319, 116.5695, '古迹', '人文历史,徒步登山,拍照'], + ['香山公园', 39.9960, 116.1896, '自然风光', '自然风光,徒步登山,拍照'], + ['北京环球度假区', 39.8508, 116.6767, '主题乐园', '亲子,主题乐园'], + ['798艺术区', 39.9828, 116.4905, '展馆', '人文历史,拍照,室内避雨'], + ['中国国家博物馆', 39.9037, 116.4018, '博物馆', '人文历史,亲子,室内避雨'], + ['古北水镇', 40.6883, 117.2764, '古镇', '古镇,人文历史,拍照'], + ['十渡风景区', 39.6472, 115.5883, '风景区', '自然风光,亲水海滨,徒步登山'], + ['五大道', 39.1167, 117.2000, '人文历史', '人文历史,拍照,休闲'], + ['盘山', 40.0889, 117.2500, '山峰', '自然风光,徒步登山,拍照'], + ['承德避暑山庄', 40.9975, 117.9364, '园林', '人文历史,休闲,拍照'], + ['北戴河海滨', 39.8167, 119.4833, '海滨', '亲水海滨,自然风光,休闲'], + ['山海关', 40.0089, 119.7536, '古迹', '人文历史,拍照'], + ['白洋淀', 38.8500, 115.9833, '风景区', '自然风光,亲水海滨,休闲'], + ['野三坡', 39.6833, 115.4333, '风景区', '自然风光,徒步登山'], + ['正定古城', 38.1458, 114.5750, '古镇', '古镇,人文历史,拍照'], + ['平遥古城', 37.2011, 112.1761, '古镇', '古镇,人文历史,拍照'], + ['云冈石窟', 40.1106, 113.1222, '古迹', '人文历史,拍照'], + ['五台山', 38.9833, 113.5833, '山峰', '自然风光,徒步登山,人文历史'], + ['泰山', 36.2558, 117.1008, '山峰', '自然风光,徒步登山,拍照'], + ['趵突泉', 36.6608, 117.0142, '公园', '自然风光,休闲,拍照'], + ['曲阜三孔', 35.5917, 116.9886, '古迹', '人文历史,拍照'], + ['青岛栈桥', 36.0597, 120.3128, '海滨', '亲水海滨,拍照,休闲'], + ['崂山', 36.1500, 120.6333, '山峰', '自然风光,徒步登山,亲水海滨'], + ['龙门石窟', 34.5589, 112.4714, '古迹', '人文历史,拍照'], + ['少林寺', 34.5086, 113.0031, '寺观', '人文历史,拍照'], + ['清明上河园', 34.8000, 114.3500, '主题乐园', '人文历史,亲子,主题乐园'], + ['秦始皇兵马俑', 34.3853, 109.2785, '古迹', '人文历史,亲子,室内避雨'], + ['华山', 34.4833, 110.0833, '山峰', '自然风光,徒步登山,拍照'], + ['中山陵', 32.0617, 118.8486, '古迹', '人文历史,休闲'], + ['拙政园', 31.4900, 120.6294, '园林', '人文历史,拍照,休闲'], + ['周庄古镇', 31.1236, 120.8500, '古镇', '古镇,人文历史,拍照'], + ['鼋头渚', 31.5333, 120.2333, '风景区', '自然风光,亲水海滨,拍照'], + ['上海外滩', 31.2400, 121.4900, '人文历史', '人文历史,拍照,休闲'], + ['上海迪士尼', 31.1434, 121.6570, '主题乐园', '亲子,主题乐园'], + ['杭州西湖', 30.2424, 120.1481, '风景区', '自然风光,拍照,休闲'], + ['乌镇', 30.7461, 120.4889, '古镇', '古镇,人文历史,拍照'], + ['千岛湖', 29.6000, 118.9833, '风景区', '自然风光,亲水海滨,休闲'], + ['普陀山', 30.0000, 122.3833, '寺观', '人文历史,亲水海滨,自然风光'], + ['黄山', 30.1333, 118.1667, '山峰', '自然风光,徒步登山,拍照'], + ['宏村', 29.9000, 117.9833, '古镇', '古镇,人文历史,拍照'], + ['鼓浪屿', 24.4489, 118.0672, '海滨', '亲水海滨,人文历史,拍照'], + ['武夷山', 27.7167, 117.9833, '山峰', '自然风光,徒步登山,亲水海滨'], + ['黄鹤楼', 30.5450, 114.3030, '古迹', '人文历史,拍照'], + ['武当山', 32.4000, 111.0000, '山峰', '自然风光,徒步登山,人文历史'], + ['张家界国家森林公园', 29.3167, 110.4833, '风景区', '自然风光,徒步登山,拍照'], + ['九寨沟', 33.2500, 103.9167, '风景区', '自然风光,拍照,亲水海滨'], + ['都江堰', 31.0000, 103.6167, '古迹', '人文历史,自然风光'], + ['青城山', 30.9000, 103.5667, '山峰', '自然风光,徒步登山,人文历史'], + ['洪崖洞', 29.5630, 106.5830, '人文历史', '人文历史,拍照,美食'], + ['白云山', 23.1833, 113.3000, '山峰', '自然风光,徒步登山,休闲'], + ['丹霞山', 25.0333, 113.7500, '风景区', '自然风光,徒步登山,拍照'], + ['阳朔漓江', 24.7833, 110.4833, '风景区', '自然风光,亲水海滨,拍照'], + ['大理古城', 25.6911, 100.1600, '古镇', '古镇,人文历史,休闲'], + ['洱海', 25.7500, 100.1833, '风景区', '自然风光,亲水海滨,拍照'], + ['丽江古城', 26.8667, 100.2333, '古镇', '古镇,人文历史,拍照'], + ['黄果树瀑布', 25.9833, 105.6667, '风景区', '自然风光,亲水海滨,拍照'], + ['亚龙湾', 18.2167, 109.6500, '海滨', '亲水海滨,自然风光,休闲'], + ['沈阳故宫', 41.7997, 123.4539, '古迹', '人文历史,拍照'], + ['长白山天池', 42.0000, 128.0667, '山峰', '自然风光,徒步登山,拍照'], + ['青海湖', 36.8833, 100.1833, '风景区', '自然风光,亲水海滨,拍照'], + ['莫高窟', 40.0417, 94.8000, '古迹', '人文历史,室内避雨,拍照'], + ['布达拉宫', 29.6578, 91.1175, '古迹', '人文历史,拍照'], +]; + +const CURATED = [...CURATED_BASE, ...EXTRA_SCENIC]; + +module.exports = { WMO, wmo, CATEGORIES, INTERESTS, JUNK_NAME, POOR_NAME, LOW_VALUE_NAME, CURATED, CN_CITIES, NICHE_SPOTS }; diff --git a/lib/hotels-rollinggo.js b/lib/hotels-rollinggo.js new file mode 100644 index 0000000..6f888d3 --- /dev/null +++ b/lib/hotels-rollinggo.js @@ -0,0 +1,392 @@ +/** + * RollingGo 酒店查询适配器(只读) + * + * 说明:https://github.com/RollingGo-AI/rollinggo-hotel-skill-CN 提供的是 + * 「Claude Skill + 本机 CLI」而不是 MCP Server。底层 npm 包 @rollinggo/hotel, + * 可执行文件是 rgh,认证走 OAuth 2.1(rgh login),没有 API Key。 + * + * 因此这里通过子进程调用本机 rgh,只使用两个只读子命令: + * status —— 判断是否已登录 + * search-hotels —— 按地点/日期/价位搜索,返回最低参考价 + * hotel-detail —— 查房型与退改政策(按需) + * + * ⚠️ 安全红线:ALLOWED_SUBCOMMANDS 之外的一律拒绝。 + * price-confirm / book / pre-cancel / confirm-cancel 会触发真实资金操作, + * 本适配器永不调用,也不对外暴露。 + */ +const fs = require('fs'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const ALLOWED_SUBCOMMANDS = new Set(['status', 'search-hotels', 'hotel-detail']); + +// 参数值里只有「地点名 / 查询语」是自由文本,其余由服务端生成。 +// Windows 下 .cmd 需要 shell 才能 spawn,所以对自由文本做严格白名单,杜绝命令注入。 +const SAFE_TEXT = /^[\u4e00-\u9fa5A-Za-z0-9\u3000-\u303f\s·\-—()()]{1,48}$/; + +function safeText(v, fallback = '') { + const s = String(v || '').trim(); + if (!s) return fallback; + return SAFE_TEXT.test(s) ? s : fallback; +} + +function argSafe(v) { + // 兜底:任何值都不允许出现 shell 元字符 + return !/[&|;`$<>"'\r\n]/.test(String(v)); +} + +/** + * WSL 会把 Windows 的 PATH 混进 Linux 环境,`which rgh` 可能命中 /mnt/c/... 的 Windows 版, + * 那是 shell 脚本、无法在 Linux 正常运行,必须排除。 + */ +function isForeignMount(p) { + return process.platform !== 'win32' && /^\/mnt\/[a-z]\//i.test(String(p || '')); +} + +/** 找到真正的可执行文件:绝对路径直接用,裸命令名先查 PATH,再查常见全局安装位置 */ +function resolveBin(cfg) { + const raw = String(cfg.RHG_BIN || 'rgh').trim(); + if (!raw) return ''; + if (/[\\/]/.test(raw)) return (fs.existsSync(raw) && !isForeignMount(raw)) ? raw : ''; + + try { + const probe = process.platform === 'win32' ? 'where' : 'which'; + const r = spawnSync(probe, [raw], { encoding: 'utf8', windowsHide: true, timeout: 5000 }); + if (r.status === 0 && r.stdout) { + const list = String(r.stdout).split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + // Windows 下 `where rgh` 会先给一个无扩展名的 sh 脚本,那个 spawn 不起来; + // 按 .exe > .cmd > .bat > 其他 排序后再取 + const rank = (p) => (/\.exe$/i.test(p) ? 0 : (/\.cmd$/i.test(p) ? 1 : (/\.bat$/i.test(p) ? 2 : 3))); + list.sort((a, b) => rank(a) - rank(b)); + const hit = list.find((p) => fs.existsSync(p) && !isForeignMount(p)); + if (hit) return hit; + } + } catch { /* 继续找 */ } + + // 服务进程的 PATH 未必包含 npm 全局目录(尤其桌面端启动的开发服务), + // 这里补一遍常见位置,否则用户装了 rgh 也永远检测不到。 + const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : ['']; + const dirs = []; + if (process.platform === 'win32') { + if (process.env.APPDATA) dirs.push(path.join(process.env.APPDATA, 'npm')); + if (process.env.ProgramFiles) dirs.push(path.join(process.env.ProgramFiles, 'nodejs')); + if (process.env.LOCALAPPDATA) dirs.push(path.join(process.env.LOCALAPPDATA, 'Programs', 'nodejs')); + } else { + dirs.push('/usr/local/bin', '/opt/homebrew/bin', path.join(require('os').homedir(), '.local', 'bin')); + } + for (const dir of dirs) { + for (const ext of exts) { + const p = path.join(dir, raw + ext); + try { if (fs.existsSync(p) && !isForeignMount(p)) return p; } catch { /* 忽略 */ } + } + } + return ''; +} + +/** + * 从 npm 的 bin 脚本位置推断真实 JS 入口。 + * Windows 全局安装拿到的 rgh.cmd 会经 cmd.exe 转发,中文参数有编码风险, + * 直接 `node /dist/index.js` 既避开编码问题,也顺带杜绝了命令注入。 + */ +function jsEntryNear(binPath) { + try { + const dir = path.dirname(binPath); + const candidates = [ + path.join(dir, 'node_modules', '@rollinggo', 'hotel', 'dist', 'index.js'), + path.resolve(dir, '..', 'lib', 'node_modules', '@rollinggo', 'hotel', 'dist', 'index.js'), + // 局部安装时 bin 在 node_modules/.bin 下,包体在上一层的 @rollinggo 里 + path.resolve(dir, '..', '@rollinggo', 'hotel', 'dist', 'index.js'), + ]; + for (const p of candidates) if (fs.existsSync(p)) return p; + } catch { /* 忽略 */ } + return ''; +} + +function planInvocation(cfg) { + const bin = resolveBin(cfg); + if (!bin) return null; + if (/\.js$/i.test(bin)) return { cmd: process.execPath, argv: [bin], bin }; + const near = jsEntryNear(bin); + if (near) return { cmd: process.execPath, argv: [near], bin }; + if (process.platform !== 'win32' || /\.exe$/i.test(bin)) return { cmd: bin, argv: [], bin }; + // .cmd 且找不到 JS 入口:只能借 shell(参数已做严格白名单过滤) + return { cmd: bin, argv: [], bin, shell: true }; +} + +function runRaw(cfg, args, timeoutMs = 20000) { + return new Promise((resolve) => { + const inv = planInvocation(cfg); + if (!inv) return resolve({ ok: false, code: -1, out: '', err: 'ENOENT', missing: true }); + let child; + try { + child = spawn(inv.cmd, [...inv.argv, ...args], { + shell: Boolean(inv.shell), + windowsHide: true, + timeout: timeoutMs, + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + // rgh 用 dotenvx 加载配置,会往 stdout 打启动日志;能关就关,关不掉靠 extractJSON 兜 + DOTENV_CONFIG_QUIET: 'true', + DOTENVX_SILENT: '1', + }, + }); + } catch (err) { + return resolve({ ok: false, code: -1, out: '', err: err.message }); + } + let out = ''; + let err = ''; + const cap = 2 * 1024 * 1024; + child.stdout.on('data', (d) => { if (out.length < cap) out += d; }); + child.stderr.on('data', (d) => { if (err.length < cap) err += d; }); + child.on('error', (e) => resolve({ ok: false, code: -1, out, err: e.message })); + child.on('close', (code) => { + const clean = (s) => String(s || '').replace(/\u001b\[[0-9;]*m/g, '').trim(); + resolve({ ok: code === 0, code, out: clean(out), err: clean(err) }); + }); + }); +} + +/** 从 start 开始按括号配对切出完整的一段(跳过字符串内的括号) */ +function sliceBalanced(s, start) { + const open = s[start]; + const close = open === '{' ? '}' : ']'; + let depth = 0; + let inStr = false; + let esc = false; + for (let i = start; i < s.length; i += 1) { + const ch = s[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === '\\') esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') { inStr = true; continue; } + if (ch === open) depth += 1; + else if (ch === close) { + depth -= 1; + if (depth === 0) return s.slice(start, i + 1); + } + } + return ''; +} + +/** + * 从 CLI 输出里捞出 JSON 主体。 + * + * 实测坑:rgh 启动时 dotenvx 会往 stdout 打一行 + * ◇ injected env (1) from ... // tip: ... { path: '/custom/path/.env' } + * 这行本身就带花括号,所以「第一个 { 到最后一个 }」会把日志和 JSON 粘成一坨。 + * 这里先从业务字段的位置回推所属对象,再按括号配对精确切分。 + */ +function extractJSON(text) { + const s = String(text || ''); + if (!s.trim()) return null; + try { return JSON.parse(s); } catch (_) { /* 有前缀日志,继续找 */ } + + const hints = ['"success"', '"hotelInformationList"', '"roomRatePlans"', '"orderInfoList"', '"hotelId"']; + const starts = []; + for (const h of hints) { + const idx = s.indexOf(h); + if (idx < 0) continue; + const b = s.lastIndexOf('{', idx); + if (b >= 0) starts.push(b); + } + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (ch === '{' || ch === '[') starts.push(i); + } + + const seen = new Set(); + for (const st of starts) { + if (seen.has(st)) continue; + seen.add(st); + const frag = sliceBalanced(s, st); + if (!frag) continue; + try { + const obj = JSON.parse(frag); + if (obj && typeof obj === 'object') return obj; + } catch (_) { /* 试下一个起点 */ } + } + return null; +} + +let statusCache = { at: 0, ready: false, reason: '未检测' }; +const STATUS_TTL = 60000; + +/** 探测:rgh 是否存在、是否已登录。结果缓存 60s,避免每次查住宿都起一个进程 */ +async function availability(cfg, force = false) { + if (!force && Date.now() - statusCache.at < STATUS_TTL) return statusCache; + const r = await runRaw(cfg, ['status'], 12000); + if (r.missing) { + statusCache = { at: Date.now(), installed: false, ready: false, reason: '未安装 rgh(npm i -g @rollinggo/hotel)' }; + return statusCache; + } + const text = `${r.out} ${r.err}`.trim(); + const loggedOut = /未登录|请先.{0,8}login|not logged in|unauthenticated/i.test(text); + statusCache = { + at: Date.now(), + installed: true, + ready: r.ok && !loggedOut, + reason: loggedOut ? 'rgh 已安装但未登录(执行 rgh login)' : (r.ok ? '' : (text.slice(0, 120) || '状态未知')), + }; + return statusCache; +} + +const PLACE_TYPES = new Set(['城市', '机场', '景点', '火车站', '地铁站', '酒店', '区/县', '详细地址']); + +/** + * 按名字推断地点类型。 + * 「柳州市」是城市不是景点——用错类型会直接查空,然后被误判成「这家数据源没数据」。 + */ +function guessPlaceType(name) { + const s = String(name || '').trim(); + if (/(市|地区|自治州|盟)$/.test(s)) return '城市'; + if (/(区|县|旗|镇|乡|街道)$/.test(s)) return '区/县'; + return '景点'; +} + +/** + * 单次搜索(placeType 由调用方决定)。 + * @param {object} q { name, checkIn, nights, maxPricePerNight, distanceMeters, size, adults, lat, lon } + */ +async function searchOnce(q, cfg, placeType) { + const place = safeText(q.name); + if (!place || !argSafe(place)) { + return { hotels: [], error: '地点名包含不可用字符,跳过 RollingGo 查询' }; + } + const size = Math.min(20, Math.max(1, Number(q.size) || 8)); + const nights = Math.min(30, Math.max(1, Number(q.nights) || 1)); + const adults = Math.min(10, Math.max(1, Number(q.adults) || 2)); + const dist = Math.min(20000, Math.max(500, Number(q.distanceMeters) || 10000)); + + const args = [ + 'search-hotels', + '--origin-query', safeText(`${place}周边适合出行的酒店`, `${place} 酒店`), + '--place', place, + '--place-type', placeType, + '--size', String(size), + '--stay-nights', String(nights), + '--adult-count', String(adults), + '--distance-in-meter', String(dist), + ]; + if (/^\d{4}-\d{2}-\d{2}$/.test(String(q.checkIn || ''))) { + args.push('--check-in-date', String(q.checkIn)); + } + const maxPrice = Number(q.maxPricePerNight); + if (Number.isFinite(maxPrice) && maxPrice > 0) { + args.push('--max-price-per-night', String(Math.round(maxPrice))); + } + const sub = args[0]; + if (!ALLOWED_SUBCOMMANDS.has(sub)) return { hotels: [], error: '子命令不在白名单内' }; + if (!args.every((a) => argSafe(a))) return { hotels: [], error: '参数包含不可用字符' }; + + const r = await runRaw(cfg, args, Number(cfg.ROLLINGGO_TIMEOUT_MS) || 25000); + const data = extractJSON(r.out) || extractJSON(r.err); + if (!data) { + const why = (r.out || r.err || '').slice(0, 160) || `exit ${r.code}`; + return { hotels: [], error: `RollingGo 返回无法解析:${why}` }; + } + const list = data.hotelInformationList || data.hotels || []; + if (!Array.isArray(list) || !list.length) { + return { hotels: [], error: '', empty: true, message: String(data.message || '').slice(0, 80) }; + } + + const anchorLat = Number(q.lat); + const anchorLon = Number(q.lon); + const hasAnchor = Number.isFinite(anchorLat) && Number.isFinite(anchorLon); + // 城市类查询时 --distance-in-meter 不生效,服务端可能返回全城甚至外地的酒店, + // 所以这里按坐标自己算距离并过滤,否则会出现「去柳州却推荐重庆酒店」。 + const radiusKm = Math.min(50, Math.max(1, Number(q.distanceMeters) / 1000 || 10)); + const limitKm = placeType === '城市' ? Math.max(radiusKm, 20) : radiusKm; + + const hotels = []; + let droppedFar = 0; + for (const h of list) { + const lat = Number(h.latitude); + const lon = Number(h.longitude); + const price = h.price && Number(h.price.lowestPrice); + + // 距离优先自己算:数据源给的 distanceInMeters 实测是错的 + // (柳州城中区的酒店被标成 64527 米,实际约 2.8km) + let distanceKm = null; + if (hasAnchor && Number.isFinite(lat) && Number.isFinite(lon)) { + const R = 6371; + const dLat = ((lat - anchorLat) * Math.PI) / 180; + const dLon = ((lon - anchorLon) * Math.PI) / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos((anchorLat * Math.PI) / 180) * Math.cos((lat * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; + distanceKm = 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } else if (Number.isFinite(Number(h.distanceInMeters))) { + distanceKm = Number(h.distanceInMeters) / 1000; + } + if (distanceKm != null && distanceKm > limitKm) { droppedFar += 1; continue; } + + const star = Number(h.starRating); + hotels.push({ + name: String(h.name || '').slice(0, 60), + address: String(h.address || '').slice(0, 80), + lat: Number.isFinite(lat) ? lat : null, + lon: Number.isFinite(lon) ? lon : null, + distanceKm: distanceKm != null ? Math.round(distanceKm * 10) / 10 : null, + rating: Number.isFinite(star) && star > 0 ? star : null, + cost: Number.isFinite(price) && price > 0 ? Math.round(price) : null, + currency: (h.price && h.price.currency) || 'CNY', + type: '住宿', + tags: Array.isArray(h.tags) ? h.tags.slice(0, 4) : [], + bookingUrl: String(h.bookingUrl || ''), + source: 'RollingGo', + }); + } + hotels.sort((a, b) => (a.distanceKm ?? 999) - (b.distanceKm ?? 999)); + return { + hotels: hotels.filter((h) => h.name), + droppedFar, + limitKm: Math.round(limitKm), + }; +} + +/** + * 搜索目的地周边酒店:先按名字推断地点类型,查空就换一种类型再试一次。 + * 实测:「柳州市」用「景点」查返回 0 家,「城市」查才有结果; + * 反过来把「坡峰岭」当城市查也会落空。两次都空才认定没数据。 + */ +async function searchHotels(q, cfg) { + const first = PLACE_TYPES.has(q.placeType) ? q.placeType : guessPlaceType(q.name); + const r1 = await searchOnce(q, cfg, first); + if (r1.hotels.length || r1.error) return { ...r1, placeType: first }; + + const second = first === '城市' ? '景点' : '城市'; + const r2 = await searchOnce(q, cfg, second); + if (r2.hotels.length) return { ...r2, placeType: second, retried: true }; + return { ...r1, placeType: first, retried: true }; +} + +/** 查单个酒店的房型与退改政策(可选增强,失败不影响主流程) */ +async function hotelDetail(q, cfg) { + const name = safeText(q.name); + if (!name) return { rooms: [], error: '缺少酒店名' }; + const args = ['hotel-detail', '--name', name, '--room-count', '1', '--adult-count', '2']; + if (/^\d{4}-\d{2}-\d{2}$/.test(String(q.checkIn || ''))) args.push('--check-in-date', String(q.checkIn)); + if (/^\d{4}-\d{2}-\d{2}$/.test(String(q.checkOut || ''))) args.push('--check-out-date', String(q.checkOut)); + if (!args.every((a) => argSafe(a))) return { rooms: [], error: '参数包含不可用字符' }; + + const r = await runRaw(cfg, args, Number(cfg.ROLLINGGO_TIMEOUT_MS) || 25000); + const data = extractJSON(r.out) || extractJSON(r.err); + if (!data) return { rooms: [], error: '详情返回无法解析' }; + const plans = data.roomRatePlans || []; + const rooms = plans.slice(0, 5).map((p) => ({ + roomName: p.roomNameCn || p.roomName || '', + bedType: p.bedTypeDescription || '', + totalPrice: Number(p.totalPrice) || null, + avgPrice: Number(p.totalSalesRate) || null, + freeCancel: Array.isArray(p.cancellationPolicies) + && p.cancellationPolicies.some((c) => Number(c.amount) === 0), + cancelText: (Array.isArray(p.cancellationPolicies) && p.cancellationPolicies[0]?.description) || '', + })); + return { rooms }; +} + +module.exports = { availability, searchHotels, hotelDetail, extractJSON, ALLOWED_SUBCOMMANDS }; diff --git a/lib/places-cn.js b/lib/places-cn.js new file mode 100644 index 0000000..7ace8b1 --- /dev/null +++ b/lib/places-cn.js @@ -0,0 +1,219 @@ +'use strict'; + +/** + * 国内兜底地名库 + * + * 用途:Photon / 高德 POI 服务不可用或结果不足时,仍能提供一份可用的周边清单。 + * 坐标为近似值(WGS-84),用于天气与路网估算足够;结果里会明确标注数据来源, + * 不会伪装成实时检索结果。用户配置了高德 Key 或 Photon 可用时,以此为基础做补充。 + */ + +// 格式:[名称, 纬度, 经度, 类型, 标签(逗号分隔)] +const EXTRA_SCENIC = [ + ['金山岭长城', 40.6790, 117.2290, '古迹', '人文历史,徒步登山,拍照'], + ['司马台长城', 40.6370, 117.3350, '古迹', '人文历史,徒步登山,拍照'], + ['龙庆峡', 40.5330, 116.0080, '风景区', '自然风光,亲水海滨,休闲'], + ['百里画廊', 40.5670, 116.2830, '风景区', '自然风光,拍照,休闲'], + ['百花山', 39.8330, 115.6000, '山峰', '自然风光,徒步登山,拍照'], + ['灵山', 39.9830, 115.4330, '山峰', '自然风光,徒步登山'], + ['雾灵山', 40.6000, 117.4830, '山峰', '自然风光,徒步登山,拍照'], + ['京东大峡谷', 40.1830, 117.1670, '风景区', '自然风光,徒步登山,亲水海滨'], + ['石林峡', 40.1830, 117.2000, '风景区', '自然风光,徒步登山,拍照'], + ['坡峰岭', 39.7170, 115.7330, '自然风光', '自然风光,徒步登山,拍照'], + ['乐谷银滩', 39.6330, 115.5670, '风景区', '自然风光,亲水海滨'], + ['清东陵', 40.1830, 117.6330, '古迹', '人文历史,拍照'], + ['黄崖关长城', 40.2330, 117.4000, '古迹', '人文历史,徒步登山,拍照'], + ['天津之眼', 39.1536, 117.1742, '人文历史', '人文历史,拍照,休闲'], + ['意式风情区', 39.1431, 117.2008, '人文历史', '人文历史,拍照,休闲'], + ['瓷房子', 39.1250, 117.2000, '展馆', '人文历史,拍照,室内避雨'], + ['西柏坡纪念地', 38.2833, 113.9500, '古迹', '人文历史,休闲'], + ['嶂石岩', 37.6000, 114.2330, '风景区', '自然风光,徒步登山,拍照'], + ['苍岩山', 37.7830, 114.1330, '山峰', '自然风光,徒步登山,人文历史'], + ['天生桥瀑布', 38.7500, 113.8330, '风景区', '自然风光,亲水海滨,拍照'], + ['壶口瀑布', 36.1500, 110.4330, '风景区', '自然风光,亲水海滨,拍照'], + ['乔家大院', 37.4000, 112.4330, '古镇', '古镇,人文历史,拍照'], + ['王家大院', 36.8500, 111.8830, '古镇', '古镇,人文历史,拍照'], + ['悬空寺', 39.7330, 113.7000, '寺观', '人文历史,拍照'], + ['应县木塔', 39.5670, 113.1830, '古迹', '人文历史,拍照'], + ['雁门关', 39.2000, 112.8500, '古迹', '人文历史,徒步登山'], + ['绵山', 36.8830, 111.9830, '山峰', '自然风光,徒步登山,人文历史'], + ['云台山', 35.4330, 113.4000, '风景区', '自然风光,徒步登山,亲水海滨'], + ['老君山', 33.7500, 111.6330, '山峰', '自然风光,徒步登山,拍照'], + ['白云山洛阳', 33.6500, 111.8500, '山峰', '自然风光,徒步登山'], + ['殷墟', 36.1330, 114.3170, '古迹', '人文历史,室内避雨'], + ['清明上河园', 34.8000, 114.3500, '主题乐园', '人文历史,亲子,主题乐园'], + ['夫子庙秦淮河', 32.0217, 118.7881, '人文历史', '人文历史,美食,拍照'], + ['牛首山', 31.9000, 118.7500, '山峰', '自然风光,人文历史,休闲'], + ['栖霞山', 32.1500, 118.9670, '山峰', '自然风光,徒步登山,拍照'], + ['灵山大佛', 31.4330, 120.1000, '寺观', '人文历史,休闲,拍照'], + ['拈花湾', 31.4170, 120.0830, '古镇', '古镇,人文历史,拍照'], + ['同里古镇', 31.1600, 120.7170, '古镇', '古镇,人文历史,拍照'], + ['西塘古镇', 30.9400, 120.8900, '古镇', '古镇,人文历史,拍照'], + ['南浔古镇', 30.8830, 120.4170, '古镇', '古镇,人文历史,拍照'], + ['天目湖', 31.3000, 119.4170, '风景区', '自然风光,亲水海滨,休闲'], + ['莫干山', 30.6000, 119.9000, '山峰', '自然风光,徒步登山,休闲'], + ['普陀山', 30.0000, 122.3833, '寺观', '人文历史,亲水海滨,拍照'], + ['雁荡山', 28.3667, 121.0500, '山峰', '自然风光,徒步登山,拍照'], + ['楠溪江', 28.3000, 120.6500, '风景区', '自然风光,亲水海滨,休闲'], + ['江郎山', 28.5330, 118.5670, '山峰', '自然风光,徒步登山,拍照'], + ['根宫佛国', 29.1330, 118.4000, '展馆', '人文历史,拍照,室内避雨'], + ['西递', 29.8830, 117.9830, '古镇', '古镇,人文历史,拍照'], + ['呈坎', 29.9170, 118.2670, '古镇', '古镇,人文历史,拍照'], + ['三清山', 28.9000, 118.0670, '山峰', '自然风光,徒步登山,拍照'], + ['龙虎山', 28.1000, 116.9830, '山峰', '自然风光,徒步登山,人文历史'], + ['庐山', 29.5830, 115.9830, '山峰', '自然风光,徒步登山,休闲'], + ['婺源篁岭', 29.3830, 117.9330, '古镇', '古镇,自然风光,拍照'], + ['滕王阁', 28.6830, 115.8830, '古迹', '人文历史,拍照'], + ['鼓山', 26.0830, 119.3830, '山峰', '自然风光,徒步登山'], + ['福建土楼', 24.6830, 117.1330, '古镇', '古镇,人文历史,拍照'], + ['清源山', 24.9330, 118.5830, '山峰', '自然风光,徒步登山,人文历史'], + ['泰山日出', 36.2558, 117.1008, '山峰', '自然风光,徒步登山,拍照'], + ['蓬莱阁', 37.8170, 120.7500, '古迹', '人文历史,亲水海滨,拍照'], + ['刘公岛', 37.5000, 122.1830, '海滨', '亲水海滨,人文历史,拍照'], + ['台儿庄古城', 34.5670, 117.7330, '古镇', '古镇,人文历史,拍照'], + ['青州古城', 36.6830, 118.4830, '古镇', '古镇,人文历史,拍照'], + ['黄鹤楼', 30.5450, 114.3030, '古迹', '人文历史,拍照'], + ['东湖绿道', 30.5500, 114.4000, '公园', '自然风光,休闲,拍照'], + ['三峡大坝', 30.8330, 111.0000, '风景区', '自然风光,人文历史'], + ['恩施大峡谷', 30.5670, 109.2670, '风景区', '自然风光,徒步登山,拍照'], + ['神农架', 31.7500, 110.3330, '风景区', '自然风光,徒步登山'], + ['武陵源', 29.3167, 110.4833, '风景区', '自然风光,徒步登山,拍照'], + ['凤凰古城', 27.9500, 109.6000, '古镇', '古镇,人文历史,拍照'], + ['岳麓山', 28.1830, 112.9330, '山峰', '自然风光,徒步登山,人文历史'], + ['橘子洲', 28.1830, 112.9500, '公园', '自然风光,休闲,拍照'], + ['丹霞山', 25.0333, 113.7500, '风景区', '自然风光,徒步登山,拍照'], + ['广州塔', 23.1066, 113.3245, '人文历史', '人文历史,拍照,美食'], + ['沙面', 23.1080, 113.2400, '人文历史', '人文历史,拍照,休闲'], + ['开平碉楼', 22.3667, 112.7000, '古镇', '古镇,人文历史,拍照'], + ['惠州西湖', 23.0830, 114.4000, '风景区', '自然风光,休闲,拍照'], + ['桂林象鼻山', 25.2700, 110.3000, '风景区', '自然风光,拍照,亲水海滨'], + ['龙脊梯田', 25.7500, 110.1330, '自然风光', '自然风光,徒步登山,拍照'], + ['涠洲岛', 21.0330, 109.1170, '海滨', '亲水海滨,自然风光,拍照'], + ['德天瀑布', 22.8500, 106.7170, '风景区', '自然风光,亲水海滨,拍照'], + ['天涯海角', 18.2500, 109.3500, '海滨', '亲水海滨,拍照,休闲'], + ['南山文化旅游区', 18.3000, 109.2000, '寺观', '人文历史,亲水海滨,休闲'], + ['宽窄巷子', 30.6690, 104.0550, '人文历史', '人文历史,美食,拍照'], + ['武侯祠', 30.6470, 104.0480, '古迹', '人文历史,拍照'], + ['杜甫草堂', 30.6630, 103.9930, '园林', '人文历史,拍照,休闲'], + ['大熊猫繁育基地', 30.7330, 104.1500, '动物园', '亲子,自然风光'], + ['峨眉山', 29.5330, 103.3500, '山峰', '自然风光,徒步登山,人文历史'], + ['乐山大佛', 29.5500, 103.7670, '古迹', '人文历史,拍照,亲水海滨'], + ['稻城亚丁', 28.4330, 100.3000, '风景区', '自然风光,徒步登山,拍照'], + ['四姑娘山', 31.1000, 102.9000, '山峰', '自然风光,徒步登山,拍照'], + ['若尔盖花湖', 33.9000, 102.8330, '风景区', '自然风光,拍照,亲水海滨'], + ['黄龙', 32.7500, 103.8330, '风景区', '自然风光,徒步登山,亲水海滨'], + ['磁器口古镇', 29.5800, 106.4500, '古镇', '古镇,美食,人文历史'], + ['大足石刻', 29.7000, 105.7000, '古迹', '人文历史,拍照'], + ['武隆天生三桥', 29.4330, 107.8000, '风景区', '自然风光,徒步登山,拍照'], + ['青岩古镇', 26.4170, 106.6830, '古镇', '古镇,人文历史,美食'], + ['荔波小七孔', 25.3000, 107.7000, '风景区', '自然风光,亲水海滨,拍照'], + ['镇远古镇', 27.0500, 108.4330, '古镇', '古镇,人文历史,拍照'], + ['西江千户苗寨', 26.4830, 108.1830, '古镇', '古镇,人文历史,拍照'], + ['石林风景区', 24.8167, 103.3333, '风景区', '自然风光,拍照,徒步登山'], + ['玉龙雪山', 27.1000, 100.1830, '山峰', '自然风光,徒步登山,拍照'], + ['束河古镇', 26.9000, 100.2170, '古镇', '古镇,人文历史,休闲'], + ['泸沽湖', 27.7000, 100.7830, '风景区', '自然风光,亲水海滨,拍照'], + ['西双版纳植物园', 21.9170, 101.2670, '园林', '自然风光,亲子,拍照'], + ['崇圣寺三塔', 25.7000, 100.1500, '寺观', '人文历史,拍照'], + ['兵马俑', 34.3853, 109.2785, '古迹', '人文历史,亲子,室内避雨'], + ['华清宫', 34.3670, 109.2170, '古迹', '人文历史,拍照'], + ['大唐芙蓉园', 34.2170, 108.9670, '园林', '人文历史,拍照,亲子'], + ['法门寺', 34.4330, 107.9000, '寺观', '人文历史,室内避雨'], + ['太白山', 34.0830, 107.7670, '山峰', '自然风光,徒步登山'], + ['麦积山石窟', 34.3500, 106.0000, '古迹', '人文历史,拍照'], + ['张掖七彩丹霞', 38.9000, 100.1330, '风景区', '自然风光,拍照,徒步登山'], + ['嘉峪关关城', 39.8000, 98.2200, '古迹', '人文历史,拍照'], + ['鸣沙山月牙泉', 40.0830, 94.6670, '风景区', '自然风光,拍照,亲子'], + ['塔尔寺', 36.4830, 101.5670, '寺观', '人文历史,拍照'], + ['茶卡盐湖', 36.7500, 99.0830, '风景区', '自然风光,拍照,亲水海滨'], + ['沙坡头', 37.4500, 105.0000, '风景区', '自然风光,亲子,亲水海滨'], + ['镇北堡西部影城', 38.5000, 106.0830, '主题乐园', '人文历史,拍照,亲子'], + ['伊犁那拉提', 43.3000, 84.0000, '风景区', '自然风光,拍照,徒步登山'], + ['喀纳斯', 48.7330, 87.0330, '风景区', '自然风光,拍照,亲水海滨'], + ['赛里木湖', 44.6000, 81.2000, '风景区', '自然风光,拍照,亲水海滨'], + ['沈阳故宫', 41.7997, 123.4539, '古迹', '人文历史,拍照'], + ['大连星海广场', 38.8767, 121.5733, '海滨', '亲水海滨,休闲,拍照'], + ['长白山天池', 42.0000, 128.0667, '山峰', '自然风光,徒步登山,拍照'], + ['伪满皇宫', 43.8830, 125.3500, '古迹', '人文历史,室内避雨'], + ['中央大街', 45.7750, 126.6167, '人文历史', '人文历史,美食,拍照'], + ['北极村', 53.4670, 122.3500, '古镇', '自然风光,拍照,休闲'], +]; + +// 主要城市:[名称, 纬度, 经度],用作「城市级」周边目的地 +const CN_CITIES = [ + ['北京市', 39.9042, 116.4074], ['天津市', 39.3434, 117.3616], + ['石家庄市', 38.0428, 114.5149], ['唐山市', 39.6304, 118.1804], + ['秦皇岛市', 39.9354, 119.6005], ['邯郸市', 36.6256, 114.5391], + ['保定市', 38.8740, 115.4646], ['张家口市', 40.7687, 114.8865], + ['承德市', 40.9544, 117.9634], ['廊坊市', 39.5377, 116.6833], + ['沧州市', 38.3037, 116.8388], ['衡水市', 37.7392, 115.6690], + ['邢台市', 37.0682, 114.5048], ['太原市', 37.8706, 112.5489], + ['大同市', 40.0903, 113.3001], ['长治市', 36.1954, 113.1163], + ['晋中市', 37.6870, 112.7529], ['运城市', 35.0263, 111.0038], + ['临汾市', 36.0880, 111.5190], ['呼和浩特市', 40.8414, 111.7519], + ['包头市', 40.6574, 109.8404], ['赤峰市', 42.2586, 118.9568], + ['鄂尔多斯市', 39.6086, 109.7816], ['沈阳市', 41.8057, 123.4315], + ['大连市', 38.9140, 121.6147], ['丹东市', 40.1243, 124.3939], + ['锦州市', 41.0955, 121.1257], ['营口市', 40.6674, 122.2352], + ['长春市', 43.8171, 125.3235], ['吉林市', 43.8379, 126.5496], + ['延吉市', 42.9048, 129.5091], ['哈尔滨市', 45.8038, 126.5349], + ['齐齐哈尔市', 47.3427, 123.9177], ['牡丹江市', 44.5527, 129.6333], + ['上海市', 31.2304, 121.4737], ['南京市', 32.0603, 118.7969], + ['无锡市', 31.4912, 120.3119], ['徐州市', 34.2058, 117.2848], + ['常州市', 31.8107, 119.9730], ['苏州市', 31.2989, 120.5853], + ['南通市', 31.9802, 120.8943], ['连云港市', 34.5967, 119.2216], + ['扬州市', 32.3936, 119.4128], ['镇江市', 32.1878, 119.4253], + ['杭州市', 30.2741, 120.1551], ['宁波市', 29.8683, 121.5440], + ['温州市', 27.9938, 120.6993], ['嘉兴市', 30.7461, 120.7555], + ['湖州市', 30.8927, 120.0868], ['绍兴市', 30.0303, 120.5802], + ['金华市', 29.0784, 119.6474], ['舟山市', 29.9853, 122.2073], + ['台州市', 28.6560, 121.4207], ['合肥市', 31.8206, 117.2272], + ['芜湖市', 31.3529, 118.4331], ['蚌埠市', 32.9168, 117.3838], + ['安庆市', 30.5259, 117.0520], ['黄山市', 29.7147, 118.3376], + ['福州市', 26.0745, 119.2965], ['厦门市', 24.4798, 118.0894], + ['泉州市', 24.8741, 118.6757], ['漳州市', 24.5125, 117.6471], + ['南平市', 26.6416, 118.1781], ['南昌市', 28.6820, 115.8579], + ['景德镇市', 29.2689, 117.1783], ['九江市', 29.7120, 116.0010], + ['赣州市', 25.8310, 114.9350], ['上饶市', 28.4549, 117.9434], + ['济南市', 36.6512, 117.1201], ['青岛市', 36.0671, 120.3826], + ['淄博市', 36.8134, 118.0548], ['烟台市', 37.4638, 121.4479], + ['潍坊市', 36.7069, 119.1618], ['济宁市', 35.4145, 116.5872], + ['泰安市', 36.1944, 117.1280], ['威海市', 37.5128, 122.1204], + ['日照市', 35.4166, 119.5269], ['临沂市', 35.1045, 118.3563], + ['郑州市', 34.7466, 113.6254], ['开封市', 34.7973, 114.3077], + ['洛阳市', 34.6197, 112.4539], ['安阳市', 36.0990, 114.3920], + ['新乡市', 35.3028, 113.9268], ['南阳市', 32.9908, 112.5287], + ['武汉市', 30.5928, 114.3055], ['宜昌市', 30.6919, 111.2865], + ['襄阳市', 32.0090, 112.1226], ['荆州市', 30.3350, 112.2390], + ['恩施市', 30.2720, 109.4880], ['长沙市', 28.2282, 112.9388], + ['株洲市', 27.8275, 113.1339], ['衡阳市', 26.8932, 112.5719], + ['岳阳市', 29.3570, 113.1288], ['张家界市', 29.1171, 110.4794], + ['郴州市', 25.7707, 113.0117], ['广州市', 23.1291, 113.2644], + ['深圳市', 22.5431, 114.0579], ['珠海市', 22.2710, 113.5767], + ['佛山市', 23.0218, 113.1218], ['汕头市', 23.3535, 116.6808], + ['湛江市', 21.2707, 110.3594], ['肇庆市', 23.0471, 112.4720], + ['惠州市', 23.1110, 114.4160], ['东莞市', 23.0207, 113.7518], + ['中山市', 22.5208, 113.3820], ['南宁市', 22.8170, 108.3665], + ['柳州市', 24.3264, 109.4280], ['桂林市', 25.2736, 110.2994], + ['北海市', 21.4813, 109.1200], ['海口市', 20.0442, 110.1999], + ['三亚市', 18.2528, 109.5119], ['重庆市', 29.5630, 106.5516], + ['成都市', 30.5728, 104.0668], ['绵阳市', 31.4670, 104.6790], + ['乐山市', 29.5520, 103.7620], ['宜宾市', 28.7660, 104.6170], + ['南充市', 30.8370, 106.1100], ['西昌市', 27.8860, 102.2580], + ['贵阳市', 26.6470, 106.6302], ['遵义市', 27.7250, 106.9270], + ['安顺市', 26.2530, 105.9470], ['昆明市', 25.0389, 102.7183], + ['曲靖市', 25.4900, 103.7970], ['玉溪市', 24.3500, 102.5460], + ['丽江市', 26.8720, 100.2320], ['大理市', 25.6060, 100.2670], + ['景洪市', 22.0070, 100.7980], ['拉萨市', 29.6525, 91.1721], + ['日喀则市', 29.2670, 88.8810], ['林芝市', 29.6490, 94.3620], + ['西安市', 34.3416, 108.9398], ['宝鸡市', 34.3620, 107.2370], + ['咸阳市', 34.3290, 108.7090], ['延安市', 36.5860, 109.4900], + ['汉中市', 33.0670, 107.0230], ['榆林市', 38.2860, 109.7350], + ['兰州市', 36.0611, 103.8343], ['天水市', 34.5800, 105.7250], + ['张掖市', 38.9260, 100.4500], ['敦煌市', 40.1420, 94.6610], + ['西宁市', 36.6171, 101.7782], ['银川市', 38.4872, 106.2309], + ['乌鲁木齐市', 43.8256, 87.6168], ['喀什市', 39.4700, 75.9890], + ['伊宁市', 43.9160, 81.3240], ['阿勒泰市', 47.8480, 88.1390], +]; + +module.exports = { EXTRA_SCENIC, CN_CITIES }; diff --git a/lib/places-niche.js b/lib/places-niche.js new file mode 100644 index 0000000..901c72b --- /dev/null +++ b/lib/places-niche.js @@ -0,0 +1,240 @@ +'use strict'; + +/** + * 小众 / 冷门目的地库 + * + * 与 places-cn.js 的「知名景区」互补:这里收录的是真实存在、但游客相对较少的 + * 古村、遗址、湿地、地质景观、小馆与徒步线,用于让清单不再千篇一律。 + * + * 格式:[名称, 纬度, 经度, 类型, 标签(逗号分隔), 所在区域] + * 坐标为近似 WGS-84,仅用于天气与路网估算;结果会标注「内置小众地点库」, + * 不会伪装成实时检索。所有条目统一带「小众秘境」标签,便于前端筛选与兴趣匹配。 + */ + +const NICHE_SPOTS = [ + // ---------------------------------------------------------------- 北京 · 门头沟 / 海淀 / 昌平 + ['爨底下村', 39.8990, 115.6350, '古村', '小众秘境,古镇,人文历史,拍照', '北京 · 门头沟'], + ['灵水举人村', 39.9330, 115.5820, '古村', '小众秘境,古镇,人文历史,休闲', '北京 · 门头沟'], + ['双龙峡', 39.9060, 115.5560, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '北京 · 门头沟'], + ['黄草梁', 39.8520, 115.4520, '山峰', '小众秘境,自然风光,徒步登山,拍照', '北京 · 门头沟'], + ['珍珠湖', 39.8830, 115.6010, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '北京 · 门头沟'], + ['妙峰山', 39.9680, 116.0730, '山峰', '小众秘境,自然风光,徒步登山,人文历史', '北京 · 门头沟'], + ['法海寺', 39.9510, 116.1520, '寺观', '小众秘境,人文历史,室内避雨,拍照', '北京 · 石景山'], + ['模式口历史文化街', 39.9480, 116.1480, '古村', '小众秘境,古镇,人文历史,美食', '北京 · 石景山'], + ['承恩寺', 39.9530, 116.1660, '寺观', '小众秘境,人文历史,室内避雨', '北京 · 石景山'], + ['田义墓', 39.9400, 116.1610, '古迹', '小众秘境,人文历史,拍照', '北京 · 石景山'], + ['北宫国家森林公园', 39.8820, 116.1300, '自然保护地', '小众秘境,自然风光,徒步登山,休闲', '北京 · 丰台'], + ['千灵山', 39.8510, 116.0980, '山峰', '小众秘境,自然风光,徒步登山,人文历史', '北京 · 丰台'], + ['大觉寺', 40.0520, 116.1020, '寺观', '小众秘境,人文历史,休闲,拍照', '北京 · 海淀'], + ['鹫峰', 40.0670, 116.0770, '山峰', '小众秘境,自然风光,徒步登山', '北京 · 海淀'], + ['阳台山', 40.0830, 116.0820, '山峰', '小众秘境,自然风光,徒步登山,拍照', '北京 · 海淀'], + ['凤凰岭', 40.1020, 116.1010, '风景区', '小众秘境,自然风光,徒步登山,人文历史', '北京 · 海淀'], + ['贝家花园', 40.0480, 116.1100, '遗址', '小众秘境,人文历史,拍照,休闲', '北京 · 海淀'], + ['五塔寺', 39.9420, 116.3210, '古迹', '小众秘境,人文历史,室内避雨,拍照', '北京 · 海淀'], + ['智化寺', 39.9180, 116.4310, '寺观', '小众秘境,人文历史,室内避雨', '北京 · 东城'], + ['史家胡同博物馆', 39.9260, 116.4380, '展馆', '小众秘境,人文历史,室内避雨,亲子', '北京 · 东城'], + ['银山塔林', 40.3330, 116.3350, '古迹', '小众秘境,人文历史,徒步登山,拍照', '北京 · 昌平'], + ['沟崖自然风景区', 40.2830, 116.2820, '峡谷', '小众秘境,自然风光,徒步登山', '北京 · 昌平'], + ['巩华城遗址', 40.1330, 116.2830, '遗址', '小众秘境,人文历史,拍照', '北京 · 昌平'], + ['大杨山森林公园', 40.3020, 116.4180, '自然保护地', '小众秘境,自然风光,徒步登山', '北京 · 昌平'], + ['黄花城水长城', 40.4160, 116.2830, '古迹', '小众秘境,人文历史,徒步登山,亲水海滨', '北京 · 怀柔'], + ['箭扣长城', 40.4520, 116.5200, '古迹', '小众秘境,人文历史,徒步登山,拍照', '北京 · 怀柔'], + ['喇叭沟门原始森林', 40.9020, 116.4020, '自然保护地', '小众秘境,自然风光,徒步登山,拍照', '北京 · 怀柔'], + ['云蒙山', 40.5520, 116.7320, '山峰', '小众秘境,自然风光,徒步登山', '北京 · 密云'], + ['白河峡谷', 40.6030, 116.6010, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '北京 · 密云'], + ['黑龙潭', 40.5520, 116.7520, '风景区', '小众秘境,自然风光,亲水海滨,徒步登山', '北京 · 密云'], + ['桃源仙谷', 40.5720, 116.7710, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '北京 · 密云'], + ['京都第一瀑', 40.5830, 116.7820, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '北京 · 密云'], + ['古崖居', 40.5520, 116.0210, '遗址', '小众秘境,人文历史,拍照,徒步登山', '北京 · 延庆'], + ['玉渡山', 40.5820, 115.8820, '自然保护地', '小众秘境,自然风光,徒步登山,拍照', '北京 · 延庆'], + ['野鸭湖湿地', 40.4210, 115.8510, '湿地', '小众秘境,自然风光,拍照,亲子', '北京 · 延庆'], + ['硅化木国家地质公园', 40.6830, 116.3320, '地质公园', '小众秘境,自然风光,亲子,拍照', '北京 · 延庆'], + ['滴水壶', 40.6520, 116.4210, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '北京 · 延庆'], + ['乌龙峡谷', 40.6210, 116.7520, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '北京 · 延庆'], + ['海坨山', 40.6210, 115.8510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '北京 · 延庆'], + ['云居寺', 39.6170, 115.7680, '寺观', '小众秘境,人文历史,室内避雨,拍照', '北京 · 房山'], + ['石经山', 39.6210, 115.7810, '古迹', '小众秘境,人文历史,徒步登山', '北京 · 房山'], + ['上方山国家森林公园', 39.6810, 115.8210, '自然保护地', '小众秘境,自然风光,徒步登山', '北京 · 房山'], + ['圣莲山', 39.7510, 115.7210, '山峰', '小众秘境,自然风光,徒步登山,人文历史', '北京 · 房山'], + ['白草畔', 39.8020, 115.5510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '北京 · 房山'], + ['石花洞', 39.7810, 115.9310, '洞穴', '小众秘境,自然风光,室内避雨,亲子', '北京 · 房山'], + ['银狐洞', 39.7010, 115.8510, '洞穴', '小众秘境,自然风光,室内避雨', '北京 · 房山'], + ['仙栖洞', 39.6510, 115.6010, '洞穴', '小众秘境,自然风光,室内避雨', '北京 · 房山'], + ['金陵遗址', 39.8010, 115.9510, '遗址', '小众秘境,人文历史,拍照', '北京 · 房山'], + ['琉璃河遗址', 39.6010, 116.0310, '遗址', '小众秘境,人文历史,亲子', '北京 · 房山'], + ['团河行宫遗址', 39.7620, 116.4210, '遗址', '小众秘境,人文历史,休闲,拍照', '北京 · 大兴'], + ['中国铁道博物馆东郊馆', 39.9910, 116.5210, '展馆', '小众秘境,人文历史,室内避雨,亲子', '北京 · 朝阳'], + ['观复博物馆', 39.9820, 116.5210, '展馆', '小众秘境,人文历史,室内避雨,拍照', '北京 · 朝阳'], + ['红砖美术馆', 40.0210, 116.5020, '美术馆', '小众秘境,人文历史,室内避雨,拍照', '北京 · 朝阳'], + ['松美术馆', 40.0520, 116.5520, '美术馆', '小众秘境,人文历史,室内避雨,拍照', '北京 · 顺义'], + ['罗红摄影艺术馆', 40.0010, 116.5520, '美术馆', '小众秘境,拍照,室内避雨,休闲', '北京 · 顺义'], + ['张家湾古镇', 39.8510, 116.7020, '古镇', '小众秘境,古镇,人文历史,拍照', '北京 · 通州'], + ['燃灯佛舍利塔', 39.9110, 116.6610, '古迹', '小众秘境,人文历史,拍照', '北京 · 通州'], + ['路县故城遗址公园', 39.9020, 116.6810, '遗址', '小众秘境,人文历史,休闲,亲子', '北京 · 通州'], + + // ---------------------------------------------------------------- 天津 / 河北 + ['鸡鸣驿', 40.4710, 115.3010, '古村', '小众秘境,古镇,人文历史,拍照', '河北 · 张家口'], + ['天漠', 40.3810, 115.5510, '地质公园', '小众秘境,自然风光,拍照,徒步登山', '河北 · 张家口'], + ['后城赤壁', 40.7510, 115.9010, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '河北 · 张家口'], + ['暖泉古镇', 39.9410, 114.5010, '古镇', '小众秘境,古镇,人文历史,拍照', '河北 · 张家口'], + ['开阳堡', 40.0610, 114.4310, '古村', '小众秘境,古镇,人文历史,拍照', '河北 · 张家口'], + ['小五台山', 39.9010, 115.2010, '山峰', '小众秘境,自然风光,徒步登山', '河北 · 张家口'], + ['易水湖', 39.2010, 115.1010, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '河北 · 保定'], + ['白石山玻璃栈道', 39.1010, 114.7010, '山峰', '小众秘境,自然风光,徒步登山,拍照', '河北 · 保定'], + ['狼牙山', 39.1510, 115.1810, '山峰', '小众秘境,自然风光,徒步登山,人文历史', '河北 · 保定'], + ['窦王岭', 37.9010, 114.2510, '风景区', '小众秘境,自然风光,徒步登山', '河北 · 石家庄'], + ['于家石头村', 37.8510, 114.0510, '古村', '小众秘境,古镇,人文历史,拍照', '河北 · 石家庄'], + ['正定隆兴寺', 38.1510, 114.5810, '寺观', '小众秘境,人文历史,室内避雨,拍照', '河北 · 石家庄'], + ['广府古城', 36.7010, 114.7310, '古镇', '小众秘境,古镇,人文历史,亲水海滨', '河北 · 邯郸'], + ['京娘湖', 36.7510, 113.9510, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '河北 · 邯郸'], + ['杨柳青古镇', 39.1410, 116.9910, '古镇', '小众秘境,古镇,人文历史,拍照', '天津 · 西青'], + ['蓟州独乐寺', 40.0410, 117.4010, '寺观', '小众秘境,人文历史,室内避雨,拍照', '天津 · 蓟州'], + ['蓟州梨木台', 40.2010, 117.4510, '自然保护地', '小众秘境,自然风光,徒步登山,亲水海滨', '天津 · 蓟州'], + + // ---------------------------------------------------------------- 山西 / 内蒙古 + ['得胜堡', 40.3510, 113.2510, '古村', '小众秘境,古镇,人文历史,拍照', '山西 · 大同'], + ['悬空村', 38.8510, 112.0510, '古村', '小众秘境,古镇,自然风光,徒步登山', '山西 · 忻州'], + ['芦芽山', 38.7510, 111.9010, '山峰', '小众秘境,自然风光,徒步登山', '山西 · 忻州'], + ['万年冰洞', 38.8010, 111.9510, '洞穴', '小众秘境,自然风光,室内避雨,拍照', '山西 · 忻州'], + ['李家山村', 37.6010, 110.8010, '古村', '小众秘境,古镇,人文历史,拍照', '山西 · 吕梁'], + ['郭峪古城', 35.5010, 112.4510, '古镇', '小众秘境,古镇,人文历史,拍照', '山西 · 晋城'], + ['湘峪古堡', 35.4810, 112.4010, '古村', '小众秘境,古镇,人文历史,拍照', '山西 · 晋城'], + ['王莽岭', 35.6010, 113.4510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '山西 · 晋城'], + ['锡林郭勒石条山', 42.1010, 115.2010, '地质公园', '小众秘境,自然风光,拍照,徒步登山', '内蒙古 · 锡林郭勒'], + ['阿尔山森林公园', 47.1810, 119.9510, '自然保护地', '小众秘境,自然风光,徒步登山,拍照', '内蒙古 · 兴安盟'], + ['恩和俄罗斯族民族乡', 50.7510, 119.5510, '古村', '小众秘境,古镇,人文历史,拍照', '内蒙古 · 呼伦贝尔'], + + // ---------------------------------------------------------------- 东北 + ['绿江村', 40.5510, 125.3510, '古村', '小众秘境,自然风光,拍照,休闲', '辽宁 · 丹东'], + ['本溪关门山', 41.2510, 124.1510, '风景区', '小众秘境,自然风光,徒步登山,拍照', '辽宁 · 本溪'], + ['盘锦红海滩', 40.9510, 121.8510, '湿地', '小众秘境,自然风光,拍照,亲水海滨', '辽宁 · 盘锦'], + ['长白山望天鹅', 41.4510, 127.7510, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '吉林 · 白山'], + ['防川一眼望三国', 42.4410, 130.6110, '观景地', '小众秘境,自然风光,拍照,人文历史', '吉林 · 延边'], + ['横道河子镇', 44.5510, 129.6510, '古镇', '小众秘境,古镇,人文历史,拍照', '黑龙江 · 牡丹江'], + ['五大连池', 48.7210, 126.1510, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '黑龙江 · 黑河'], + + // ---------------------------------------------------------------- 江浙沪皖 + ['石塘古镇', 28.3510, 121.6010, '古镇', '小众秘境,古镇,亲水海滨,拍照', '浙江 · 台州'], + ['括苍山', 28.7510, 120.9510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '浙江 · 台州'], + ['公盂村', 28.7510, 120.5510, '古村', '小众秘境,自然风光,徒步登山,拍照', '浙江 · 台州'], + ['前童古镇', 29.2510, 121.3510, '古镇', '小众秘境,古镇,人文历史,拍照', '浙江 · 宁波'], + ['许家山石头村', 29.3510, 121.4510, '古村', '小众秘境,古镇,人文历史,拍照', '浙江 · 宁波'], + ['覆卮山', 29.8510, 120.8510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '浙江 · 绍兴'], + ['安昌古镇', 30.1510, 120.5010, '古镇', '小众秘境,古镇,美食,拍照', '浙江 · 绍兴'], + ['廿八都古镇', 28.3510, 118.4510, '古镇', '小众秘境,古镇,人文历史,拍照', '浙江 · 衢州'], + ['江郎山霞客古道', 28.5310, 118.5610, '古道', '小众秘境,自然风光,徒步登山', '浙江 · 衢州'], + ['松阳杨家堂村', 28.4510, 119.4810, '古村', '小众秘境,古镇,人文历史,拍照', '浙江 · 丽水'], + ['云和梯田', 28.1010, 119.5810, '风景区', '小众秘境,自然风光,拍照,徒步登山', '浙江 · 丽水'], + ['遂昌南尖岩', 28.3510, 119.1510, '山峰', '小众秘境,自然风光,拍照,徒步登山', '浙江 · 丽水'], + ['芹川古村', 29.5510, 118.8510, '古村', '小众秘境,古镇,人文历史,休闲', '浙江 · 杭州'], + ['指南村', 30.2510, 119.6510, '古村', '小众秘境,自然风光,拍照,休闲', '浙江 · 杭州'], + ['径山寺', 30.3510, 119.8810, '寺观', '小众秘境,人文历史,休闲,拍照', '浙江 · 杭州'], + ['良渚古城遗址', 30.3910, 119.9910, '遗址', '小众秘境,人文历史,亲子,拍照', '浙江 · 杭州'], + ['锦溪古镇', 31.1810, 120.8910, '古镇', '小众秘境,古镇,人文历史,休闲', '江苏 · 苏州'], + ['甪直古镇', 31.2710, 120.8810, '古镇', '小众秘境,古镇,人文历史,美食', '江苏 · 苏州'], + ['东山陆巷古村', 31.1010, 120.4010, '古村', '小众秘境,古镇,人文历史,拍照', '江苏 · 苏州'], + ['窑湾古镇', 34.2510, 118.0510, '古镇', '小众秘境,古镇,人文历史,美食', '江苏 · 徐州'], + ['高淳老街', 31.3310, 118.8810, '古镇', '小众秘境,古镇,美食,拍照', '江苏 · 南京'], + ['石塘竹海', 31.5510, 119.0510, '自然保护地', '小众秘境,自然风光,徒步登山,休闲', '江苏 · 南京'], + ['朱家角古镇', 31.1110, 121.0510, '古镇', '小众秘境,古镇,人文历史,美食', '上海 · 青浦'], + ['广富林遗址', 31.0510, 121.2210, '遗址', '小众秘境,人文历史,亲子,拍照', '上海 · 松江'], + ['武康路历史街区', 31.2110, 121.4310, '历史街区', '小众秘境,人文历史,拍照,美食', '上海 · 徐汇'], + ['木渎古镇', 31.2510, 120.5110, '古镇', '小众秘境,古镇,人文历史,休闲', '江苏 · 苏州'], + ['查济古村', 30.5510, 118.1510, '古村', '小众秘境,古镇,人文历史,拍照', '安徽 · 宣城'], + ['桃花潭', 30.5510, 118.0510, '风景区', '小众秘境,自然风光,人文历史,亲水海滨', '安徽 · 宣城'], + ['徽杭古道', 30.1510, 118.7510, '古道', '小众秘境,自然风光,徒步登山', '安徽 · 宣城'], + ['阳产土楼', 29.8510, 118.7510, '古村', '小众秘境,古镇,人文历史,拍照', '安徽 · 黄山'], + ['木梨硔', 29.6510, 118.1510, '古村', '小众秘境,自然风光,拍照,徒步登山', '安徽 · 黄山'], + ['唐模古村', 29.9010, 118.2210, '古村', '小众秘境,古镇,人文历史,休闲', '安徽 · 黄山'], + ['石潭村', 29.8510, 118.6510, '古村', '小众秘境,自然风光,拍照,休闲', '安徽 · 黄山'], + + // ---------------------------------------------------------------- 福建 / 江西 / 山东 / 河南 + ['四礵列岛', 26.7510, 120.3510, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '福建 · 宁德'], + ['霞浦滩涂', 26.8810, 120.0010, '湿地', '小众秘境,亲水海滨,自然风光,拍照', '福建 · 宁德'], + ['屏南龙潭里', 26.8510, 119.1510, '古村', '小众秘境,古镇,人文历史,休闲', '福建 · 宁德'], + ['培田古民居', 25.6510, 116.6510, '古村', '小众秘境,古镇,人文历史,拍照', '福建 · 龙岩'], + ['樟脚村', 25.1510, 118.8510, '古村', '小众秘境,古镇,人文历史,拍照', '福建 · 泉州'], + ['蟳埔村', 24.8810, 118.6510, '古村', '小众秘境,古镇,拍照,美食', '福建 · 泉州'], + ['瑶里古镇', 29.5510, 117.5510, '古镇', '小众秘境,古镇,人文历史,自然风光', '江西 · 景德镇'], + ['晓起古村', 29.4210, 117.9810, '古村', '小众秘境,古镇,人文历史,拍照', '江西 · 上饶'], + ['武功山', 27.4510, 114.1810, '山峰', '小众秘境,自然风光,徒步登山,拍照', '江西 · 萍乡'], + ['铅山鹅湖书院', 28.2510, 117.7510, '古迹', '小众秘境,人文历史,室内避雨,休闲', '江西 · 上饶'], + ['青州井塘古村', 36.6510, 118.3510, '古村', '小众秘境,古镇,人文历史,拍照', '山东 · 潍坊'], + ['长岛九丈崖', 37.9510, 120.7510, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '山东 · 烟台'], + ['荣成成山头', 37.4010, 122.7010, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '山东 · 威海'], + ['沂蒙山龟蒙', 35.5510, 117.8510, '山峰', '小众秘境,自然风光,徒步登山', '山东 · 临沂'], + ['郭亮村挂壁公路', 35.7510, 113.6010, '古村', '小众秘境,古镇,自然风光,徒步登山', '河南 · 新乡'], + ['南太行八里沟', 35.6510, 113.5510, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '河南 · 新乡'], + ['巩义康百万庄园', 34.7510, 112.9810, '古迹', '小众秘境,人文历史,室内避雨,拍照', '河南 · 郑州'], + ['登封观星台', 34.4010, 113.1510, '古迹', '小众秘境,人文历史,亲子,拍照', '河南 · 郑州'], + + // ---------------------------------------------------------------- 湖北 / 湖南 / 广东 / 广西 / 海南 + ['鹿院坪', 30.3510, 109.7510, '峡谷', '小众秘境,自然风光,徒步登山,拍照', '湖北 · 恩施'], + ['屏山峡谷', 29.8510, 109.8510, '峡谷', '小众秘境,自然风光,亲水海滨,拍照', '湖北 · 恩施'], + ['地心谷', 30.5510, 109.9510, '峡谷', '小众秘境,自然风光,徒步登山,亲水海滨', '湖北 · 恩施'], + ['羊楼洞古镇', 29.7510, 113.8510, '古镇', '小众秘境,古镇,人文历史,美食', '湖北 · 咸宁'], + ['高椅古村', 26.8510, 109.7510, '古村', '小众秘境,古镇,人文历史,拍照', '湖南 · 怀化'], + ['紫鹊界梯田', 27.6510, 111.0510, '风景区', '小众秘境,自然风光,拍照,徒步登山', '湖南 · 娄底'], + ['崀山', 26.3510, 110.8510, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '湖南 · 邵阳'], + ['边城茶峒', 28.5510, 109.2510, '古镇', '小众秘境,古镇,人文历史,休闲', '湖南 · 湘西'], + ['连南千年瑶寨', 24.6510, 112.2510, '古村', '小众秘境,古镇,人文历史,拍照', '广东 · 清远'], + ['南澳岛', 23.4210, 117.0510, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '广东 · 汕头'], + ['盐洲岛', 22.7510, 114.9510, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '广东 · 惠州'], + ['英西峰林', 24.2510, 112.9510, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '广东 · 清远'], + ['石塘古村', 25.1010, 113.3510, '古村', '小众秘境,古镇,人文历史,拍照', '广东 · 韶关'], + ['斜阳岛', 20.9510, 109.2010, '海滨', '小众秘境,亲水海滨,自然风光,拍照', '广西 · 北海'], + ['程阳八寨', 25.9010, 109.6510, '古村', '小众秘境,古镇,人文历史,拍照', '广西 · 柳州'], + ['会仙湿地', 25.1510, 110.2010, '湿地', '小众秘境,自然风光,亲水海滨,拍照', '广西 · 桂林'], + ['加榜梯田', 25.7510, 108.4510, '风景区', '小众秘境,自然风光,拍照,徒步登山', '贵州 · 黔东南'], + ['肇兴侗寨', 25.7510, 109.1510, '古村', '小众秘境,古镇,人文历史,拍照', '贵州 · 黔东南'], + ['镇远青龙洞', 27.0510, 108.4510, '古迹', '小众秘境,人文历史,拍照,室内避雨', '贵州 · 黔东南'], + ['万峰林', 25.0510, 104.9510, '地质公园', '小众秘境,自然风光,拍照,休闲', '贵州 · 黔西南'], + ['旧县古村', 24.8510, 110.4510, '古村', '小众秘境,古镇,人文历史,拍照', '广西 · 桂林'], + + // ---------------------------------------------------------------- 川渝 / 云南 / 西藏 + ['党岭葫芦海', 30.8510, 101.5510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '四川 · 甘孜'], + ['莫斯卡村', 30.7510, 101.8510, '古村', '小众秘境,古镇,自然风光,徒步登山', '四川 · 甘孜'], + ['中路藏寨', 30.8510, 101.8510, '古村', '小众秘境,古镇,人文历史,拍照', '四川 · 甘孜'], + ['亚青寺', 31.1510, 99.5510, '寺观', '小众秘境,人文历史,拍照,自然风光', '四川 · 甘孜'], + ['措普沟', 30.4510, 99.5510, '自然保护地', '小众秘境,自然风光,徒步登山,亲水海滨', '四川 · 甘孜'], + ['七藏沟', 32.7510, 103.7510, '峡谷', '小众秘境,自然风光,徒步登山,拍照', '四川 · 阿坝'], + ['神座村', 32.5510, 102.0510, '古村', '小众秘境,古镇,自然风光,休闲', '四川 · 阿坝'], + ['龚滩古镇', 28.7510, 108.4510, '古镇', '小众秘境,古镇,人文历史,亲水海滨', '重庆 · 酉阳'], + ['石柱刀背梁', 30.0010, 108.2510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '重庆 · 石柱'], + ['偏岩古镇', 29.9510, 106.5510, '古镇', '小众秘境,古镇,人文历史,休闲', '重庆 · 北碚'], + ['诺邓古村', 25.9510, 99.3510, '古村', '小众秘境,古镇,人文历史,美食', '云南 · 大理'], + ['沙溪古镇', 26.3210, 99.8510, '古镇', '小众秘境,古镇,人文历史,休闲', '云南 · 大理'], + ['雨崩村', 28.3510, 98.7510, '古村', '小众秘境,自然风光,徒步登山,拍照', '云南 · 迪庆'], + ['丙中洛', 28.0510, 98.6510, '峡谷', '小众秘境,自然风光,徒步登山,人文历史', '云南 · 怒江'], + ['元阳箐口民俗村', 23.1510, 102.7510, '古村', '小众秘境,古镇,自然风光,拍照', '云南 · 红河'], + ['建水团山民居', 23.6010, 102.7510, '古村', '小众秘境,古镇,人文历史,拍照', '云南 · 红河'], + ['坝美村', 23.4510, 105.0510, '古村', '小众秘境,古镇,自然风光,亲水海滨', '云南 · 文山'], + ['普者黑', 24.0510, 104.1510, '湿地', '小众秘境,自然风光,亲水海滨,拍照', '云南 · 文山'], + ['萨普神山', 30.8510, 93.5510, '山峰', '小众秘境,自然风光,徒步登山,拍照', '西藏 · 那曲'], + ['札达土林', 31.5010, 79.7510, '地质公园', '小众秘境,自然风光,人文历史,拍照', '西藏 · 阿里'], + ['来古冰川', 29.2510, 96.7510, '冰川', '小众秘境,自然风光,徒步登山,拍照', '西藏 · 昌都'], + ['索松村', 29.5510, 94.9510, '古村', '小众秘境,古镇,自然风光,拍照', '西藏 · 林芝'], + + // ---------------------------------------------------------------- 西北 + ['波浪谷', 37.5510, 108.8510, '地质公园', '小众秘境,自然风光,拍照,徒步登山', '陕西 · 榆林'], + ['米脂窑洞古城', 37.7510, 110.1710, '古村', '小众秘境,古镇,人文历史,拍照', '陕西 · 榆林'], + ['青木川古镇', 32.8510, 105.5510, '古镇', '小众秘境,古镇,人文历史,休闲', '陕西 · 汉中'], + ['留坝高江路', 33.6010, 106.8510, '古道', '小众秘境,自然风光,拍照,休闲', '陕西 · 汉中'], + ['扎尕那', 34.2510, 103.1510, '古村', '小众秘境,古镇,自然风光,徒步登山', '甘肃 · 甘南'], + ['郎木寺', 34.0510, 102.6510, '寺观', '小众秘境,人文历史,自然风光,拍照', '甘肃 · 甘南'], + ['炳灵寺石窟', 35.8010, 103.0510, '古迹', '小众秘境,人文历史,室内避雨,亲水海滨', '甘肃 · 临夏'], + ['石包城遗址', 39.5510, 95.1510, '遗址', '小众秘境,人文历史,拍照,自然风光', '甘肃 · 酒泉'], + ['翡翠湖', 37.4510, 95.1510, '风景区', '小众秘境,自然风光,亲水海滨,拍照', '青海 · 海西'], + ['水上雅丹', 37.7510, 93.3510, '地质公园', '小众秘境,自然风光,亲水海滨,拍照', '青海 · 海西'], + ['贵德黄河清', 36.0410, 101.4310, '风景区', '小众秘境,自然风光,亲水海滨,休闲', '青海 · 海南'], + ['坎布拉', 36.0510, 101.7510, '地质公园', '小众秘境,自然风光,徒步登山,拍照', '青海 · 黄南'], + ['西夏王陵', 38.2510, 105.9510, '遗址', '小众秘境,人文历史,拍照,亲子', '宁夏 · 银川'], + ['水洞沟', 38.3510, 106.5510, '遗址', '小众秘境,人文历史,亲子,自然风光', '宁夏 · 银川'], + ['大海道', 42.0510, 93.7510, '地质公园', '小众秘境,自然风光,拍照,徒步登山', '新疆 · 哈密'], + ['夏塔古道', 43.1510, 80.7510, '古道', '小众秘境,自然风光,徒步登山,拍照', '新疆 · 伊犁'], + ['琼库什台', 43.1510, 82.3510, '古村', '小众秘境,古镇,自然风光,徒步登山', '新疆 · 伊犁'], + ['禾木村', 48.5510, 87.4510, '古村', '小众秘境,古镇,自然风光,拍照', '新疆 · 阿勒泰'], + ['白哈巴村', 48.6510, 86.7510, '古村', '小众秘境,古镇,自然风光,拍照', '新疆 · 阿勒泰'], +]; + +module.exports = { NICHE_SPOTS }; diff --git a/lib/planner.js b/lib/planner.js new file mode 100644 index 0000000..554ee07 --- /dev/null +++ b/lib/planner.js @@ -0,0 +1,587 @@ +'use strict'; + +/** + * AI 规划层 + * - 使用标准 OpenAI 兼容 /chat/completions 协议(流式 SSE) + * - 无 API Key 时自动进入「演示模式」:用本地规则引擎生成同结构 Markdown, + * 保证整个网站不配密钥也能端到端跑通 + */ + +const U = require('./util'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function chunkString(str, size = 28) { + const out = []; + for (let i = 0; i < str.length; i += size) out.push(str.slice(i, i + size)); + return out; +} + +/** 构造发给模型的结构化上下文。字段刻意精简:多传一个字段就多一次 token。 */ +function buildPayload(body) { + const days = Math.min(7, Math.max(1, Number(body.timeRange?.days) || 2)); + const shortlist = (Array.isArray(body.shortlist) ? body.shortlist : []).slice(0, 5).map((c, i) => { + // 逐日天气是最大的 token 头:每个候选都带一串逐日数据。 + // 只给第 1 名(主目的地)完整逐日天气——模型排日程主要围绕它, + // 其余候选只留区间摘要,需要时用 summary 判断"要不要换目的地"。 + const withDaily = i === 0; + return { + rank: i + 1, + name: String(c.name || '').slice(0, 40), + kind: c.kind || '', + tags: (c.tags || []).slice(0, 3), + distanceKm: c.distanceKm, + direction: c.direction || '', + score: c.score, + verdict: String(c.verdict || '').slice(0, 30), + // 只要最优一档,别把 3 种交通方式全塞进去。 + // road/rail 只给第 1 名(模型写交通与预算只针对主目的地),其余候选留空。 + travel: c.travel ? { + bestHours: c.travel.bestHours, + bestMode: c.travel.bestMode, + road: (i === 0 && c.road) ? { distanceKm: c.road.distanceKm, durationMinutes: c.road.durationMinutes, source: c.road.source } : null, + rail: (i === 0 && c.rail) ? { available: c.rail.available, durationMinutes: c.rail.durationMinutes, fareCny: c.rail.fareCny, source: c.rail.source, note: c.rail.note } : null, + } : null, + weather: c.weather ? { + // 第 1 名给完整摘要;其余只给"够判断要不要换目的地"的极简摘要 + summary: withDaily ? (c.weather.summary || null) : (c.weather.summary ? { + avgLow: c.weather.summary.avgLow, + avgHigh: c.weather.summary.avgHigh, + maxPrecipProb: c.weather.summary.maxPrecipProb, + badDays: c.weather.summary.badDays, + } : null), + // 只带行程覆盖的那几天:原来一次带 31 天,纯浪费 + daily: withDaily ? (c.weather.daily || []).slice(0, days).map((d) => ({ + date: d.date, weekday: d.weekday, label: d.label, tmax: d.tmax, tmin: d.tmin, precip: d.precip, precipProb: d.precipProb, + })) : [], + } : null, + aqi: c.aqi ?? null, + }; + }); + + return { + origin: body.origin ? { name: body.origin.name || body.origin.label || '', label: body.origin.label || body.origin.name || '' } : {}, + timeRange: body.timeRange || {}, + constraints: body.constraints ? { + maxOneWayHours: body.constraints.maxOneWayHours, + interests: (body.constraints.interests || []).slice(0, 6), + } : {}, + shortlist, + userNeeds: String(body.userNeeds || '').slice(0, 500) || '(未填,按清单与约束做通用安排)', + // 数据口径只留最关键的几条,全文在页面上有,不必喂给模型 + dataNotes: (Array.isArray(body.dataNotes) ? body.dataNotes : []).slice(0, 3) + .map((n) => String(n).slice(0, 60)), + // 周边住宿:只作为"住哪一片"的参考,POI 里没有房态和实时房价 + hotels: Array.isArray(body.hotels) ? body.hotels.slice(0, 5).map((h) => ({ + name: String(h.name || '').slice(0, 30), + distanceKm: h.distanceKm, + rating: h.rating, + cost: h.cost, + })) : [], + // 目的地周边的可点名地点:没有这部分,模型只能写「某某片区」再让你自行核实 + nearby: (() => { + const n = body.nearby; + if (!n || typeof n !== 'object') return null; + const pick = (arr) => (Array.isArray(arr) ? arr.slice(0, 5) : []).map((x) => ({ + name: String(x.name || '').slice(0, 20), + type: String(x.type || '').slice(0, 10), + distanceKm: x.distanceKm, + rating: x.rating, + cost: x.cost, + })).filter((x) => x.name); + const food = pick(n.food); + const sights = pick(n.sights); + if (!food.length && !sights.length) return null; + return { + area: String(n.area || '').slice(0, 40), + radiusKm: Number(n.radiusKm) || 8, + food, + sights, + }; + })(), + // 用户选的住宿价位:既约束推荐,也用于预算表 + lodging: body.lodging && typeof body.lodging === 'object' ? { + label: String(body.lodging.label || '').slice(0, 24), + min: Number.isFinite(body.lodging.min) ? body.lodging.min : null, + max: Number.isFinite(body.lodging.max) ? body.lodging.max : null, + } : null, + }; +} + +/** + * 构造对话。 + * 第一轮:system + 完整上下文 JSON + 用户需求。 + * 追加轮:再塞入「上一版方案(assistant)」+「这次要怎么改(user)」, + * 让模型在原方案上迭代,而不是从零重来——否则用户会觉得"又给我一份新的"。 + */ +function buildMessages(payload, systemPrompt, extra = {}) { + // 上下文用紧凑 JSON:缩进和换行本身也要算 token,白白多花几百 + const json = JSON.stringify(payload); + const msgs = [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: + '以下是本站分析得到的上下文(JSON)。请据此与我的具体需求生成行程规划,严格按系统提示要求的 Markdown 结构输出。\n\n' + + '```json\n' + json + '\n```\n\n' + + `我的具体需求:${payload.userNeeds}`, + }, + ]; + if (extra.previous) { + msgs.push({ role: 'assistant', content: extra.previous }); + msgs.push({ + role: 'user', + content: + `在上一版方案的基础上做如下调整:${extra.followUp || '请自行提升可执行性'}\n\n` + + '要求:输出调整后的**完整**方案,保持原有 Markdown 结构(方案总览 / 逐日安排 / 天气与装备 / 交通方案 / 预算粗估 / 出发前清单 / 风险与备选)。' + + '不要只输出改动片段,也不要写「已按要求修改」之类的交代;与上一版冲突的地方直接改掉,并在「风险与备选」里说明取舍。', + }); + } + return msgs; +} + +// ------------------------------------------------------------ 演示模式规划 + +function fmtHours(min) { + if (min == null) return '—'; + const h = Math.floor(min / 60); + const m = Math.round(min % 60); + return h ? `${h}小时${m ? m + '分' : ''}` : `${m}分钟`; +} + +function demoPlan(p) { + const list = p.shortlist || []; + const days = p.timeRange?.days || 1; + const dates = U.eachDate(p.timeRange?.start || U.todayISO(), p.timeRange?.end || U.todayISO(), 31); + const main = list[0]; + const backup = list[1]; + const maxH = p.constraints?.maxOneWayHours || 2.5; + const prefs = (p.constraints?.transportPrefs || []).join('、') || '不限'; + const interests = (p.constraints?.interests || []).join('、') || '未指定'; + const road = main?.travel?.road || main?.road || null; + const rail = main?.travel?.rail || main?.rail || null; + const w = main?.weather?.summary; + const daily = main?.weather?.daily || []; + const allNotes = (p.dataNotes || []).map((n) => `- ${n}`).join('\n'); + + const L = []; + L.push('> ⚠️ **演示模式**:当前未配置 AI 接口密钥,以下为本地规则引擎依据同一份数据生成的示例方案。配置任意 OpenAI 兼容接口后,将改由大模型结合你的需求生成更贴合的规划。'); + L.push(''); + L.push('## 方案总览'); + L.push(''); + if (!main) { + L.push('尚未选择目的地。请返回上一步,在备选清单中勾选至少一个目的地后再生成规划。'); + return L.join('\n'); + } + L.push(`以 **${main.name}** 为主目的地(适配分 ${main.score ?? '—'},${main.verdict || ''}),` + + `出行 ${days} 天(${p.timeRange?.start || ''} ~ ${p.timeRange?.end || ''},${p.timeRange?.weekdayHint || ''})。` + + `出发地 ${p.origin?.name || '—'},直线距离约 ${U.round(main.distanceKm ?? 0, 1)}km,` + + `最快单程约 ${fmtHours((main.travel?.bestHours ?? (road?.durationMinutes ?? 0)) * 60)}(${main.travel?.bestMode || '自驾'}),` + + `往返约 ${fmtHours(((main.travel?.bestHours ?? 0) * 2) * 60)}。`); + L.push(''); + if (backup) L.push(`备选 **${backup.name}**(${backup.score ?? '—'} 分):${backup.reasons?.[0] || '综合条件次优'};不首选的原因是适配分低于主目的地,适合作为天气突变或主目的地拥挤时的替换。`); + else L.push('清单中暂无第二备选,建议放宽半径或时间范围后再分析一次。'); + L.push(''); + L.push(`约束:可接受单程 ≤ ${maxH} 小时;交通偏好 ${prefs};兴趣 ${interests}。节奏按「先扣往返、再排游玩」安排${days >= 2 ? ',每天留出午餐与 1 小时机动时间' : ',当日往返不安排过夜项目'}。`); + L.push(''); + L.push(`> **需求备忘**:${p.userNeeds}。演示模式只做通用排布;配置 AI 接口后,模型会据此细化同行人、预算、体力与偏好,并指出与数据的冲突。`); + L.push(''); + + L.push('## 逐日安排'); + L.push(''); + dates.forEach((date, i) => { + const d = daily[i] || daily[Math.min(i, daily.length - 1)] || null; + const wd = U.weekdayCN(date); + L.push(`### D${i + 1}(${date} ${wd})`); + L.push(''); + L.push('| 时段 | 安排 | 交通与耗时 | 天气应对 |'); + L.push('| --- | --- | --- | --- |'); + const weatherText = d ? `${d.label} ${d.tmin}~${d.tmax}℃,降水概率 ${d.precipProb}%` : '天气数据缺失'; + const rainPlan = d && (d.precipProb >= 60 || d.precip >= 5) + ? '改为室内:博物馆 / 展馆 / 商圈,备雨具' + : '按户外安排,备防晒与饮水'; + if (i === 0) { + const arriveH = 8 + Math.ceil(main.travel?.bestHours ?? 1); + L.push(`| 08:00–${String(Math.min(arriveH, 23)).padStart(2, '0')}:00 | 从${p.origin?.name || '出发地'}出发前往${main.name} | ${main.travel?.bestMode || '自驾'} 约 ${fmtHours((main.travel?.bestHours ?? 0) * 60)}${main.travel?.bestMode === '铁路/高铁' ? '(估算)' : ''} | 出发前再看一次当日预报 |`); + L.push(`| 上午 | 抵达后先游览核心区域,轻量开场 | 步行 / 景区接驳 | ${weatherText};${rainPlan} |`); + L.push(`| 12:00–13:30 | 午餐与休息(不安排赶场) | — | ${d && d.tmax >= 30 ? '避开正午高温,选室内就餐' : '按体感调整'} |`); + L.push(`| 下午 | ${main.kind || '目的地'}主线游览${backup && days === 1 ? `,视体力可选顺路短停 ${backup.name}` : ''} | 景区内交通 | ${weatherText};${rainPlan} |`); + if (days === 1) { + L.push(`| 16:30 前 | 返程,预留 1 小时缓冲 | ${fmtHours((main.travel?.bestHours ?? 0) * 60)} | 天黑前结束山路/水域活动 |`); + } else { + L.push('| 傍晚 | 入住 / 晚餐,整理次日装备 | — | 复核次日天气与车次 |'); + } + } else if (i === dates.length - 1 && days > 1) { + L.push('| 上午 | 补充昨日未尽项目或周边轻量点位 | 步行 / 短途接驳 | ' + weatherText + ';' + rainPlan + ' |'); + L.push('| 12:00–13:30 | 午餐、退房 | — | — |'); + L.push(`| 14:00 前 | 返程,预留 1 小时缓冲 | ${fmtHours((main.travel?.bestHours ?? 0) * 60)} | 避免夜间长途 |`); + } else { + L.push(`| 上午 | ${backup ? backup.name + ' 或 ' : ''}主线深度游 | 当地交通(估算) | ${weatherText};${rainPlan} |`); + L.push('| 12:00–13:30 | 午餐与休息 | — | 高温/降雨时延长室内休息 |'); + L.push('| 下午 | 次级点位,留 1 小时机动 | 当地交通(估算) | ' + rainPlan + ' |'); + } + L.push(''); + }); + + L.push('## 天气与装备'); + L.push(''); + if (w) { + L.push(`区间平均 **${w.avgLow ?? '—'}~${w.avgHigh ?? '—'}℃**,累计降水 ${w.totalPrecip ?? 0}mm,最大降水概率 ${w.maxPrecipProb ?? 0}%,最大风速 ${w.maxWind ?? '—'}km/h,需留意天数 ${w.badDays ?? 0} 天,最差天气为${w.dominant?.label || '—'}。`); + } else { + L.push('未取到逐日天气,以下为通用建议。'); + } + L.push(''); + L.push('- 穿衣:按日温差分层,外层防风;' + (w && w.avgHigh >= 28 ? '高温天备速干衣物与遮阳帽。' : '低温天备保暖层。')); + L.push('- 雨具/防晒:' + (w && w.maxPrecipProb >= 50 ? '折叠伞或轻便雨衣必带,鞋子防滑。' : '备一把折叠伞与防晒霜即可。')); + L.push(`- 空气质量:${main.aqi != null ? `AQI ${main.aqi}(${main.aqi <= 50 ? '优' : main.aqi <= 100 ? '良' : '一般'}),敏感人群酌情减少户外停留。` : '本次未取到 AQI 数据,敏感人群请临近出发前查询。'}`); + L.push('- **若遇降雨或大风**:把户外主线替换为清单中标注「室内避雨」的候选地,或缩短停留、提前返程;山区/水域项目一律取消。'); + L.push(''); + + L.push('## 交通方案'); + L.push(''); + const noDrive = (p.constraints?.transportPrefs || []).some((x) => /不限|公共交通|铁路|高铁/.test(x)) && !(p.constraints?.transportPrefs || []).includes('自驾'); + if (!noDrive && road) { + L.push(`**自驾**:${p.origin?.name || '出发地'} → ${main.name},约 ${road.distanceKm}km / ${fmtHours(road.durationMinutes)}(${road.source}${road.source === '估算' ? ',非实时路网' : ''})。建议 08:00 前出发避开拥堵;确认停车场位置与收费、景区是否限行/需预约,长途每 2 小时休息一次。`); + L.push(''); + } + if (rail) { + L.push(rail.available + ? `**铁路 / 公共交通**:估算约 ${fmtHours(rail.durationMinutes)},参考票价 ¥${rail.fareCny ?? '—'}(${rail.source})。${rail.note || ''} 建议提前 30–45 分钟到站,回程留意末班车。` + : `**铁路**:${rail.note || '距离过近或暂无直达城际铁路'},建议改用自驾或市内公共交通。`); + L.push(''); + } + L.push('> 车次、余票、实时路况与限行政策请在 12306 / 地图 App / 景区官方渠道临近出发前核对;本方案中的估算值不作为购票依据。'); + L.push(''); + + L.push('## 预算粗估'); + L.push(''); + L.push('| 项目 | 区间(¥) | 说明 |'); + L.push('| --- | --- | --- |'); + L.push(`| 往返交通 | ${Math.round((road?.distanceKm || 60) * 1.2)} – ${Math.round((road?.distanceKm || 60) * 2.4)} | 油费/过路费或车票,按 ${road?.distanceKm || 60}km 里程粗估 |`); + L.push('| 门票 / 预约 | 0 – 200 | 是否收费需查景区官方公告,本站未提供门票数据 |'); + L.push(`| 餐饮 | ${60 * days} – ${120 * days} | 按每人每天 60–120 元 × ${days} 天 |`); + L.push(days > 1 ? `| 住宿 | ${200 * (days - 1)} – ${500 * (days - 1)} | 按 ${days - 1} 晚、每间 200–500 元粗估 |` : '| 住宿 | 0 | 当日往返,不安排过夜 |'); + L.push('| 其他(停车/接驳/保险) | 30 – 150 | 机动 |'); + L.push('| **合计** | **粗估,不含购物** | 以实际发生为准,所有金额均为估算 |'); + L.push(''); + + L.push('## 出发前清单'); + L.push(''); + L.push('- 复核:出行日前 1–2 天再看一次天气预报(本方案天气可能来自预报或历史气候参考)、景区开放与预约状态、车次余票。'); + L.push('- 证件与支付:身份证、优惠证件、手机电量与充电宝、少量现金。'); + L.push('- 装备:按「天气与装备」一节准备;山区加防滑鞋与登山杖,带儿童/老人加常用药与保暖备用。'); + L.push('- 把返程时间与末班交通设成手机提醒,预留至少 1 小时缓冲。'); + L.push(''); + + L.push('## 风险与备选'); + L.push(''); + L.push('1. **降雨/大风** → 触发条件:当日降水概率 ≥60% 或风速 ≥40km/h;替换动作:启用「室内避雨」候选地,取消山区、水域与高处观景。'); + L.push(`2. **交通延误** → 触发条件:单程实际耗时超过 ${maxH} 小时;替换动作:压缩下午项目${backup ? `,或改去更近的 ${backup.name}` : ''},不硬赶返程。`); + L.push('3. **体力不支 / 同行人不适** → 触发条件:老人、儿童或同行者提出休息;替换动作:只保留上午核心项目,午后直接返程。'); + L.push('4. **拥挤排队** → 触发条件:节假日或现场排队超预期;替换动作:改走次级点位,把热门项目放到次日早场。'); + L.push(''); + L.push('---'); + L.push(''); + L.push('**数据来源说明**'); + L.push(''); + L.push(allNotes || '- 见页面底部数据源声明'); + L.push(''); + L.push(`> 本方案由${p.site || 'TripSense'}基于候选清单数据生成,未提供的票价、班次、营业时间均未编造;凡标注「估算」处请自行核实。`); + return L.join('\n'); +} + +// ------------------------------------------------- OpenAI 兼容流式调用 + +async function* planEvents(body, ai, cfg) { + const payload = buildPayload(body); + // 追加轮:带上上一版方案与这次的新要求,让模型在原文上改 + const rawPrev = String(body.previous || ''); + const previous = rawPrev.length > 3500 + ? `${rawPrev.slice(0, 3500)}\n\n(上一版过长,已截断,请以本次要求为准重构)` + : rawPrev; + const followUp = String(body.followUp || '').slice(0, 500); + const useDemo = !ai.apiKey; + // 粗估输入 token:中文按 ~1.6 字/token,JSON 里的英文/数字约 3 字/token。 + // 只用于给用户一个"上下文有多大"的直观数字,不做计费依据。 + const ctxChars = JSON.stringify(payload).length; + const approxTokens = Math.round(ctxChars / 2.2); + yield { + type: 'meta', + mode: useDemo ? 'demo' : 'live', + model: useDemo ? '本地规则引擎' : ai.model, + baseUrl: ai.baseUrl, + count: payload.shortlist.length, + isFollowUp: Boolean(previous), + ctxChars, + approxTokens, + }; + + if (useDemo) { + let md = demoPlan(payload); + if (previous) { + md = '> ℹ️ 当前是**演示模式**:本地规则引擎无法真正理解增量需求,下面是重新生成的通用方案。到「设置」里填好 AI 接口后,就能基于上一版做针对性调整。\n\n' + md; + } + for (const piece of chunkString(md, 26)) { + yield { type: 'delta', text: piece }; + await sleep(10); + } + yield { type: 'done' }; + return; + } + + const url = `${String(ai.baseUrl).replace(/\/+$/, '')}/chat/completions`; + const messages = buildMessages(payload, cfg.SYSTEM_PROMPT, { previous, followUp }); + const timeoutMs = ai.timeoutMs || cfg.AI.TIMEOUT_MS; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ai.apiKey}`, + Accept: 'text/event-stream, application/json', + }; + // 只带必需参数的版本:新版推理模型会拒绝 temperature / max_tokens + const minimalPayload = { model: ai.model, messages, stream: true }; + const baseMaxTokens = ai.maxTokens ?? cfg.AI.MAX_TOKENS; + + // 推理模型会把 max_tokens 大量花在思考上,正文可能一个字都不剩 + // (实测 deepseek 系模型 3000 的额度全被思考吃光)。第一次只拿到思考时, + // 自动放大额度重跑一次,省得用户自己去改配置。 + let maxTokens = baseMaxTokens; + for (let attempt = 1; attempt <= 2; attempt += 1) { + const out = yield* runOnce({ url, headers, payload: minimalPayload, timeoutMs, maxTokens, allowParamFallback: true }); + if (out.emitted > 0) { yield { type: 'done' }; return; } + if (out.fatal) { yield { type: 'error', message: out.errorMsg }; yield { type: 'done' }; return; } + + if (out.reasoningLen > 0 && attempt === 1) { + const next = Math.min(Math.max(baseMaxTokens * 3, 8192), 16384); + if (next > maxTokens) { + yield { type: 'notice', message: `模型把 token 预算花在思考上了(${out.reasoningLen} 字),已把上限 ${maxTokens} → ${next} 自动重试` }; + maxTokens = next; + continue; + } + } + + let msg; + if (out.reasoningLen) { + msg = `模型只输出了 ${out.reasoningLen} 字思考过程,没有正文;已把 max_tokens 提到 ${maxTokens} 仍不够。建议换非推理模型,或手动调大 AI_MAX_TOKENS。`; + } else if (out.finishReason === 'content_filter') { + msg = '模型触发了内容安全策略,输出被拦截(finish_reason=content_filter)。'; + } else if (out.eventCount) { + msg = `接口返回了流但没有正文(收到 ${out.eventCount} 个事件,finish_reason=${out.finishReason || '空'})。片段:${(out.sample || []).join(' | ') || '(空)'}`; + } else { + msg = out.errorMsg || '接口返回 200,但流里没有任何数据事件。请确认地址是 OpenAI 兼容的 /chat/completions(常见坑:少了 /v1 后缀)。'; + } + yield { type: 'error', message: msg }; + yield { type: 'done' }; + return; + } +} + +/** + * 单次请求:发出去、读流,只负责 yield delta / reasoning。 + * 结果(有没有正文、思考了多少字、是否已致命)通过 return 值交回, + * 外层才能判断"要不要换更大的 max_tokens 再试一次"。 + */ +async function* runOnce({ url, headers, payload, timeoutMs, maxTokens, allowParamFallback }) { + const body = { ...payload, max_tokens: maxTokens }; + let ctrl = new AbortController(); + let timer = setTimeout(() => ctrl.abort(), timeoutMs); + let res; + try { + res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal }); + if (!res.ok) { + const txt = await res.text().catch(() => ''); + const paramIssue = res.status === 400 + && /unsupported|not support|unknown parameter|too large|temperature|max_tokens|max_completion_tokens/i.test(txt); + if (paramIssue && allowParamFallback) { + // 额度超限时先降到 8192(DeepSeek 系等模型的上限)再试, + // 仍不行才退回最小参数,避免"重试反而把请求撑爆" + clearTimeout(timer); + const tooLarge = /too large|too_large|exceed|out of range/i.test(txt); + const tries = []; + if (tooLarge && maxTokens > 8192) tries.push({ ...payload, max_tokens: 8192 }); + tries.push(payload); + let lastTxt = txt; + for (const b of tries) { + ctrl = new AbortController(); + timer = setTimeout(() => ctrl.abort(), timeoutMs); + res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(b), signal: ctrl.signal }); + if (res.ok) break; + lastTxt = await res.text().catch(() => ''); + } + if (!res.ok) { + clearTimeout(timer); + return { fatal: true, errorMsg: `AI 接口返回 HTTP ${res.status}:${lastTxt.slice(0, 300)}`, emitted: 0, reasoningLen: 0 }; + } + } else { + clearTimeout(timer); + return { fatal: true, errorMsg: `AI 接口返回 HTTP ${res.status}:${txt.slice(0, 300)}`, emitted: 0, reasoningLen: 0 }; + } + } + } catch (err) { + clearTimeout(timer); + return { fatal: true, errorMsg: `连接 AI 接口失败:${err.message}`, emitted: 0, reasoningLen: 0 }; + } + + const ct = res.headers.get('content-type') || ''; + if (!ct.includes('text/event-stream')) { + // 部分兼容服务忽略 stream 参数,直接返回完整 JSON + clearTimeout(timer); + try { + const d = await res.json(); + const text = d.choices?.[0]?.message?.content || d.choices?.[0]?.text || ''; + const reasoning = d.choices?.[0]?.message?.reasoning_content || ''; + if (text) { + yield { type: 'delta', text }; + return { emitted: String(text).length, reasoningLen: 0, finishReason: '' }; + } + return { + emitted: 0, + reasoningLen: String(reasoning).length, + finishReason: d.choices?.[0]?.finish_reason || '', + }; + } catch (err) { + return { fatal: true, errorMsg: `AI 接口返回的不是 JSON:${err.message}`, emitted: 0, reasoningLen: 0 }; + } + } + + let emitted = 0; + let reasoningLen = 0; + let finishReason = ''; + let eventCount = 0; + const sample = []; + try { + const reader = res.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buf = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() || ''; + for (const raw of lines) { + const line = raw.trim(); + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; + eventCount += 1; + if (sample.length < 2) sample.push(data.slice(0, 150)); + try { + const j = JSON.parse(data); + const choice = j.choices?.[0]; + if (choice?.finish_reason) finishReason = choice.finish_reason; + const delta = choice?.delta?.content ?? choice?.message?.content ?? ''; + const reasoning = choice?.delta?.reasoning_content || choice?.message?.reasoning_content; + if (delta) { emitted += String(delta).length; yield { type: 'delta', text: delta }; } + if (reasoning) { reasoningLen += String(reasoning).length; yield { type: 'reasoning', text: reasoning }; } + // 个别兼容服务把正文放在 delta.text + if (!delta && choice?.delta?.text) { + emitted += String(choice.delta.text).length; + yield { type: 'delta', text: choice.delta.text }; + } + } catch { /* 忽略心跳/非法片段 */ } + } + } + } catch (err) { + clearTimeout(timer); + return { fatal: false, errorMsg: `读取 AI 流失败:${err.message}`, emitted, reasoningLen, finishReason }; + } finally { + clearTimeout(timer); + } + + return { emitted, reasoningLen, finishReason, eventCount, sample }; +} + +/** + * 连通性自检:发一个最小的非流式请求,把失败定位到具体环节 + * (没 Key / 连不上 / HTTP 报错 / 返回不是 JSON / 参数不被支持 / 通了但没内容)。 + * 正因为它用非流式,才能直接读到上游的错误 JSON,比在流里猜要准。 + */ +async function testAI(ai) { + if (!ai.apiKey) return { ok: false, stage: 'config', message: '没有可用的 API Key' }; + if (!ai.model) return { ok: false, stage: 'config', message: '没有填写模型名称' }; + const url = `${String(ai.baseUrl).replace(/\/+$/, '')}/chat/completions`; + const t0 = Date.now(); + const messages = [{ role: 'user', content: '回复两个字:可用' }]; + const timeout = Math.min(ai.timeoutMs || 30000, 30000); + + const once = async (payload) => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeout); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ai.apiKey}` }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const txt = await res.text(); + return { res, txt }; + } finally { clearTimeout(timer); } + }; + + try { + let note = ''; + let { res, txt } = await once({ model: ai.model, messages, stream: false, max_tokens: 32, temperature: 0.2 }); + if (!res.ok && res.status === 400 + && /unsupported|not support|unknown parameter|temperature|max_tokens|max_completion_tokens/i.test(txt)) { + note = '该模型不接受 temperature / max_tokens,已按最小参数重试'; + ({ res, txt } = await once({ model: ai.model, messages, stream: false })); + } + const ms = Date.now() - t0; + if (!res.ok) { + return { ok: false, stage: 'http', status: res.status, ms, note, message: `HTTP ${res.status}:${txt.slice(0, 400)}` }; + } + let data; + try { data = JSON.parse(txt); } catch { + return { ok: false, stage: 'parse', status: res.status, ms, note, message: `返回的不是 JSON(前 200 字):${txt.slice(0, 200)}` }; + } + if (data.error) { + return { ok: false, stage: 'api', status: res.status, ms, note, message: `接口报错:${JSON.stringify(data.error).slice(0, 300)}` }; + } + const choice = data.choices?.[0]; + const content = choice?.message?.content ?? choice?.text ?? ''; + const reasoning = choice?.message?.reasoning_content || ''; + const finish = choice?.finish_reason || ''; + if (!content && !reasoning) { + return { ok: false, stage: 'empty', status: res.status, ms, note, finish, message: `接口通了但没返回内容(finish_reason=${finish || '空'})。可能是 max_tokens 太小或命中内容策略。` }; + } + return { + ok: true, status: res.status, ms, note, + model: data.model || ai.model, + sample: String(content || reasoning).replace(/\s+/g, ' ').slice(0, 60), + reasoningOnly: !content && Boolean(reasoning), + finish, + }; + } catch (err) { + const ms = Date.now() - t0; + const isTimeout = err.name === 'AbortError'; + return { + ok: false, stage: 'network', ms, + message: isTimeout + ? `请求超时(${Math.round(timeout / 1000)}s 内无响应)` + : `无法连接:${err.message}。检查地址能否访问、是否需要代理,或是否少了 /v1。`, + }; + } +} + +/** 合并服务端配置与前端覆盖头 */ +function resolveAI(headers, cfg) { + const h = (k) => { + const v = headers[k]; + return Array.isArray(v) ? v[0] : v; + }; + const overrideKey = h('x-ai-api-key'); + let apiKey = cfg.AI.API_KEY; + if (overrideKey === '__demo__') apiKey = ''; + else if (overrideKey && overrideKey !== '__server__') apiKey = overrideKey; + return { + baseUrl: (h('x-ai-base-url') || cfg.AI.BASE_URL).replace(/\/+$/, ''), + apiKey, + model: h('x-ai-model') || cfg.AI.MODEL, + temperature: cfg.AI.TEMPERATURE, + maxTokens: cfg.AI.MAX_TOKENS, + timeoutMs: cfg.AI.TIMEOUT_MS, + }; +} + +module.exports = { planEvents, resolveAI, buildPayload, demoPlan, testAI }; diff --git a/lib/sources.js b/lib/sources.js new file mode 100644 index 0000000..11903e2 --- /dev/null +++ b/lib/sources.js @@ -0,0 +1,1296 @@ +'use strict'; + +/** + * 数据层:地理编码 / 周边候选 / 天气 / 空气质量 / 公路 / 铁路 + * + * 设计原则: + * - 全部接口免密钥即可跑通(Open-Meteo、Photon、OSRM); + * - 每个外部依赖都有降级路径,任何单点失败不会让整页白屏; + * - 所有「估算」结果都带 source 标记,最终会原样传给 AI,避免模型把估算当事实。 + */ + +const U = require('./util'); +const C = require('./cache'); +const { JUNK_NAME, POOR_NAME, LOW_VALUE_NAME, CURATED, CN_CITIES, NICHE_SPOTS, wmo } = require('./data'); + +// 数据源层缓存 TTL(秒级语义): +// 地理编码 7 天(行政区划不会变)/高德 POI 6 小时(商家会变) +// 天气 & 空气质量 3 小时(Open-Meteo 按小时刷新)/路况 3 小时 +const TTL = { + geo: 7 * 24 * 3600 * 1000, + poi: 6 * 3600 * 1000, + weather: 3 * 3600 * 1000, + road: 3 * 3600 * 1000, +}; + +/** + * 数据源层缓存键:高德 Key 后 8 位必须参与——换 Key 会改变 POI 与路网结果, + * 不能把 A Key 的结果当成 B Key 的结果复用。 + */ +function sk(key, cfg) { + const k = cfg && cfg.AMAP_KEY ? String(cfg.AMAP_KEY).slice(-8) : ''; + return `${key}|amap:${k}`; +} + +// Photon 公共实例易限流,只取覆盖度最高的几类;失败会快速降级到内置地名库 +const PHOTON_JOBS = [ + { include: 'osm.tourism.attraction', kind: '景点', tags: ['人文历史', '拍照', '观光'] }, + { include: 'osm.tourism.museum', kind: '博物馆', tags: ['人文历史', '亲子', '室内避雨'] }, + { include: 'osm.tourism.viewpoint', kind: '观景地', tags: ['自然风光', '拍照', '徒步登山'] }, + { include: 'osm.tourism.theme_park', kind: '主题乐园', tags: ['亲子', '主题乐园'] }, + { include: 'osm.tourism.zoo', kind: '动物园', tags: ['亲子', '自然风光'] }, + { include: 'osm.leisure.park', kind: '公园', tags: ['自然风光', '休闲', '亲子'] }, + { include: 'osm.leisure.nature_reserve', kind: '自然保护地', tags: ['自然风光', '徒步登山'] }, + { include: 'osm.natural.peak', kind: '山峰', tags: ['自然风光', '徒步登山', '拍照'] }, + { include: 'osm.boundary.national_park', kind: '风景区', tags: ['自然风光', '徒步登山', '拍照'] }, + { include: 'osm.place.city', kind: '城市', tags: ['休闲', '人文历史', '美食'], isCity: true }, +]; + +// ================================================================ 地理编码 + +async function geocode(name, cfg, count = 8) { + return C.memo(sk(`geo:${String(name).trim()}:${count}`, cfg), async () => { + const u = new URL(cfg.ENDPOINTS.geocoding); + u.searchParams.set('name', String(name).trim()); + u.searchParams.set('count', String(count)); + u.searchParams.set('language', 'zh'); + u.searchParams.set('format', 'json'); + const data = await U.fetchJSON(u.toString(), {}, 12000); + return (data.results || []).map((r) => ({ + name: r.name, + label: [r.name, r.admin1 && r.admin1 !== r.name ? r.admin1 : '', r.country && r.country !== '中国' ? r.country : ''] + .filter(Boolean).join(' · '), + lat: r.latitude, + lon: r.longitude, + admin1: r.admin1 || '', + country: r.country || '', + timezone: r.timezone || 'auto', + population: r.population || 0, + })); + }, TTL.geo); +} + +async function reverseGeocode(lat, lon, cfg) { + const key = sk(`rev:${U.round(lat, 3)},${U.round(lon, 3)}`, cfg); + const hit = C.peek(key); + if (hit) return hit.result; + const res = await reverseGeocodeRaw(lat, lon, cfg); + // 「当前位置」是反向地理编码彻底失败后的占位假地名,绝不能进缓存: + // 缓存了它,后续所有依赖地名的查询(酒店/POI)都会拿着假地名去查,必然落空。 + if (res && res.name && res.name !== '当前位置') C.set(key, res, TTL.geo); + return res; +} + +/** + * 高德反向地理编码:坐标 → 省/市/区县/街道。 + * BigDataCloud 与 Photon reverse 在国内经常返回空或被限流, + * 高德 regeo 能稳定拿到「柳州市 / 城中区」——这是住宿「城市兜底」能生效的前提。 + */ +async function amapReverse(lat, lon, cfg) { + if (!cfg.AMAP_KEY) return null; + const [glng, glat] = U.wgs84ToGcj02(lon, lat); + const u = new URL(cfg.ENDPOINTS.amapRegeo); + u.searchParams.set('location', `${U.round(glng, 6)},${U.round(glat, 6)}`); + u.searchParams.set('extensions', 'base'); + u.searchParams.set('key', cfg.AMAP_KEY); + const d = await U.fetchJSON(u.toString(), {}, 8000, 1); + if (d.status !== '1' || !d.regeocode) return null; + const ac = d.regeocode.addressComponent || {}; + const city = String(ac.city || ac.district || '').trim(); + const district = String(ac.district || '').trim(); + const province = String(ac.province || '').trim(); + if (!city && !district) return null; + // 高德在直辖市/县级市会把 city 留空,用 province 兜 + const label = [province, city === province ? '' : city, district === city ? '' : district] + .filter(Boolean).join(' · '); + return { + name: city || district || province, + label: label || city || district, + lat, lon, + admin1: province, + country: '中国', + timezone: 'Asia/Shanghai', + source: '高德反向地理编码', + }; +} + +async function reverseGeocodeRaw(lat, lon, cfg) { + // 高德优先:国内地址结构最准,且能给到城市名(酒店兜底要用) + if (cfg.AMAP_KEY) { + try { + const am = await amapReverse(lat, lon, cfg); + if (am && am.name) return am; + } catch (err) { + console.warn('[reverse] amap:', err.message); + } + } + const u = new URL(cfg.ENDPOINTS.reverse); + u.searchParams.set('latitude', String(lat)); + u.searchParams.set('longitude', String(lon)); + u.searchParams.set('localityLanguage', 'zh'); + try { + const d = await U.fetchJSON(u.toString(), {}, 10000); + const city = d.city || d.principalSubdivision || d.locality || ''; + const region = d.principalSubdivision || ''; + return { + name: city || d.locality || '当前位置', + label: [city, region && region !== city ? region : '', d.countryName].filter(Boolean).join(' · '), + lat, lon, admin1: region, country: d.countryName || '', timezone: 'auto', + }; + } catch (err) { + // 兜底:Photon reverse + try { + const u2 = new URL(cfg.ENDPOINTS.photon.replace(/\/api$/, '/reverse')); + u2.searchParams.set('lat', String(lat)); + u2.searchParams.set('lon', String(lon)); + u2.searchParams.set('limit', '1'); + const d2 = await U.fetchJSON(u2.toString(), { headers: { 'Accept-Language': 'zh-CN,zh' } }, 10000); + const p = d2.features?.[0]?.properties || {}; + return { + name: p.city || p.name || '当前位置', + label: [p.city || p.name, p.state].filter(Boolean).join(' · '), + lat, lon, admin1: p.state || '', country: p.country || '', timezone: 'auto', + }; + } catch { + return { name: '当前位置', label: `当前位置 (${U.round(lat, 3)}, ${U.round(lon, 3)})`, lat, lon, admin1: '', country: '', timezone: 'auto' }; + } + } +} + +function photonCandidate(feature, cat, origin) { + const p = feature.properties || {}; + const coord = feature.geometry?.coordinates || []; + const lon = Number(coord[0]); + const lat = Number(coord[1]); + const name = String(p.name || '').trim(); + if (!name || !Number.isFinite(lat) || !Number.isFinite(lon)) return null; + if (JUNK_NAME.test(name) || POOR_NAME.test(name) || LOW_VALUE_NAME.test(name)) return null; + if (/^\d+$/.test(name)) return null; + if (name.length < 2 || name.length > 30) return null; + const distanceKm = U.haversine(origin.lat, origin.lon, lat, lon); + return { + id: `osm-${p.osm_type || 'N'}-${p.osm_id || Math.round(lat * 1e5)}`, + name, + kind: cat.kind, + tags: [...cat.tags], + lat: U.round(lat, 5), + lon: U.round(lon, 5), + region: [p.city, p.district, p.locality].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).slice(0, 2).join(' · '), + distanceKm, + bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), + source: 'OpenStreetMap / Photon', + hasExtent: Boolean(p.extent), + featured: false, + }; +} + +async function photonQuery(job, bboxArr, origin, cfg) { + // Photon 公共实例限流严格,分类检索的 bbox + 分类组合是稳定键,直接缓存 + const pkey = `photon:${job.include}:${job.isCity ? 'c' : 'a'}:${bboxArr.map((n) => U.round(n, 2)).join(',')}:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}`; + return C.memo(sk(pkey, cfg), async () => { + const u = new URL(cfg.ENDPOINTS.photon); + u.searchParams.set('limit', String(job.isCity ? 20 : cfg.SEARCH.photonPerCategory)); + u.searchParams.set('bbox', bboxArr.join(',')); + u.searchParams.set('lat', String(origin.lat)); + u.searchParams.set('lon', String(origin.lon)); + u.searchParams.set('location_bias_scale', job.isCity ? '0.9' : '0.15'); + u.searchParams.set('include', job.include); + u.searchParams.set('dedupe', '1'); + const data = await U.fetchJSON(u.toString(), { + headers: { 'Accept-Language': 'zh-CN,zh;q=0.9' }, + }, cfg.SEARCH.photonTimeoutMs, cfg.SEARCH.photonRetries); + return data.features || []; + }, TTL.poi); +} + +/** + * 高德地理编码:中文区县 / 乡镇覆盖远好于 Open-Meteo。 + * Open-Meteo 走 GeoNames,连「河池」「金城江」这类地级市/区县都查不到, + * 而高德能直接定位到区县中心。坐标会从 GCJ-02 转回 WGS-84。 + * 先走 geocode/geo(行政区),查不到再用 place/text(POI)。 + */ +async function amapGeocode(name, cfg) { + if (!cfg.AMAP_KEY) return []; + return C.memo(sk(`amapGeo:${String(name).trim()}`, cfg), async () => { + const out = []; + let lastErr = ''; + const push = (obj) => { + if (!out.some((x) => Math.abs(x.lat - obj.lat) < 0.01 && Math.abs(x.lon - obj.lon) < 0.01)) { + out.push(obj); + } + }; + + try { + const u = new URL(cfg.ENDPOINTS.amapGeocode); + u.searchParams.set('address', name); + u.searchParams.set('key', cfg.AMAP_KEY); + const d = await U.fetchJSON(u.toString(), {}, 6000, 1); + if (d.status === '1') { + for (const g of d.geocodes || []) { + const [lng, lat] = String(g.location || '').split(',').map(Number); + if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue; + const [wlon, wlat] = U.gcj02ToWgs84(lng, lat); + const city = Array.isArray(g.city) ? '' : (g.city || ''); + const district = Array.isArray(g.district) ? '' : (g.district || ''); + const province = Array.isArray(g.province) ? '' : (g.province || ''); + const parts = [...new Set([province, city === province ? '' : city, district].filter(Boolean))]; + push({ + name: parts[parts.length - 1] || name, + label: parts.join(' · '), + lat: wlat, lon: wlon, + admin1: province, + country: '中国', + timezone: 'Asia/Shanghai', + level: g.level || '', + source: '高德地理编码', + }); + } + } else { + lastErr = `高德地理编码:${d.info || d.infocode || d.status}`; + } + } catch (e) { + console.warn('[amapGeocode] geo:', e.message); + lastErr = e.message; + } + + if (!out.length) { + try { + const u = new URL(cfg.ENDPOINTS.amapPlaceText); + u.searchParams.set('keywords', name); + u.searchParams.set('offset', '6'); + u.searchParams.set('page', '1'); + u.searchParams.set('extensions', 'base'); + u.searchParams.set('key', cfg.AMAP_KEY); + const d = await U.fetchJSON(u.toString(), {}, 6000, 1); + if (d.status === '1') { + for (const p of d.pois || []) { + const [lng, lat] = String(p.location || '').split(',').map(Number); + if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue; + const [wlon, wlat] = U.gcj02ToWgs84(lng, lat); + const city = Array.isArray(p.cityname) ? '' : (p.cityname || ''); + const ad = Array.isArray(p.adname) ? '' : (p.adname || ''); + const label = [p.name, ad, city].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).slice(0, 3); + push({ + name: String(p.name || name), + label: label.join(' · '), + lat: wlat, lon: wlon, + admin1: Array.isArray(p.pname) ? '' : (p.pname || ''), + country: '中国', + timezone: 'Asia/Shanghai', + level: 'POI', + source: '高德 POI', + }); + if (out.length >= 6) break; + } + } else if (!lastErr) { + lastErr = `高德 POI:${d.info || d.infocode || d.status}`; + } + } catch (e) { + console.warn('[amapGeocode] text:', e.message); + if (!lastErr) lastErr = e.message; + } + } + + // 有 Key 但一条都没取到、且确实报错了 → 抛给上层,让用户看到 Key 无效之类的原因, + // 而不是静默变成"查不到这个地名" + if (!out.length && lastErr) { + const err = new Error(lastErr); + err.amapFatal = /INVALID_USER_KEY|USERKEY_PLAT_NOMATCH|DAILY_QUERY_OVER_LIMIT|10001|10002|10003|10009/i.test(lastErr); + throw err; + } + return out; + }, TTL.geo); +} + +/** + * 周边住宿参考:高德 POI「住宿服务」(types=100000)。 + * 只用于给行程规划提供"住哪一片"的参考,房态与实时房价不存在于 POI 里, + * 所以前端与提示词都必须标注"仅供参考、需自行核实"。 + */ +async function amapHotels(lat, lon, radiusKm, cfg) { + if (!cfg.AMAP_KEY) return { hotels: [] }; + return C.memo(sk(`amapHotel:${U.round(lat, 3)},${U.round(lon, 3)}:${Math.round(radiusKm)}`, cfg), async () => { + const [glng, glat] = U.wgs84ToGcj02(lon, lat); + const u = new URL(cfg.ENDPOINTS.amapAround); + u.searchParams.set('location', `${U.round(glng, 6)},${U.round(glat, 6)}`); + u.searchParams.set('types', '100000'); + u.searchParams.set('radius', String(Math.min(Math.round(radiusKm * 1000), 20000))); + u.searchParams.set('offset', '15'); + u.searchParams.set('page', '1'); + u.searchParams.set('extensions', 'all'); + u.searchParams.set('key', cfg.AMAP_KEY); + const d = await U.fetchJSON(u.toString(), {}, 8000, 1); + if (d.status !== '1') throw new Error(`高德住宿检索:${d.info || d.infocode || d.status}`); + const hotels = (d.pois || []).map((p) => { + const [plng, plat] = String(p.location || '').split(',').map(Number); + if (!Number.isFinite(plng) || !Number.isFinite(plat)) return null; + const [wlon, wlat] = U.gcj02ToWgs84(plng, plat); + const biz = p.biz_ext || {}; + const rating = Number(biz.rating); + const cost = Number(biz.cost); + return { + name: String(p.name || '').slice(0, 40), + address: String(p.address || '').slice(0, 60), + lat: wlat, lon: wlon, + distanceKm: U.round(U.haversine(lat, lon, wlat, wlon), 1), + rating: Number.isFinite(rating) && rating > 0 ? rating : null, + cost: Number.isFinite(cost) && cost > 0 ? cost : null, + type: String(p.type || '').split(';').pop() || '住宿', + }; + }).filter((h) => h && h.name) + .sort((a, b) => (b.rating || 0) - (a.rating || 0) || a.distanceKm - b.distanceKm) + .slice(0, 8); + return { hotels }; + }, TTL.poi); +} + +/** + * 住宿价格的补充来源(可选)。 + * 高德 POI 的 biz_ext.cost 覆盖很差,多数酒店干脆没有价格字段—— + * 这不是解析问题,是数据源本身的限制。所以留一个可插拔的价格接口: + * 配置 HOTEL_PRICE_API_URL 后按酒店名逐条查询并合并,取不到就保持 null, + * 绝不猜价。模板占位符:{name} {lat} {lon}。 + */ +async function mergeHotelPrices(hotels, cfg) { + const tpl = cfg.HOTEL_PRICE_API_URL; + if (!tpl || !hotels.length) return { hotels, priceSource: '' }; + const out = hotels.map((h) => ({ ...h })); + const headers = cfg.HOTEL_PRICE_API_KEY ? { Authorization: `Bearer ${cfg.HOTEL_PRICE_API_KEY}` } : {}; + let hit = 0; + await U.mapLimit(out, 3, async (h) => { + if (h.cost != null) return; + try { + const url = tpl + .replace('{name}', encodeURIComponent(h.name || '')) + .replace('{lat}', String(h.lat ?? '')) + .replace('{lon}', String(h.lon ?? '')); + const d = await U.fetchJSON(url, { headers }, 6000, 0); + const list = Array.isArray(d) ? d : (Array.isArray(d?.items) ? d.items : []); + let best = null; + for (const it of list) { + const price = Number(it.price ?? it.cost); + if (!Number.isFinite(price) || price <= 0) continue; + const nm = String(it.name || ''); + // 名称要真的对得上,避免把别家价格套过来 + if (!nm || (!nm.includes(h.name) && !h.name.includes(nm))) continue; + best = price; + break; + } + if (best != null) { h.cost = Math.round(best); hit += 1; } + } catch { /* 价格源失败不影响主流程 */ } + }); + return { hotels: out, priceSource: hit ? '第三方价格源' : '' }; +} + +/** + * 计算静态地图的取景参数(中心点 / zoom / 尺寸 / GCJ-02 坐标)。 + * 前端要用它把经纬度投影成像素,才能在图上叠加编号标记与连线。 + */ +function staticMapMeta(points, cfg, opt = {}) { + const width = U.clamp(Number(opt.width) || 640, 320, 1024); + const height = U.clamp(Number(opt.height) || 360, 200, 1024); + const pts = (points || []).slice(0, 9).map((p) => { + const [lng, lat] = U.wgs84ToGcj02(p.lon, p.lat); + return { lng, lat }; + }); + if (!pts.length) return null; + + const lats = pts.map((p) => p.lat); + const lngs = pts.map((p) => p.lng); + const cLat = (Math.min(...lats) + Math.max(...lats)) / 2; + const cLng = (Math.min(...lngs) + Math.max(...lngs)) / 2; + const cosLat = Math.cos((cLat * Math.PI) / 180) || 1; + + // 横纵分别算,再取较"远"的那个:用同一个跨度套两个方向会明显过远 + const spanNS = Math.max((Math.max(...lats) - Math.min(...lats)) * 110540, 300); + const spanEW = Math.max((Math.max(...lngs) - Math.min(...lngs)) * 111320 * cosLat, 300); + // 下限 6 m/px:行程点往往集中在城市几公里内,若按真实跨度算, + // metersPerPx 会小到 2m,zoom 直接顶到 16 级,几十个点全叠成一坨, + // 反而看不出线路。下限保证图上至少有约 4km 的视野。 + const metersPerPx = Math.max(spanEW / (width * 0.72), spanNS / (height * 0.68), 6); + const zoom = U.clamp( + Math.round(Math.log2((156543.03392 * cosLat) / metersPerPx)), + 4, 16, + ); + + return { + width, + height, + center: { lng: U.round(cLng, 6), lat: U.round(cLat, 6) }, + zoom, + points: pts.map((p) => [U.round(p.lng, 6), U.round(p.lat, 6)]), + }; +} + +/** + * 高德静态地图 URL:只取底图。 + * + * 为什么不用 markers/paths: + * - markers 的 size 只接受 small/mid/large(写 medium 直接 20003); + * - 多组 markers(用 | 分隔)实测只渲染第一组,多目的地时图上只有一个点; + * - paths 的 weight/transparency 容错也差。 + * 所以这里只请求纯底图(参数最少、最稳),标记与连线由前端按 staticMapMeta 的 + * 中心点/zoom 投影后叠加,编号、名称、顺序完全可控。 + */ +function staticMapUrl(points, cfg, opt = {}) { + if (!cfg.AMAP_KEY) return null; + const meta = staticMapMeta(points, cfg, opt); + if (!meta) return null; + // 固定 scale=1:scale=2 会改变像素与地理范围的对应关系,前端投影会算错。 + // 用裸逗号拼:参数全是数字,且高德对 %2C 的处理不稳定。 + return `${cfg.ENDPOINTS.amapStaticMap}?location=${meta.center.lng},${meta.center.lat}` + + `&zoom=${meta.zoom}&size=${meta.width}*${meta.height}&scale=1` + + `&key=${encodeURIComponent(cfg.AMAP_KEY)}`; +} + +/** + * 目的地周边的「可点名地点」:餐饮 + 景点 + 博物馆。 + * 规划里不能只写"某某片区",得能点到具体店名/馆名,所以这里专门取 POI。 + * 高德 place/around 支持多类型用 | 连接,一次请求拿三类。 + */ +async function amapNearbyPois(lat, lon, radiusKm, cfg, limit = 12) { + if (!cfg.AMAP_KEY) return { food: [], sights: [], available: false, reason: '未配置高德 Key' }; + return C.memo(sk(`amapNear:${U.round(lat, 3)},${U.round(lon, 3)}:${Math.round(radiusKm)}:${limit}`, cfg), async () => { + const [glng, glat] = U.wgs84ToGcj02(lon, lat); + const u = new URL(cfg.ENDPOINTS.amapAround); + u.searchParams.set('location', `${U.round(glng, 6)},${U.round(glat, 6)}`); + u.searchParams.set('radius', String(Math.min(Math.round(radiusKm * 1000), 20000))); + u.searchParams.set('types', '050000|110000|140100'); + u.searchParams.set('offset', '25'); + u.searchParams.set('sortrule', 'weight'); + u.searchParams.set('extensions', 'all'); + u.searchParams.set('key', cfg.AMAP_KEY); + + const d = await U.fetchJSON(u.toString(), {}, 10000, 1); + if (d.status !== '1') throw new Error(`高德周边检索:${d.info || d.infocode || d.status}`); + + const food = []; + const sights = []; + for (const p of d.pois || []) { + const [plng, plat] = String(p.location || '').split(',').map(Number); + if (!Number.isFinite(plng) || !Number.isFinite(plat)) continue; + const [wlon, wlat] = U.gcj02ToWgs84(plng, plat); + const type = String(p.type || ''); + const biz = p.biz_ext || {}; + const rating = Number(biz.rating); + const cost = Number(biz.cost); + const item = { + name: String(p.name || '').slice(0, 40), + type: type.split(';').pop() || '', + distanceKm: U.round(U.haversine(lat, lon, wlat, wlon), 1), + rating: Number.isFinite(rating) && rating > 0 ? rating : null, + cost: Number.isFinite(cost) && cost > 0 ? Math.round(cost) : null, + address: String(p.address || p.adname || '').slice(0, 50), + // 坐标必须留着:路线图要把方案里点名的地点落到真实位置上 + lat: U.round(wlat, 5), lon: U.round(wlon, 5), + city: String(p.cityname || '').trim(), + district: String(p.adname || '').trim(), + }; + if (!item.name) continue; + if (/餐饮|餐厅|美食|小吃|咖啡|茶/.test(type)) food.push(item); + else sights.push(item); + } + + const dedupe = (arr) => { + const seen = new Set(); + return arr + .filter((x) => (seen.has(x.name) ? false : (seen.add(x.name), true))) + .sort((a, b) => a.distanceKm - b.distanceKm) + .slice(0, limit); + }; + return { food: dedupe(food), sights: dedupe(sights), available: true }; + }, TTL.poi); +} + +/** 可选:高德周边 POI(风景名胜类),需 AMAP_KEY */ +/** + * 高德周边 POI 检索 —— **类型词跨城检索**。 + * + * 为什么不能只查出发地一圈:高德 place/around 的 radius 上限 50km,返回按距离 + * 排序、offset 只有 25 条。以柳州为圆心连查 6 页,150 条结果**全是柳州市区**的 + * 游园、亭子、码头——50km 外一个都搜不到。这不是参数问题,是接口的覆盖边界。 + * + * 改为:走高德 place/text,用一组**目的地类型词**(风景区 / 古镇 / 森林公园…) + * 在省级范围内检索,再按直线距离过滤。实测能拿到阳朔漓江、遇龙河、黄姚古镇、 + * 德天瀑布、乐业天坑、三门海等真正的周边目的地,且每条都带 city / adname, + * 正好可以按区县做「排除同城」判定。 + */ + +/** 目的地类型词:覆盖不同类型的周边目的地,命中面广又不至于太泛 */ +const SIGHT_KEYWORDS = [ + '风景区', '古镇', '森林公园', '地质公园', '湿地公园', + '瀑布', '溶洞', '峡谷', '梯田', '温泉', + '博物馆', '古村落', '山水景区', '草原', '湖', +]; + +/** 高德 place/text:按类型词在给定城市范围内检索景点 */ +async function amapTextSearch(keyword, cityName, cfg, offset = 12) { + const u = new URL(cfg.ENDPOINTS.amapPlaceText); + u.searchParams.set('keywords', keyword); + if (cityName) u.searchParams.set('city', cityName); + u.searchParams.set('citylimit', 'false'); + u.searchParams.set('types', '110200|110100|140100'); + u.searchParams.set('offset', String(offset)); + u.searchParams.set('page', '1'); + u.searchParams.set('extensions', 'base'); + u.searchParams.set('key', cfg.AMAP_KEY); + const d = await U.fetchJSON(u.toString(), {}, 9000, 1); + if (d.status !== '1') return []; + return Array.isArray(d.pois) ? d.pois : []; +} + +/** + * 按名称定位一个地点(路线图用)。 + * + * 方案正文里点名的地点(如「马鞍山马尾坡野生红花林场」)不一定在候选清单里, + * 但要画到地图上就必须有坐标。这里用高德 place/text 按名字检索: + * - city 传目的地所在市/县,citylimit=false,跨县也能命中; + * - 不限定 types,否则会漏掉餐饮、酒店、场馆等非景点类别; + * - 命中多个时,优先取「名字包含查询词」且离参考点最近的,避免定位到同名的外地门店。 + */ +async function amapResolvePlace(name, cityName, cfg, near) { + if (!cfg.AMAP_KEY) return null; + const q = String(name || '').trim(); + if (!q) return null; + // 缓存键必须含参考点坐标:距离护栏与城市加分都依赖它, + // 否则换一个目的地会复用上一个目的地的定位结果 + const nearKey = near && Number.isFinite(Number(near.lat)) && Number.isFinite(Number(near.lon)) + ? `${Number(near.lat).toFixed(2)},${Number(near.lon).toFixed(2)}` : ''; + return C.memo(sk(`amapResolve:${q}:${cityName || ''}:${nearKey}`, cfg), async () => { + const u = new URL(cfg.ENDPOINTS.amapPlaceText); + u.searchParams.set('keywords', q); + if (cityName) u.searchParams.set('city', cityName); + u.searchParams.set('citylimit', 'false'); + u.searchParams.set('offset', '8'); + u.searchParams.set('page', '1'); + u.searchParams.set('extensions', 'base'); + u.searchParams.set('key', cfg.AMAP_KEY); + const d = await U.fetchJSON(u.toString(), {}, 8000, 1); + if (d.status !== '1') return null; + const pois = Array.isArray(d.pois) ? d.pois : []; + if (!pois.length) return null; + const scored = pois.map((p) => { + const [lng, lat] = String(p.location || '').split(',').map(Number); + if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null; + const [wlon, wlat] = U.gcj02ToWgs84(lng, lat); + const pn = String(p.name || ''); + // 名字完全相等 > 包含查询词 > 其余 + let score = pn === q ? 100 : (pn.includes(q) || q.includes(pn)) ? 60 : 20; + // 城市匹配加分:查询「柳州马鞍山马尾坡」时, + // 命中「柳州市」的那条才是对的,高德默认可能返回「马鞍山市」的同名 POI + const pcity = String(p.cityname || '').trim(); + if (cityName && pcity && (pcity === cityName || cityName.includes(pcity) || pcity.includes(cityName))) { + score += 40; + } + let dKm = null; + if (near && Number.isFinite(Number(near.lat)) && Number.isFinite(Number(near.lon))) { + dKm = U.haversine(Number(near.lat), Number(near.lon), wlat, wlon); + score += Math.max(0, 30 - dKm); // 同名时离参考点近的胜出 + } + return { + name: pn, lat: U.round(wlat, 5), lon: U.round(wlon, 5), score, + distanceKm: dKm == null ? null : U.round(dKm, 1), + city: String(p.cityname || '').trim(), + district: String(p.adname || '').trim(), + address: String(p.address || '').slice(0, 60), + source: '高德 POI', + }; + }).filter(Boolean); + if (!scored.length) return null; + scored.sort((a, b) => b.score - a.score); + let best = scored[0]; + // 距离护栏:名称里带地名(如「马鞍山马尾坡野生红花林场」)会让高德匹配到 + // 同名的外地 POI,实测被定位到 1211km 外的安徽马鞍山市。 + // 参考点存在时,超过 150km 一律不认——宁可返回 null 让前端标注「未能定位」, + // 也不能在地图上画一个完全错误的位置。 + if (near && best.distanceKm != null && best.distanceKm > 150) { + const within = scored.find((x) => x.distanceKm != null && x.distanceKm <= 150); + if (!within) return null; + best = within; + } + return best; + }, TTL.geo); +} + +/** 以单点为圆心查高德 POI(补充出发地近处的点位) */ +async function amapAroundOne(lng, lat, radiusM, types, offset, cfg) { + const u = new URL(cfg.ENDPOINTS.amapAround); + u.searchParams.set('location', `${U.round(lng, 6)},${U.round(lat, 6)}`); + u.searchParams.set('radius', String(Math.min(radiusM, 50000))); + u.searchParams.set('types', types); + u.searchParams.set('offset', String(offset)); + u.searchParams.set('page', '1'); + u.searchParams.set('sortrule', 'weight'); + u.searchParams.set('extensions', 'base'); + u.searchParams.set('key', cfg.AMAP_KEY); + const d = await U.fetchJSON(u.toString(), {}, 10000, 1); + if (d.status !== '1') { + // 不静默吞掉:用户刚填的 Key 如果是错的,必须能在界面上看到原因 + const reason = d.info || d.infocode || `status=${d.status}`; + const err = new Error(`高德接口拒绝请求:${reason}`); + err.amapInvalidKey = /INVALID_USER_KEY|USERKEY_PLAT_NOMATCH|DAILY_QUERY_OVER_LIMIT|10001|10002|10003|10009/.test(String(reason)); + throw err; + } + return Array.isArray(d.pois) ? d.pois : []; +} + +/** 高德 POI → 候选对象 */ +function amapPoiToCandidate(p, origin, cfg) { + const [plng, plat] = String(p.location || '').split(',').map(Number); + if (!Number.isFinite(plng) || !Number.isFinite(plat)) return null; + const [wlon, wlat] = U.gcj02ToWgs84(plng, plat); + const name = String(p.name || '').trim(); + if (!name) return null; + // typecode 前 3 位决定大类,用它做 kind,比一律写「景点」准确得多 + const tc = String(p.typecode || ''); + const kind = tc.startsWith('1401') ? '博物馆' + : tc.startsWith('1101') ? '公园' + : tc.startsWith('1103') ? '自然景观' + : tc.startsWith('1102') ? '风景区' : '景点'; + return { + id: `amap-${p.id}`, + name, + kind, + tags: kind === '博物馆' ? ['人文历史', '室内避雨'] + : kind === '公园' ? ['自然风光', '休闲', '亲子'] + : kind === '自然景观' ? ['自然风光', '徒步登山', '拍照'] + : ['人文历史', '拍照', '观光'], + lat: U.round(wlat, 5), lon: U.round(wlon, 5), + // 城市与区县分开存:排除同城要按**区县**判定, + // 因为融水、三江都是「柳州市」下辖的县,它们是目的地不是同城市区 + city: String(p.cityname || '').trim(), + district: String(p.adname || '').trim(), + region: [p.cityname, p.adname].filter(Boolean).join(' · '), + distanceKm: U.haversine(origin.lat, origin.lon, wlat, wlon), + bearingDeg: U.bearing(origin.lat, origin.lon, wlat, wlon), + source: '高德周边检索', + hasExtent: false, + featured: false, + }; +} + +async function amapAround(origin, radiusKm, cfg) { + if (!cfg.AMAP_KEY) return []; + return C.memo(sk(`amapAround:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}:${Math.round(radiusKm)}`, cfg), async () => { + // 检索范围用「省」:出发地所在省内的目的地才是「周边出行」, + // 跨省的通常已超出合理半径,会被距离过滤掉 + const province = cfg.AMAP_PROVINCE || ''; + const raw = []; + + for (const kw of SIGHT_KEYWORDS) { + try { + const pois = await amapTextSearch(kw, province, cfg); + raw.push(...pois); + // 高德 QPS 限制:串行 + 间隔(实测并发 5 就会 CUQPS_HAS_EXCEEDED_THE_LIMIT) + await new Promise((r) => setTimeout(r, 180)); + } catch (e) { + console.warn('[amapAround] keyword', kw, e.message); + } + } + + // 再补一圈出发地近处的 POI:类型词检索偏热门,近处小众点位靠这一圈补 + try { + const [lng, lat] = U.wgs84ToGcj02(origin.lon, origin.lat); + raw.push(...await amapAroundOne(lng, lat, Math.min(Math.round(radiusKm * 1000), 50000), '110200|110101|140100|110300', 25, cfg)); + } catch (e) { + console.warn('[amapAround] home ring:', e.message); + } + + const out = []; + const seen = new Set(); + for (const p of raw) { + const c = amapPoiToCandidate(p, origin, cfg); + if (!c) continue; + if (JUNK_NAME.test(c.name) || POOR_NAME.test(c.name) || LOW_VALUE_NAME.test(c.name)) continue; + // 「某园/某亭/某角/某店」这类是所在地的附属物,不是独立目的地 + if (/(园|亭|角|门|巷|坊|社|所|部|局|铺|店|档|摊|桥|碑|塔)$/.test(c.name)) continue; + // 主题乐园/动物世界这类「XX世界」多为小体量场馆,不是值得跑一趟的目的地 + if (/(蛇世界|欢乐世界|动物大世界|共享农场|奇幻世界|梦幻世界|水上世界|欢乐谷|游乐园)$/.test(c.name)) continue; + if (c.distanceKm > radiusKm * 1.05) continue; + const gk = `${Math.round(c.lat * 100)}_${Math.round(c.lon * 100)}_${c.name}`; + if (seen.has(gk)) continue; + seen.add(gk); + out.push(c); + } + return out; + }, TTL.poi); +} + +/** + * 检索周边目的地(多层来源,任一不可用都不影响出结果): + * Photon 分类检索 → 高德周边 POI(可选) → 内置知名景区 → 内置城市库 → 内置小众地点库 + */ +async function discoverCandidates(origin, radiusKm, cfg, opts = {}) { + // 候选发现缓存键:起点 + 半径 + 排除同城设置。Photon 限流严重,重复分析时必须复用。 + const dkey = `disc:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}:${Math.round(radiusKm)}:${opts.excludeLocal !== false}:${opts.localKm || ''}:${opts.homeDistrict || ''}:${opts.homeCity || ''}`; + return C.memo(sk(dkey, cfg), async () => { + const bbox = U.bboxFromRadius(origin.lat, origin.lon, radiusKm); + const cityBbox = U.bboxFromRadius(origin.lat, origin.lon, Math.min(radiusKm * 1.7, cfg.SEARCH.maxRadiusKm)); + + let amapError = ''; + const [photonResults, amapPois] = await Promise.all([ + U.mapLimit(PHOTON_JOBS, cfg.SEARCH.photonConcurrency, async (job) => { + const features = await photonQuery(job, job.isCity ? cityBbox : bbox, origin, cfg); + return { job, features }; + }), + (async () => { + if (!cfg.AMAP_KEY) return []; + try { + return await amapAround(origin, radiusKm, cfg); + } catch (e) { + amapError = e.message || String(e); + console.warn('[amapAround]', amapError); + return []; + } + })(), + ]); + + const pool = []; + let photonOk = 0; + let categoryHits = 0; + + for (const r of photonResults) { + if (!r) continue; + photonOk += 1; + if (r.features.length) categoryHits += 1; + for (const f of r.features) { + const c = photonCandidate(f, r.job, origin); + if (!c) continue; + if (r.job.isCity) { + // 城市级候选:排除出发地自身的市辖区,只保留一定距离外的城市 + if (c.distanceKm < 35 || /[区县乡镇街道村]$/.test(c.name)) continue; + c.source = 'OpenStreetMap / Photon(城市)'; + } + if (c.distanceKm > radiusKm * 1.05) continue; + pool.push(c); + } + } + for (const c of amapPois || []) pool.push(c); + + // 内置知名目的地:既做兜底,也常年补入若干高辨识度地点 + let curatedCount = 0; + for (const [name, lat, lon, kind, tagStr] of CURATED) { + const d = U.haversine(origin.lat, origin.lon, lat, lon); + if (d > radiusKm) continue; + pool.push({ + id: `curated-${name}`, + name, kind, tags: tagStr.split(','), + lat, lon, region: '', + distanceKm: d, + bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), + source: '内置知名目的地库', + hasExtent: true, + featured: true, + }); + curatedCount += 1; + } + + // 内置城市库:保证全国范围内都有城市级候选 + let cityCount = 0; + for (const [name, lat, lon] of CN_CITIES) { + const d = U.haversine(origin.lat, origin.lon, lat, lon); + if (d < 30 || d > radiusKm) continue; + pool.push({ + id: `city-${name}`, + name, kind: '城市', tags: ['休闲', '人文历史', '美食'], + lat, lon, region: '', + distanceKm: d, + bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), + source: '内置城市库', + hasExtent: false, + featured: false, + }); + cityCount += 1; + } + + // 内置小众地点库:古村、遗址、湿地、地质景观、小馆与徒步线 + // 统一带 niche 标记与「小众秘境」标签,供前端筛选与兴趣匹配 + let nicheCount = 0; + for (const [name, lat, lon, kind, tagStr, region] of NICHE_SPOTS) { + const d = U.haversine(origin.lat, origin.lon, lat, lon); + if (d > radiusKm) continue; + pool.push({ + id: `niche-${name}`, + name, kind, tags: tagStr.split(','), region: region || '', + lat, lon, + distanceKm: d, + bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon), + source: '内置小众地点库', + hasExtent: false, + featured: false, + niche: true, + }); + nicheCount += 1; + } + + // 去重(同名 / 同网格 ~1km) + const seenName = new Set(); + const seenGrid = new Set(); + const unique = []; + for (const c of pool) { + const nk = U.normalizeName(c.name); + const gk = `${Math.round(c.lat * 100)}_${Math.round(c.lon * 100)}`; + if (seenName.has(nk) || seenGrid.has(gk)) continue; + seenName.add(nk); + seenGrid.add(gk); + unique.push(c); + } + + // 排除同城:填了高德 Key 后 POI 里会混入大量本市城区点位。 + // 必须在轮转取样前剔除,否则它们会先占满名额,把真正「周边」的地方挤出去。 + let localExcluded = 0; + let filtered = unique; + if (opts.excludeLocal) { + const localKm = Number(opts.localKm) || 30; + // 「排除同城」的语义:**排除出发地所在的城市/城区本身**, + // 而不是砍掉出发地周围的整个半径。 + // + // 原因:柳州市下辖的融水、三江、鹿寨都在 30–100km,是最该推荐的周边目的地; + // 如果按距离砍,它们会被误杀,清单里只剩市区的游园和亭子。 + // 所以优先按**区县**判定——同区县(市中心城区)才排除,跨县保留。 + const homeDistrict = String(opts.homeDistrict || '').trim(); + const homeCity = String(opts.homeCity || '').trim(); + filtered = unique.filter((c) => { + // 太近的(<8km)一定是城郊/市区内部,不管区县都排除—— + // 「周边出行」不该包含步行 10 分钟就能到的地方 + if (c.distanceKm < 8) { localExcluded += 1; return false; } + // 有区县信息:同区县 → 排除;同市不同县 → 保留(那正是周边目的地) + if (homeDistrict && c.district) { + if (c.district === homeDistrict) { localExcluded += 1; return false; } + return true; + } + // 没有区县信息(内置库/Photon):退回距离判定 + if (c.distanceKm < localKm) { localExcluded += 1; return false; } + return true; + }); + } + + // 同类内按「知名目的地优先 → 有范围轮廓优先 → 距离近优先」排序, + // 再按类别轮转取样,保证清单既多样又不被低质量 OSM 点位占满 + const groups = new Map(); + for (const c of filtered) { + if (!groups.has(c.kind)) groups.set(c.kind, []); + groups.get(c.kind).push(c); + } + const quality = (c) => (c.featured ? 0 : 1000) + (c.hasExtent ? 0 : 100) + c.distanceKm; + for (const list of groups.values()) list.sort((a, b) => quality(a) - quality(b)); + + const cap = cfg.SEARCH.maxCandidates; + const selected = []; + let round = 0; + while (selected.length < cap) { + let added = false; + for (const list of groups.values()) { + if (list[round]) { + selected.push(list[round]); + added = true; + if (selected.length >= cap) break; + } + } + if (!added) break; + round += 1; + } + + // 小众地点配额:轮转取样容易让知名景区占满名额,这里保证至少约三成是冷门目的地 + const nichePool = filtered.filter((c) => c.niche); + const minNiche = Math.min(Math.max(6, Math.round(cap * 0.3)), nichePool.length); + let nicheSelected = selected.filter((c) => c.niche).length; + if (nicheSelected < minNiche) { + const chosen = new Set(selected.map((c) => c.id)); + const extras = nichePool.filter((c) => !chosen.has(c.id)); + for (let i = selected.length - 1; i >= 0 && nicheSelected < minNiche && extras.length; i -= 1) { + if (selected[i].niche) continue; + selected[i] = extras.shift(); + nicheSelected += 1; + } + } + + return { + candidates: selected.map((c, i) => ({ ...c, index: i })), + stats: { + photonOk, categoryHits, + curatedCount, cityCount, nicheCount, + amapCount: (amapPois || []).length, + amapError: amapError || '', + poolSize: unique.length, + localExcluded, + localKm: opts.excludeLocal ? (Number(opts.localKm) || 30) : 0, + requested: PHOTON_JOBS.length, + fallbackOnly: photonOk === 0 && !(amapPois || []).length, + }, + }; + }, TTL.poi); +} + +// ================================================================== 天气 + +const DAILY_FIELDS = 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max'; + +function asArray(data) { + return Array.isArray(data) ? data : [data]; +} + +function buildDaily(one, startISO, endISO) { + const d = one?.daily; + if (!d || !Array.isArray(d.time)) return []; + const out = []; + for (let i = 0; i < d.time.length; i += 1) { + const date = d.time[i]; + if (date < startISO || date > endISO) continue; + const code = Number(d.weather_code?.[i] ?? 0); + const w = wmo(code); + out.push({ + date, + weekday: U.weekdayCN(date), + code, + label: w.label, + icon: w.icon, + severity: w.severity, + tmax: U.round(d.temperature_2m_max?.[i] ?? 0, 1), + tmin: U.round(d.temperature_2m_min?.[i] ?? 0, 1), + precip: U.round(d.precipitation_sum?.[i] ?? 0, 1), + precipProb: U.round(d.precipitation_probability_max?.[i] ?? 0, 0), + wind: U.round(d.wind_speed_10m_max?.[i] ?? 0, 1), + }); + } + return out; +} + +function summarizeWeather(daily) { + if (!daily.length) return null; + const mean = (key) => U.round(daily.reduce((s, x) => s + x[key], 0) / daily.length, 1); + const max = (key) => U.round(Math.max(...daily.map((x) => x[key])), 1); + return { + avgHigh: mean('tmax'), + avgLow: mean('tmin'), + totalPrecip: U.round(daily.reduce((s, x) => s + x.precip, 0), 1), + maxPrecipProb: max('precipProb'), + maxWind: max('wind'), + worstSeverity: Math.max(...daily.map((x) => x.severity)), + dominant: daily.reduce((best, x) => (x.severity > best.severity ? x : best), daily[0]), + badDays: daily.filter((x) => x.severity >= 3 || x.precipProb >= 70 || x.wind >= 40).length, + days: daily.length, + }; +} + +/** + * 批量获取多个坐标在日期区间内的逐日天气。 + * - 16 天内:预报 API(可带 past_days 回溯) + * - 超出 16 天:历史天气 API 取去年同期,标记「历史气候参考」 + * - 很久以前:历史天气 API 取真实日期 + */ +async function fetchWeather(locations, startISO, endISO, cfg) { + // 天气缓存键:所有候选点坐标 + 起止日期(预报模式与日期强相关) + const wkey = `wx:${locations.map((c) => `${U.round(c.lat, 3)},${U.round(c.lon, 3)}`).join(';')}:${startISO}:${endISO}`; + return C.memo(sk(wkey, cfg), async () => { + const today = U.todayISO(); + const endOffset = U.diffDays(today, endISO); + const startOffset = U.diffDays(today, startISO); + let mode = 'forecast'; + let reqStart = startISO; + let reqEnd = endISO; + let note = 'Open-Meteo 实时预报'; + + if (endOffset > 15) { + mode = 'climatology'; + reqStart = U.addYearsISO(startISO, -1); + reqEnd = U.addYearsISO(endISO, -1); + note = `所选日期超出 16 天预报范围,改用去年同期(${reqStart} ~ ${reqEnd})历史气候作参考,非实时预报`; + } else if (startOffset < -92) { + mode = 'archive'; + note = '所选日期较早,使用 Open-Meteo 历史再分析数据'; + } else if (startOffset < 0) { + mode = 'forecast-past'; + note = '包含已过去日期,使用 Open-Meteo 预报回溯(past_days)数据'; + } + + const lats = locations.map((c) => U.round(c.lat, 4)).join(','); + const lons = locations.map((c) => U.round(c.lon, 4)).join(','); + const chunks = U.chunk(locations.map((_, i) => i), 25); + const series = new Array(locations.length).fill(null); + + for (const idxChunk of chunks) { + const subLats = idxChunk.map((i) => U.round(locations[i].lat, 4)).join(','); + const subLons = idxChunk.map((i) => U.round(locations[i].lon, 4)).join(','); + let u; + if (mode === 'climatology' || mode === 'archive') { + u = new URL(cfg.ENDPOINTS.archive); + u.searchParams.set('start_date', reqStart); + u.searchParams.set('end_date', reqEnd); + u.searchParams.set('daily', DAILY_FIELDS.replace(',precipitation_probability_max', '')); + u.searchParams.set('timezone', 'auto'); + } else { + u = new URL(cfg.ENDPOINTS.forecast); + u.searchParams.set('daily', DAILY_FIELDS); + u.searchParams.set('timezone', 'auto'); + u.searchParams.set('forecast_days', '16'); + if (startOffset < 0) u.searchParams.set('past_days', String(Math.min(92, -startOffset))); + } + u.searchParams.set('latitude', subLats); + u.searchParams.set('longitude', subLons); + + let data; + try { + data = await U.fetchJSON(u.toString(), {}, cfg.SEARCH.requestTimeoutMs); + } catch (err) { + console.warn('[weather] batch failed:', err.message); + continue; + } + const arr = asArray(data); + // 预报/历史 API 对单坐标返回对象、对多坐标返回数组;这里按 chunk 顺序对齐 + idxChunk.forEach((globalIdx, localIdx) => { + const one = arr.length === idxChunk.length ? arr[localIdx] : arr[0]; + const daily = buildDaily(one, mode === 'climatology' ? reqStart : startISO, mode === 'climatology' ? reqEnd : endISO); + if (mode === 'climatology') { + // 把去年日期映射回用户所选日期 + const wanted = U.eachDate(startISO, endISO); + daily.forEach((d, i) => { if (wanted[i]) { d.date = wanted[i]; d.weekday = U.weekdayCN(wanted[i]); } }); + } + series[globalIdx] = { daily, summary: summarizeWeather(daily) }; + }); + } + + return { series, mode, note, lats, lons }; + }, TTL.weather); +} + +/** 空气质量(美标 AQI),best-effort,失败返回 null */ +async function fetchAQI(locations, startISO, endISO, cfg) { + const akey = `aqi:${locations.map((c) => `${U.round(c.lat, 3)},${U.round(c.lon, 3)}`).join(';')}:${startISO}:${endISO}`; + return C.memo(sk(akey, cfg), async () => { + const today = U.todayISO(); + if (U.diffDays(today, endISO) > 6 || U.diffDays(today, startISO) < -6) { + return { values: locations.map(() => null), note: '所选日期超出空气质量预报范围,AQI 不可用' }; + } + const values = new Array(locations.length).fill(null); + const chunks = U.chunk(locations.map((_, i) => i), 25); + for (const idxChunk of chunks) { + const u = new URL(cfg.ENDPOINTS.airQuality); + u.searchParams.set('latitude', idxChunk.map((i) => U.round(locations[i].lat, 4)).join(',')); + u.searchParams.set('longitude', idxChunk.map((i) => U.round(locations[i].lon, 4)).join(',')); + u.searchParams.set('hourly', 'us_aqi'); + u.searchParams.set('timezone', 'auto'); + u.searchParams.set('forecast_days', '7'); + try { + const data = await U.fetchJSON(u.toString(), {}, 12000); + const arr = asArray(data); + idxChunk.forEach((gi, li) => { + const one = arr.length === idxChunk.length ? arr[li] : arr[0]; + const times = one?.hourly?.time || []; + const aqis = one?.hourly?.us_aqi || []; + const picks = []; + times.forEach((t, k) => { + const date = String(t).slice(0, 10); + if (date >= startISO && date <= endISO && Number.isFinite(aqis[k])) picks.push(aqis[k]); + }); + if (picks.length) values[gi] = U.round(Math.max(...picks), 0); + }); + } catch (err) { + console.warn('[aqi]', err.message); + } + } + return { values, note: 'Open-Meteo 空气质量预报(美标 AQI,取区间内最高值)' }; + }, TTL.weather); +} + +function aqiLabel(v) { + if (v == null) return null; + if (v <= 50) return { label: '优', level: 'good' }; + if (v <= 100) return { label: '良', level: 'ok' }; + if (v <= 150) return { label: '轻度污染', level: 'warn' }; + if (v <= 200) return { label: '中度污染', level: 'warn' }; + if (v <= 300) return { label: '重度污染', level: 'bad' }; + return { label: '严重污染', level: 'bad' }; +} + +// ================================================================== 公路 + +function estimateRoad(origin, dest) { + const straight = U.haversine(origin.lat, origin.lon, dest.lat, dest.lon); + const distanceKm = U.round(straight * 1.35, 1); + const durationMinutes = U.round((distanceKm / 62) * 60 + 12, 0); + return { durationMinutes, distanceKm, source: '估算', note: '按直线距离×1.35、均速 62km/h 估算,路网服务不可用时兜底' }; +} + +async function amapRoad(origin, dest, cfg) { + const [olng, olat] = U.wgs84ToGcj02(origin.lon, origin.lat); + const [dlng, dlat] = U.wgs84ToGcj02(dest.lon, dest.lat); + const u = new URL(cfg.ENDPOINTS.amapDriving); + u.searchParams.set('origin', `${U.round(olng, 6)},${U.round(olat, 6)}`); + u.searchParams.set('destination', `${U.round(dlng, 6)},${U.round(dlat, 6)}`); + u.searchParams.set('extensions', 'base'); + u.searchParams.set('output', 'json'); + u.searchParams.set('key', cfg.AMAP_KEY); + const d = await U.fetchJSON(u.toString(), {}, 10000); + if (d.status !== '1' || !d.route?.paths?.[0]) throw new Error(d.info || '高德无结果'); + const p = d.route.paths[0]; + return { + durationMinutes: U.round(Number(p.duration) / 60, 0), + distanceKm: U.round(Number(p.distance) / 1000, 1), + source: '高德实时路网', + note: '高德 Web 服务驾车路径规划(已做 WGS-84→GCJ-02 坐标转换)', + }; +} + +async function roadMatrix(origin, destinations, cfg) { + // 路网缓存键:起点 + 全部终点坐标。路况按小时更新,3 小时 TTL。 + const rkey = `road:${U.round(origin.lat, 4)},${U.round(origin.lon, 4)}:${destinations.map((d) => `${U.round(d.lat, 4)},${U.round(d.lon, 4)}`).join(';')}`; + return C.memo(sk(rkey, cfg), async () => { + const out = new Array(destinations.length).fill(null); + + if (cfg.AMAP_KEY) { + const res = await U.mapLimit(destinations, 5, (d) => U.attempt('amapRoad', () => amapRoad(origin, d, cfg), null)); + res.forEach((r, i) => { out[i] = r; }); + } + + // 没配高德、或高德部分/全部失败时,用 OSRM 补齐缺口—— + // 而不是因为"配了一个无效 Key"就直接退化成纯估算 + const missing = []; + out.forEach((r, i) => { if (!r) missing.push(i); }); + if (missing.length) { + const targets = missing.map((i) => destinations[i]); + const coords = [origin, ...targets] + .map((c) => `${U.round(c.lon, 5)},${U.round(c.lat, 5)}`) + .join(';'); + const u = `${cfg.ENDPOINTS.osrmTable}/${coords}?sources=0&annotations=duration,distance`; + try { + const d = await U.fetchJSON(u, {}, cfg.SEARCH.osrmTimeoutMs); + if (d.code !== 'Ok') throw new Error(`OSRM ${d.code}`); + const durations = d.durations?.[0] || []; + const distances = d.distances?.[0] || []; + missing.forEach((idx, k) => { + const sec = durations[k + 1]; + const meter = distances[k + 1]; + if (Number.isFinite(sec) && Number.isFinite(meter)) { + out[idx] = { + durationMinutes: U.round(sec / 60, 0), + distanceKm: U.round(meter / 1000, 1), + source: 'OSRM 路网', + note: 'OSRM 公开路网服务计算的驾车时长与里程', + }; + } + }); + } catch (err) { + console.warn('[road] OSRM failed:', err.message); + } + } + + for (let i = 0; i < out.length; i += 1) { + if (!out[i]) out[i] = estimateRoad(origin, destinations[i]); + } + + // provider 必须反映「实际拿到的数据」,不能只看有没有配 Key + const hasAmap = out.some((r) => /高德/.test(r.source)); + const hasOsrm = out.some((r) => r.source === 'OSRM 路网'); + return { + matrix: out, + provider: hasAmap ? '高德' : (hasOsrm ? 'OSRM' : '估算'), + }; + }, TTL.road); +} + +// ================================================================== 铁路 + +function estimateRail(origin, dest) { + const straight = U.haversine(origin.lat, origin.lon, dest.lat, dest.lon); + if (straight < 25) { + return { + available: false, durationMinutes: null, distanceKm: U.round(straight, 1), + fareCny: null, source: '估算', + note: '距离过近,通常无城际铁路直达,建议自驾/市内公共交通;末端接驳未计入', + }; + } + const railKm = U.round(straight * 1.18, 1); + const speed = straight > 400 ? 250 : straight > 150 ? 200 : straight > 70 ? 120 : 75; + const overhead = straight > 150 ? 55 : 40; + const durationMinutes = U.round((railKm / speed) * 60 + overhead, 0); + return { + available: true, + durationMinutes, + distanceKm: railKm, + fareCny: U.round(railKm * 0.46, 0), + source: '估算', + note: '按里程×1.18、分档均速 + 进出站候车时间估算,非真实车次;车次/余票请在 12306 核对,景区末端接驳另计', + }; +} + +async function railFor(origin, dest, dateISO, cfg) { + if (!cfg.RAIL_API_URL) return estimateRail(origin, dest); + const url = cfg.RAIL_API_URL + .replace('{fromLat}', origin.lat).replace('{fromLon}', origin.lon) + .replace('{toLat}', dest.lat).replace('{toLon}', dest.lon) + .replace('{date}', dateISO) + .replace('{fromName}', encodeURIComponent(origin.name || '')) + .replace('{toName}', encodeURIComponent(dest.name || '')); + try { + const d = await U.fetchJSON(url, { + headers: cfg.RAIL_API_KEY ? { Authorization: `Bearer ${cfg.RAIL_API_KEY}`, 'X-Api-Key': cfg.RAIL_API_KEY } : {}, + }, 10000); + if (typeof d.durationMinutes !== 'number') throw new Error('铁路接口返回缺少 durationMinutes'); + return { + available: d.available !== false, + durationMinutes: U.round(d.durationMinutes, 0), + distanceKm: U.round(d.distanceKm ?? 0, 1), + fareCny: d.fareCny == null ? null : U.round(d.fareCny, 0), + source: '铁路接口', + note: d.note || '来自配置的铁路接口', + }; + } catch (err) { + console.warn('[rail] custom API failed:', err.message); + return { ...estimateRail(origin, dest), note: `铁路接口调用失败,已降级为估算:${err.message}` }; + } +} + +async function railMatrix(origin, destinations, dateISO, cfg) { + const key = `rail:${U.round(origin.lat, 4)},${U.round(origin.lon, 4)}:${destinations.map((d) => `${U.round(d.lat, 4)},${U.round(d.lon, 4)}`).join(';')}:${dateISO}`; + return C.memo(sk(key, cfg), async () => { + const res = await U.mapLimit(destinations, 6, (d) => railFor(origin, d, dateISO, cfg)); + return res.map((r, i) => r || estimateRail(origin, destinations[i])); + }, TTL.road); +} + +/** 在 16 天预报里找一段天气最舒服的连续窗口,用于给表单推荐默认日期 */ +async function bestWeatherWindow(lat, lon, days, cfg) { + const u = new URL(cfg.ENDPOINTS.forecast); + u.searchParams.set('latitude', String(U.round(lat, 4))); + u.searchParams.set('longitude', String(U.round(lon, 4))); + u.searchParams.set('daily', 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max'); + u.searchParams.set('timezone', 'auto'); + u.searchParams.set('forecast_days', '16'); + const d = await U.fetchJSON(u.toString(), {}, 10000); + const daily = d.daily; + if (!daily?.time?.length) return null; + const n = Math.min(Math.max(1, days), 5, daily.time.length); + let best = null; + for (let i = 0; i + n <= daily.time.length; i += 1) { + let score = 0; + const labels = []; + for (let k = 0; k < n; k += 1) { + const w = wmo(daily.weather_code?.[i + k] ?? 0); + const prob = daily.precipitation_probability_max?.[i + k] ?? 0; + const wind = daily.wind_speed_10m_max?.[i + k] ?? 0; + const tmax = daily.temperature_2m_max?.[i + k] ?? 20; + score -= prob * 0.45 + w.severity * 9 + Math.max(0, wind - 30) * 0.35 + + Math.max(0, tmax - 30) * 1.6 + Math.max(0, 8 - tmax) * 1.1; + const wd = new Date(`${daily.time[i + k]}T00:00:00Z`).getUTCDay(); + if (wd === 0 || wd === 6) score += 7; // 周末轻微加成 + labels.push(w.label); + } + if (!best || score > best.score) { + best = { score: U.round(score, 1), start: daily.time[i], end: daily.time[i + n - 1], labels }; + } + } + if (!best) return null; + return { + ...best, + note: `未来 16 天里天气最平稳的 ${n} 天窗口(${[...new Set(best.labels)].join('、')}),可点击应用`, + }; +} + +module.exports = { + geocode, amapGeocode, amapHotels, mergeHotelPrices, staticMapUrl, staticMapMeta, amapNearbyPois, + reverseGeocode, amapReverse, discoverCandidates, bestWeatherWindow, + fetchWeather, fetchAQI, aqiLabel, + roadMatrix, railMatrix, estimateRoad, estimateRail, + amapResolvePlace, +}; diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..347d037 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,214 @@ +'use strict'; + +// ------------------------------------------------------------------ 通用工具 + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +const round = (v, n = 0) => { + const p = 10 ** n; + return Math.round((Number(v) || 0) * p) / p; +}; + +/** 带超时的 fetch,返回原始 Response */ +async function fetchWithTimeout(url, opts = {}, timeoutMs = 15000) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + return await fetch(url, { ...opts, signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } +} + +async function fetchJSON(url, opts = {}, timeoutMs = 15000, retries = 2) { + let lastErr; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const res = await fetchWithTimeout(url, opts, timeoutMs); + if (!res.ok) { + const text = await res.text().catch(() => ''); + // 4xx 是确定性错误,不必重试 + if (res.status >= 400 && res.status < 500) { + throw new Error(`HTTP ${res.status} ${res.statusText} ${text.slice(0, 160)}`); + } + throw new Error(`HTTP ${res.status} ${res.statusText} ${text.slice(0, 160)}`); + } + return await res.json(); + } catch (err) { + lastErr = err; + if (attempt < retries) await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); + } + } + throw lastErr; +} + +/** 并发限制的 map,失败不中断整体 */ +async function mapLimit(items, limit, fn) { + const out = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (true) { + const i = cursor++; + if (i >= items.length) return; + try { + out[i] = await fn(items[i], i); + } catch (err) { + out[i] = null; + console.warn(`[mapLimit] ${i} failed: ${err.message}`); + } + } + }); + await Promise.all(workers); + return out; +} + +/** 安全地尝试,失败返回 fallback */ +async function attempt(label, fn, fallback) { + try { + return await fn(); + } catch (err) { + console.warn(`[${label}] ${err.message}`); + return typeof fallback === 'function' ? fallback(err) : fallback; + } +} + +// ------------------------------------------------------------------ 地理计算 + +const R_EARTH = 6371; +const toRad = (d) => (d * Math.PI) / 180; + +function haversine(lat1, lon1, lat2, lon2) { + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * R_EARTH * Math.asin(Math.sqrt(a)); +} + +/** 方位角:0 = 正北,顺时针递增 */ +function bearing(lat1, lon1, lat2, lon2) { + const p1 = toRad(lat1); + const p2 = toRad(lat2); + const dl = toRad(lon2 - lon1); + const y = Math.sin(dl) * Math.cos(p2); + const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl); + return (Math.atan2(y, x) * 180) / Math.PI; +} + +const COMPASS_8 = ['北', '东北', '东', '东南', '南', '西南', '西', '西北']; +function compassLabel(deg) { + return COMPASS_8[Math.round((((deg % 360) + 360) % 360) / 45) % 8]; +} + +/** WGS-84 -> GCJ-02(国内高德/腾讯地图需要火星坐标) */ +function wgs84ToGcj02(lng, lat) { + const PI = Math.PI; + const A = 6378245.0; + const EE = 0.00669342162296594323; + const outOfChina = (l, b) => l < 73.66 || l > 135.05 || b < 3.86 || b > 53.55; + if (outOfChina(lng, lat)) return [lng, lat]; + const transformLat = (x, y) => { + let r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); + r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3; + r += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3; + r += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3; + return r; + }; + const transformLon = (x, y) => { + let r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); + r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3; + r += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3; + r += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3; + return r; + }; + let dLat = transformLat(lng - 105, lat - 35); + let dLon = transformLon(lng - 105, lat - 35); + const radLat = (lat / 180) * PI; + let magic = Math.sin(radLat); + magic = 1 - EE * magic * magic; + const sqrtMagic = Math.sqrt(magic); + dLat = (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); + dLon = (dLon * 180) / (A / sqrtMagic * Math.cos(radLat) * PI); + return [lng + dLon, lat + dLat]; +} + +/** GCJ-02 -> WGS-84(迭代逼近,误差 < 1m),用于把高德结果转回通用坐标 */ +function gcj02ToWgs84(lng, lat) { + let wLng = lng; + let wLat = lat; + for (let i = 0; i < 5; i += 1) { + const [gLng, gLat] = wgs84ToGcj02(wLng, wLat); + wLng += lng - gLng; + wLat += lat - gLat; + } + return [wLng, wLat]; +} + +/** 由中心点与公里半径得到 bbox:minLon,minLat,maxLon,maxLat */ +function bboxFromRadius(lat, lon, radiusKm) { + const dLat = radiusKm / 111.32; + const cos = Math.max(0.2, Math.cos(toRad(lat))); + const dLon = radiusKm / (111.32 * cos); + return [lon - dLon, lat - dLat, lon + dLon, lat + dLat].map((v) => round(v, 4)); +} + +// -------------------------------------------------------------- 日期工具 + +function todayISO() { + const d = new Date(); + return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10); +} +function isoToUTC(iso) { + const [y, m, d] = iso.split('-').map(Number); + return Date.UTC(y, m - 1, d); +} +function utcToISO(ms) { + return new Date(ms).toISOString().slice(0, 10); +} +function addDaysISO(iso, n) { + return utcToISO(isoToUTC(iso) + n * 86400000); +} +function addYearsISO(iso, n) { + const [y, m, d] = iso.split('-').map(Number); + // 处理 2/29 + const day = m === 2 && d === 29 ? 28 : d; + return `${y + n}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} +function diffDays(aISO, bISO) { + return Math.round((isoToUTC(bISO) - isoToUTC(aISO)) / 86400000); +} +function eachDate(startISO, endISO, cap = 31) { + const out = []; + const n = Math.min(cap, Math.max(0, diffDays(startISO, endISO)) + 1); + for (let i = 0; i < n; i += 1) out.push(addDaysISO(startISO, i)); + return out; +} +function weekdayCN(iso) { + return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date(isoToUTC(iso)).getUTCDay()]; +} +function isValidISO(s) { + return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(isoToUTC(s)); +} +function chunk(arr, size) { + const out = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +// ------------------------------------------------------------------- 其它 + +function normalizeName(s) { + return String(s || '').replace(/[\s·・()()\[\]【】"'"']/g, '').toLowerCase(); +} +function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +module.exports = { + clamp, round, fetchWithTimeout, fetchJSON, mapLimit, attempt, + haversine, bearing, compassLabel, bboxFromRadius, wgs84ToGcj02, gcj02ToWgs84, + todayISO, isoToUTC, utcToISO, addDaysISO, addYearsISO, diffDays, eachDate, + weekdayCN, isValidISO, chunk, normalizeName, esc, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..43de9e4 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "tripsense", + "version": "1.0.0", + "private": true, + "description": "行知 · 周边出行顾问:按时间与所在地分析周边适合出行的目的地,并预留 OpenAI 兼容接口生成行程规划", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": {} +} diff --git a/preview/preview-ai-test.png b/preview/preview-ai-test.png new file mode 100644 index 0000000..a854fc0 Binary files /dev/null and b/preview/preview-ai-test.png differ diff --git a/preview/preview-amap-error.png b/preview/preview-amap-error.png new file mode 100644 index 0000000..91dc945 Binary files /dev/null and b/preview/preview-amap-error.png differ diff --git a/preview/preview-amap-test.png b/preview/preview-amap-test.png new file mode 100644 index 0000000..8465cd7 Binary files /dev/null and b/preview/preview-amap-test.png differ diff --git a/preview/preview-auto-retry.png b/preview/preview-auto-retry.png new file mode 100644 index 0000000..9c02d99 Binary files /dev/null and b/preview/preview-auto-retry.png differ diff --git a/preview/preview-cache.png b/preview/preview-cache.png new file mode 100644 index 0000000..09d9c8a Binary files /dev/null and b/preview/preview-cache.png differ diff --git a/preview/preview-compare.png b/preview/preview-compare.png new file mode 100644 index 0000000..ca56bee Binary files /dev/null and b/preview/preview-compare.png differ diff --git a/preview/preview-ctx-token.png b/preview/preview-ctx-token.png new file mode 100644 index 0000000..d4073f5 Binary files /dev/null and b/preview/preview-ctx-token.png differ diff --git a/preview/preview-diag-hotel.png b/preview/preview-diag-hotel.png new file mode 100644 index 0000000..d14f186 Binary files /dev/null and b/preview/preview-diag-hotel.png differ diff --git a/preview/preview-diag-route.png b/preview/preview-diag-route.png new file mode 100644 index 0000000..75c9f4e Binary files /dev/null and b/preview/preview-diag-route.png differ diff --git a/preview/preview-diag.png b/preview/preview-diag.png new file mode 100644 index 0000000..425a610 Binary files /dev/null and b/preview/preview-diag.png differ diff --git a/preview/preview-followup.png b/preview/preview-followup.png new file mode 100644 index 0000000..9f0c996 Binary files /dev/null and b/preview/preview-followup.png differ diff --git a/preview/preview-geo-blocked.png b/preview/preview-geo-blocked.png new file mode 100644 index 0000000..3f8de22 Binary files /dev/null and b/preview/preview-geo-blocked.png differ diff --git a/preview/preview-hotel-provider.png b/preview/preview-hotel-provider.png new file mode 100644 index 0000000..bc121c1 Binary files /dev/null and b/preview/preview-hotel-provider.png differ diff --git a/preview/preview-lan.png b/preview/preview-lan.png new file mode 100644 index 0000000..23e98a5 Binary files /dev/null and b/preview/preview-lan.png differ diff --git a/preview/preview-localhost.png b/preview/preview-localhost.png new file mode 100644 index 0000000..66ebc1f Binary files /dev/null and b/preview/preview-localhost.png differ diff --git a/preview/preview-map-fallback.png b/preview/preview-map-fallback.png new file mode 100644 index 0000000..faf2fc5 Binary files /dev/null and b/preview/preview-map-fallback.png differ diff --git a/preview/preview-map-overlay.png b/preview/preview-map-overlay.png new file mode 100644 index 0000000..acf3688 Binary files /dev/null and b/preview/preview-map-overlay.png differ diff --git a/preview/preview-map-price.png b/preview/preview-map-price.png new file mode 100644 index 0000000..4609542 Binary files /dev/null and b/preview/preview-map-price.png differ diff --git a/preview/preview-mobile-suggest.png b/preview/preview-mobile-suggest.png new file mode 100644 index 0000000..d6eb76d Binary files /dev/null and b/preview/preview-mobile-suggest.png differ diff --git a/preview/preview-route-multi.png b/preview/preview-route-multi.png new file mode 100644 index 0000000..9bca328 Binary files /dev/null and b/preview/preview-route-multi.png differ diff --git a/preview/preview-route-single.png b/preview/preview-route-single.png new file mode 100644 index 0000000..c6086c5 Binary files /dev/null and b/preview/preview-route-single.png differ diff --git a/preview/preview-settings.png b/preview/preview-settings.png new file mode 100644 index 0000000..db97907 Binary files /dev/null and b/preview/preview-settings.png differ diff --git a/preview/preview-simple-step1.png b/preview/preview-simple-step1.png new file mode 100644 index 0000000..cf83431 Binary files /dev/null and b/preview/preview-simple-step1.png differ diff --git a/preview/preview-simple-step3.png b/preview/preview-simple-step3.png new file mode 100644 index 0000000..47db6c8 Binary files /dev/null and b/preview/preview-simple-step3.png differ diff --git a/preview/preview-stay-real.png b/preview/preview-stay-real.png new file mode 100644 index 0000000..d1fe606 Binary files /dev/null and b/preview/preview-stay-real.png differ diff --git a/preview/preview-step1.png b/preview/preview-step1.png new file mode 100644 index 0000000..1e758a0 Binary files /dev/null and b/preview/preview-step1.png differ diff --git a/preview/preview-step2-detail.png b/preview/preview-step2-detail.png new file mode 100644 index 0000000..769226a Binary files /dev/null and b/preview/preview-step2-detail.png differ diff --git a/preview/preview-step2.png b/preview/preview-step2.png new file mode 100644 index 0000000..f651c62 Binary files /dev/null and b/preview/preview-step2.png differ diff --git a/preview/preview-step3.png b/preview/preview-step3.png new file mode 100644 index 0000000..9f9833e Binary files /dev/null and b/preview/preview-step3.png differ diff --git a/preview/route-map.png b/preview/route-map.png new file mode 100644 index 0000000..3eb81e0 Binary files /dev/null and b/preview/route-map.png differ diff --git a/preview/route-plan.png b/preview/route-plan.png new file mode 100644 index 0000000..3e1719f Binary files /dev/null and b/preview/route-plan.png differ diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..262697f --- /dev/null +++ b/public/app.js @@ -0,0 +1,2025 @@ +'use strict'; + +/* ================================================================== + 行知 · TripSense 前端逻辑 + 三步流程:出行条件 → 备选清单 → AI 行程规划 + ================================================================== */ + +const $ = (s, root = document) => root.querySelector(s); +const $$ = (s, root = document) => Array.from(root.querySelectorAll(s)); + +const TRANSPORTS = ['不限', '自驾', '高铁/铁路', '公共交通']; +const INTERESTS = ['自然风光', '人文历史', '亲子', '拍照', '美食', '徒步登山', '室内避雨', '亲水海滨', '古镇', '主题乐园', '休闲', '小众秘境']; +// 住宿价位:既用于筛酒店 POI,也随需求传给模型 +const LODGING = [ + { key: 'any', label: '不限', min: null, max: null }, + { key: 'budget', label: '≤¥300', min: 0, max: 300 }, + { key: 'comfort', label: '¥300–600', min: 300, max: 600 }, + { key: 'premium', label: '¥600–1200', min: 600, max: 1200 }, + { key: 'luxury', label: '¥1200+', min: 1200, max: null }, + { key: 'custom', label: '自定义', min: null, max: null }, +]; + +/** 当前生效的价位:选「自定义」时读用户输入的上下限,而不是写死档位 */ +function currentLodging() { + const base = LODGING.find((l) => l.key === state.lodging) || LODGING[0]; + if (base.key !== 'custom') return base; + const raw = state.lodgingCustom || {}; + const min = Number.isFinite(Number(raw.min)) && raw.min !== '' && raw.min != null ? Number(raw.min) : null; + const max = Number.isFinite(Number(raw.max)) && raw.max !== '' && raw.max != null ? Number(raw.max) : null; + const lo = min != null && max != null ? Math.min(min, max) : min; + const hi = min != null && max != null ? Math.max(min, max) : max; + const label = lo == null && hi == null + ? '自定义(未填)' + : `¥${lo == null ? '不限' : lo}–${hi == null ? '不限' : hi}`; + return { key: 'custom', label, min: lo, max: hi }; +} +const QUICK_NEEDS = [ + '带老人/小孩同行', '预算 1000 元以内', '不想走太多路', '避开雨天 / 要室内备选', + '不自驾,优先高铁', '想吃当地特色', '拍照出片优先', '两天一晚', '当天往返', '带宠物', +]; +const AI_PRESETS = [ + { name: 'OpenAI', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini' }, + { name: 'DeepSeek', base: 'https://api.deepseek.com/v1', model: 'deepseek-chat' }, + { name: '通义千问', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus' }, + { name: 'Kimi', base: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k' }, + { name: '智谱 GLM', base: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' }, + { name: '本地 Ollama', base: 'http://localhost:11434/v1', model: 'qwen2.5:7b' }, +]; +const VERDICT_CLASS = { '强烈推荐': 'v1', '推荐': 'v2', '可考虑': 'v3', '不推荐': 'v4' }; + +function loadAI() { + const empty = { baseUrl: '', apiKey: '', model: '', forceDemo: false, amapKey: '' }; + try { + return { ...empty, ...(JSON.parse(localStorage.getItem('tripsense.ai') || 'null') || {}) }; + } catch { + return empty; + } +} +function saveAI() { localStorage.setItem('tripsense.ai', JSON.stringify(state.ai)); } + +const state = { + config: null, + origin: null, + transport: new Set(['自驾', '高铁/铁路']), + interests: new Set(['自然风光', '拍照']), + analysis: null, + selected: new Set(), + sort: 'score', + onlyRec: false, + onlyNiche: false, + compareOpen: false, + autoPreselected: false, + datesTouched: false, + ai: loadAI(), + planText: '', + planning: false, + ctxInfo: '', + planVersions: [], + viewing: null, + hotels: [], + // 方案里点名的周边地点(餐饮/景点),带坐标,用于画方案的真实线路 + nearby: null, + // 方案线路点:{ name, lat, lon, kind, source } + routePoints: null, + // 静态地图的 blob URL。必须在这里声明:drawRouteMap 里会读它, + // 未声明的变量在 'use strict' 下会抛 ReferenceError, + // 而 drawRouteMap 是 async 且调用处不 catch → 整个函数静默失败,路线图一片空白。 + routeMapBlob: null, + lodging: 'any', + lodgingCustom: { min: '', max: '' }, +}; + +// ------------------------------------------------------------------ utils +function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} +let toastTimer; +function toast(msg, type = '') { + const el = $('#toast'); + el.textContent = msg; + el.className = `toast ${type}`; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.add('hidden'), 3200); +} +function fmtMin(min) { + if (min == null || !Number.isFinite(min)) return '—'; + const h = Math.floor(min / 60); + const m = Math.round(min % 60); + return h ? `${h}小时${m ? m + '分' : ''}` : `${m}分钟`; +} +function fmtKm(km) { return Number(km || 0) >= 10 ? `${Math.round(km)}km` : `${(Number(km) || 0).toFixed(1)}km`; } +function compassCN(deg) { + const arr = ['北', '东北', '东', '东南', '南', '西南', '西', '西北']; + const d = ((Number(deg) || 0) % 360 + 360) % 360; + return arr[Math.round(d / 45) % 8]; +} +function scoreColor(score) { + return score >= 60 ? '#4c6b4f' : score >= 45 ? '#9a7b32' : '#a53b33'; +} +function addDays(iso, n) { + const d = new Date(`${iso}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +} +function localToday() { + const d = new Date(); + return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10); +} + +/** POST + SSE 读取 */ +async function ssePost(url, body, headers, onEvent) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`HTTP ${res.status} ${await res.text().then((t) => t.slice(0, 160)).catch(() => '')}`); + const reader = res.body.getReader(); + const dec = new TextDecoder('utf-8'); + let buf = ''; + const handle = (line) => { + const s = line.trim(); + if (!s.startsWith('data:')) return; + const raw = s.slice(5).trim(); + if (!raw) return; + try { onEvent(JSON.parse(raw)); } catch { /* 忽略非法片段 */ } + }; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n'); + buf = parts.pop() || ''; + parts.forEach(handle); + } + if (buf.trim()) handle(buf); +} + +// ------------------------------------------------------- markdown renderer +function mdInline(s) { + return s + .replace(/`([^`]+)`/g, (m, p) => `${p}`) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2') + .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)\s]+)\)/g, + '$1'); +} +function mdToHtml(src) { + const lines = String(src || '').replace(/\r/g, '').split('\n'); + const out = []; + let para = []; + let i = 0; + const flush = () => { if (para.length) { out.push(`

${mdInline(para.join('
'))}

`); para = []; } }; + const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((x) => mdInline(esc(x.trim()))); + while (i < lines.length) { + const t = lines[i].trim(); + if (!t) { flush(); i += 1; continue; } + if (/^```/.test(t)) { + flush(); const buf = []; i += 1; + while (i < lines.length && !/^```/.test(lines[i].trim())) { buf.push(esc(lines[i])); i += 1; } + i += 1; + out.push(`
${buf.join('\n')}
`); + continue; + } + const h = t.match(/^(#{1,4})\s+(.*)$/); + if (h) { flush(); out.push(`${mdInline(esc(h[2]))}`); i += 1; continue; } + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) { flush(); out.push('
'); i += 1; continue; } + if (/^\|/.test(t) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1].trim())) { + flush(); + const head = cells(lines[i]); + i += 2; + const body = []; + while (i < lines.length && /^\|/.test(lines[i].trim())) { body.push(cells(lines[i])); i += 1; } + out.push(`${head.map((c) => ``).join('')}${body.map((r) => `${r.map((c) => ``).join('')}`).join('')}
${c}
${c}
`); + continue; + } + if (/^>\s?/.test(t)) { + flush(); const buf = []; + while (i < lines.length && /^>\s?/.test(lines[i].trim())) { + buf.push(mdInline(esc(lines[i].trim().replace(/^>\s?/, '')))); i += 1; + } + out.push(`

${buf.join('
')}

`); + continue; + } + if (/^([-*+]|\d+\.)\s+/.test(t)) { + flush(); + const ordered = /^\d+\./.test(t); + const tag = ordered ? 'ol' : 'ul'; + const buf = []; + while (i < lines.length && /^([-*+]|\d+\.)\s+/.test(lines[i].trim())) { + buf.push(`
  • ${mdInline(esc(lines[i].trim().replace(/^([-*+]|\d+\.)\s+/, '')))}
  • `); i += 1; + } + out.push(`<${tag}>${buf.join('')}`); + continue; + } + para.push(esc(t)); i += 1; + } + flush(); + return out.join('\n'); +} + +// ------------------------------------------------------------------ chips +function renderChips(el, items, set, onChange) { + el.innerHTML = items.map((x) => ``).join(''); + el.querySelectorAll('.chip').forEach((b) => { + b.addEventListener('click', () => onChange(b.dataset.v)); + }); +} + +/** 住宿价位(单选):既筛酒店 POI,也作为约束传给模型 */ +function renderLodging() { + const box = $('#lodgingChips'); + if (!box) return; + box.innerHTML = LODGING.map((l) => + `` + ).join(''); + box.querySelectorAll('.chip').forEach((b) => b.addEventListener('click', () => { + if (state.lodging === b.dataset.k) return; + state.lodging = b.dataset.k; + renderLodging(); + saveForm(); + // 方案已经生成过,就按新价位重查一次住宿 + if (state.planText && $('#planExtra') && !$('#planExtra').classList.contains('hidden')) loadStayPlan(); + })); + // 自定义价位:只有选中「自定义」才展开两个输入框 + const cust = $('#lodgingCustom'); + if (cust) { + cust.classList.toggle('hidden', state.lodging !== 'custom'); + const c = state.lodgingCustom || {}; + if ($('#lodgingMin') && document.activeElement !== $('#lodgingMin')) $('#lodgingMin').value = c.min ?? ''; + if ($('#lodgingMax') && document.activeElement !== $('#lodgingMax')) $('#lodgingMax').value = c.max ?? ''; + } + const tip = $('#lodgingTip'); + if (tip) { + const L = currentLodging(); + tip.textContent = L.key === 'custom' && L.min == null && L.max == null + ? '填上你能接受的每晚房价区间,会同时用于筛选住宿与方案里的预算' + : `当前:每晚 ${L.label === '不限' ? '不限价位' : L.label}`; + } +} + +/** 自定义价位的输入:只更新状态,不重渲染 chips,避免打字时被覆盖 */ +let lodgingTimer = null; +function onLodgingInput() { + state.lodging = 'custom'; + state.lodgingCustom = { + min: $('#lodgingMin').value === '' ? '' : Number($('#lodgingMin').value), + max: $('#lodgingMax').value === '' ? '' : Number($('#lodgingMax').value), + }; + const tip = $('#lodgingTip'); + if (tip) { + const L = currentLodging(); + tip.textContent = L.min == null && L.max == null + ? '填上你能接受的每晚房价区间,会同时用于筛选住宿与方案里的预算' + : `当前:每晚 ${L.label}`; + } + saveFormSoon(); + // 打完字再重查,别每敲一个数字就发一次请求 + clearTimeout(lodgingTimer); + lodgingTimer = setTimeout(() => { + if (state.planText && $('#planExtra') && !$('#planExtra').classList.contains('hidden')) loadStayPlan(); + }, 700); +} + +// ------------------------------------------------------------ 步骤与进度 +function gotoStep(n) { + $$('.step-panel').forEach((p, i) => p.classList.toggle('active', i === n - 1)); + $$('#stepper li').forEach((li, i) => { + li.classList.toggle('active', i === n - 1); + li.classList.toggle('done', i < n - 1); + }); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} +const STAGES = [ + ['origin', '解析出发地'], ['discover', '检索周边目的地'], ['weather', '获取天气 / 空气'], + ['traffic', '计算公路 / 铁路'], ['score', '综合评分'], ['done', '完成'], +]; +function setProgress(pct, text, stage) { + $('#progressBar').style.width = `${pct}%`; + $('#progressPct').textContent = `${pct}%`; + $('#progressText').textContent = text; + const idx = STAGES.findIndex((s) => s[0] === stage); + $('#progressStages').innerHTML = STAGES.map((s, k) => { + const cls = idx < 0 ? '' : k < idx ? 'done' : k === idx ? 'now' : ''; + return `${k < idx ? '✓' : '·'} ${s[1]}`; + }).join(''); +} + +// ------------------------------------------------------------- 出发地联想 +let suggestTimer = null; +let suggestResults = []; +function hideSuggest() { $('#suggestBox').classList.add('hidden'); } +function showSuggest(list, query = '', usedQuery = '', meta = {}) { + suggestResults = list; + const box = $('#suggestBox'); + if (!list.length) { + // 以前这里是静默隐藏,用户输入查不到的词就完全没反馈,看起来像"功能坏了" + let reason = '试试只输入城市名(如「杭州」)或景点名(如「西湖」)。'; + if (meta.amapError && /INVALID_USER_KEY|USERKEY_PLAT_NOMATCH|10001|10009/i.test(meta.amapError)) { + reason = `高德 Key 似乎无效(${esc(meta.amapError)}),请到「设置 → 数据源增强」检查。`; + } else if (meta.meteoError && /timeout|ETIMEDOUT|fetch failed|abort/i.test(meta.meteoError)) { + reason = '地名服务网络超时,请稍后重试。'; + } else if (!meta.amapError && !(state.ai.amapKey || state.config?.features?.amapRoad)) { + reason += ' 也可以在「设置 → 数据源增强」填一个高德 Key,中文区县地名覆盖会好很多。'; + } + box.innerHTML = `
    没找到「${esc(query)}」。${reason}
    `; + box.classList.remove('hidden'); + return; + } + const hint = usedQuery && usedQuery !== query + ? `
    按「${esc(usedQuery)}」为你找到这些:
    ` + : ''; + box.innerHTML = hint + list.map((r, i) => + `
    ${esc(r.label || r.name)}${r.lat.toFixed(2)}, ${r.lon.toFixed(2)}
    ` + ).join(''); + box.classList.remove('hidden'); + box.querySelectorAll('.suggest-item').forEach((el) => { + el.addEventListener('click', () => setOrigin(suggestResults[Number(el.dataset.i)])); + }); +} + +/** + * 地名检索。Open-Meteo 对「XX市XX区XX路」这类长地址经常直接返回空, + * 所以查不到时退一步用前 2~4 个字再试一次,避免用户以为联想坏了。 + */ +/** + * 从「杭州市西湖区」「河池市金城江区」这类结构化长地址里取出城市部分。 + * 只认行政后缀,不做无脑截字:像「金城江」这种区县名,截成「金城」会命中 + * 台湾金门 / 美国加州的同名地点,比查不到更糟。 + */ +function deriveShortQuery(q) { + const m = String(q).match(/^(.{2,8}?)(市|地区|自治州|盟|区|县|镇|乡|街道|路|街|号)/); + return m ? m[1].trim() : ''; +} + +async function geocodeLoose(q) { + let meta = {}; + const ask = async (name) => { + try { + const r = await fetch(`/api/geocode?name=${encodeURIComponent(name)}`, { headers: dataHeaders() }).then((x) => x.json()); + meta = { amapError: r.amapError || '', meteoError: r.meteoError || '', source: r.source || '' }; + return r.results || []; + } catch (e) { + meta = { meteoError: e.message || 'fetch failed' }; + return []; + } + }; + const exact = await ask(q); + if (exact.length) return { list: exact, used: q, ...meta }; + const cut = deriveShortQuery(q); + if (cut && cut !== q && cut.length >= 2) { + const alt = await ask(cut); + if (alt.length) return { list: alt, used: cut, ...meta }; + } + return { list: [], used: q, ...meta }; +} +function setOrigin(o) { + state.origin = { + name: o.name, label: o.label || o.name, + lat: Number(o.lat), lon: Number(o.lon), + admin1: o.admin1 || '', country: o.country || '', + }; + $('#originInput').value = state.origin.label; + $('#originHint').innerHTML = `已定位:${esc(state.origin.label)}(${state.origin.lat.toFixed(3)}, ${state.origin.lon.toFixed(3)})`; + hideSuggest(); + saveForm(); + suggestBestWindow(state.origin.lat, state.origin.lon); +} +async function suggestBestWindow(lat, lon) { + const box = $('#windowHint'); + box.classList.add('hidden'); + if (state.datesTouched) return; + try { + const r = await fetch(`/api/best-window?lat=${lat}&lon=${lon}&days=2`).then((x) => x.json()); + const w = r.window; + if (!w || state.datesTouched) return; + box.innerHTML = `☀️ 近期天气较平稳的窗口:${esc(w.start)} ~ ${esc(w.end)}(${esc([...new Set(w.labels)].join('、'))})`; + box.classList.remove('hidden'); + $('#applyWindow').addEventListener('click', () => { + $('#startDate').value = w.start; + $('#endDate').value = w.end; + box.classList.add('hidden'); + saveForm(); + toast('已应用推荐窗口', 'ok'); + }); + } catch { /* 静默降级 */ } +} + +// ---------------------------------------------------------------- 罗盘 +function renderCompass(cands, radiusKm) { + const maxD = Math.max(radiusKm, ...cands.map((c) => c.distanceKm || 0)) || radiusKm; + const cx = 200, cy = 200, R = 152; + let svg = ``; + svg += ``; + [0.25, 0.5, 0.75, 1].forEach((f) => { + svg += ``; + svg += `${Math.round(maxD * f)}km`; + }); + svg += ``; + svg += ``; + [['N', 0, -1], ['E', 1, 0], ['S', 0, 1], ['W', -1, 0]].forEach(([lab, dx, dy]) => { + svg += `${lab}`; + }); + cands.forEach((c) => { + const a = ((Number(c.bearingDeg) || 0) * Math.PI) / 180; + const r = R * Math.min(1, (c.distanceKm || 0) / maxD); + const x = cx + r * Math.sin(a); + const y = cy - r * Math.cos(a); + const col = scoreColor(c.score); + const rad = 3.4 + Math.max(0, c.score) / 22; + svg += `${esc(c.name)} · ${c.score}分 · ${fmtKm(c.distanceKm)} · ${compassCN(c.bearingDeg)}方向`; + }); + svg += ``; + svg += `出发地`; + svg += ``; + $('#compass').innerHTML = svg; + $$('#compass .cdot').forEach((dot) => { + dot.addEventListener('click', () => { + const card = $(`.dest-card[data-id="${CSS.escape(dot.dataset.id)}"]`); + if (!card) return; + card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + card.classList.remove('flash'); + void card.offsetWidth; + card.classList.add('flash'); + }); + }); +} + +// ------------------------------------------------------------- 清单渲染 +function scoreRing(score) { + const r = 30, C = 2 * Math.PI * r; + const dash = (C * score) / 100; + return ` + + + ${score} + / 100 + `; +} +function weatherCell(c) { + const s = c.weather?.summary; + if (!s) return `
    天气
    暂无数据
    按中性计分
    `; + return `
    天气 ${s.days} 天 +
    ${s.dominant?.icon || '🌡️'} ${esc(s.dominant?.label || '')} ${s.avgLow}~${s.avgHigh}℃
    +
    降水概率 ${s.maxPrecipProb}% · 累计 ${s.totalPrecip}mm · 风 ${s.maxWind}km/h
    `; +} +function dailyTable(c) { + const d = c.weather?.daily || []; + if (!d.length) return ''; + return `
    查看逐日天气(${d.length} 天) + + ${d.map((x) => ``).join('')} +
    日期天气气温降水风
    ${esc(x.date)} ${esc(x.weekday || '')}${x.icon || ''} ${esc(x.label)}${x.tmin}~${x.tmax}℃${x.precip}mm / ${x.precipProb}%${x.wind}km/h
    `; +} +function cardHtml(c) { + const picked = state.selected.has(c.id); + const road = c.road || {}; + const rail = c.rail || {}; + const aqi = c.aqi != null ? `AQI ${c.aqi}${c.aqiInfo ? ' · ' + esc(c.aqiInfo.label) : ''}` : '暂无数据'; + const railMain = rail.available ? `${fmtMin(rail.durationMinutes)}${rail.fareCny != null ? ` · ¥${rail.fareCny}` : ''}` : '无直达 / 建议自驾'; + return `
    +
    + #${c.rank} + ${esc(c.verdict)} + ${c.niche ? '小众' : ''} + +
    +
    +

    ${esc(c.name)}

    +
    ${esc(c.kind || '目的地')}${c.region ? ' · ' + esc(c.region) : ''} · ${compassCN(c.bearingDeg)}方向 ${fmtKm(c.distanceKm)}
    +
    +
    +
    ${scoreRing(c.score)}
    +
    + ${weatherCell(c)} +
    自驾 / 公路 +
    ${fmtMin(road.durationMinutes)} · ${fmtKm(road.distanceKm)}
    +
    ${esc(road.source || '')}${road.source === '估算' ? '(非实时)' : ''}
    +
    铁路 / 高铁 +
    ${railMain}
    +
    ${esc(rail.source || '')}${rail.source === '估算' ? '(估算,需核对 12306)' : ''}
    +
    空气质量 +
    ${aqi}
    +
    评分构成:天气 ${c.breakdown?.weather} / 交通 ${c.breakdown?.time} / 兴趣 ${c.breakdown?.interest}
    +
    +
    +
      ${(c.reasons || []).map((r) => `
    • ${esc(r)}
    • `).join('')}
    + ${(c.warnings || []).length ? `
    ${c.warnings.map((w) => `⚠ ${esc(w)}`).join('')}
    ` : ''} + ${dailyTable(c)} +
    `; +} +function sortedCandidates() { + let list = [...state.analysis.candidates]; + const key = state.sort; + list.sort((a, b) => { + if (key === 'weather') return (b.breakdown?.weather || 0) - (a.breakdown?.weather || 0) || b.score - a.score; + if (key === 'distance') return a.distanceKm - b.distanceKm; + if (key === 'time') return (a.travel?.bestHours ?? 99) - (b.travel?.bestHours ?? 99); + return b.score - a.score; + }); + if (state.onlyRec) list = list.filter((c) => c.score >= 60); + if (state.onlyNiche) list = list.filter((c) => c.niche); + return list; +} +function renderCards() { + const list = sortedCandidates(); + // 重渲染会重建 DOM:先记住哪些逐日天气是展开的,避免勾选/排序后莫名收起 + const openDays = new Set($$('#cards .daily[open]').map((d) => d.dataset.day)); + const noBase = !(state.analysis?.candidates || []).length; + $('#cards').innerHTML = list.length + ? list.map(cardHtml).join('') + : `
    ${noBase + ? `当前半径内没有可去的地点${state.analysis?.constraints?.excludeLocal ? '(已排除出发地附近的同城点位)' : ''},试试取消「排除同城」或增大搜索半径。` + : state.onlyNiche + ? '当前半径内没有小众地点,试试取消「只看小众」或增大搜索半径。' + : '没有符合筛选条件的目的地,试试取消「仅显示推荐」或「只看小众」。'}
    `; + $$('#cards .daily').forEach((d) => { if (openDays.has(d.dataset.day)) d.open = true; }); + $$('#cards .pick-btn').forEach((b) => b.addEventListener('click', () => togglePick(b.dataset.id))); + $('#selectedCount').textContent = `已选 ${state.selected.size} 个`; + $('#preselectHint').classList.toggle('hidden', !state.autoPreselected); + renderCompass(list, state.analysis.constraints.radiusKm); + renderCompare(); +} +function togglePick(id) { + if (state.selected.has(id)) state.selected.delete(id); + else state.selected.add(id); + state.autoPreselected = false; + // 住宿是按「已选里评分最高的目的地」查的,选择变了必须重查,否则旧数据会带进方案 + state.hotels = []; + renderCards(); +} +function renderStep2() { + const a = state.analysis; + const excl = a.constraints.excludeLocal ? ' · 已排除出发地所在城区' : ''; + $('#resultSub').textContent = `${a.origin.label} · ${a.timeRange.start} ~ ${a.timeRange.end}(${a.timeRange.days} 天,${a.timeRange.weekdayHint}) · 半径 ${a.constraints.radiusKm}km${excl}`; + const s = a.summary; + $('#summaryPanel').innerHTML = ` +
    +
    候选目的地
    ${s.total}个
    推荐及以上 ${s.recommended} 个
    +
    平均适配分
    ${s.avgScore}/100
    天气 / 交通 / 距离 / 偏好
    +
    区间平均高温
    ${s.avgHigh ?? '—'}℃
    需留意天气的候选 ${s.rainyCount} 个
    +
    +
    +
    天气 ${esc(s.weatherNote)}
    +
    公路 ${esc(s.roadProvider === '估算' ? '路网服务不可用,里程-速度估算' : s.roadProvider === '高德' ? '高德 Web 服务驾车路径' : 'OSRM 公开路网矩阵')}
    +
    铁路 ${a.dataNotes.some((n) => n.includes('铁路接口')) && !a.dataNotes.some((n) => n.includes('未配置铁路接口')) ? '已配置铁路接口' : '未配置铁路接口,耗时与票价均为估算'}
    +
    `; + renderAlerts(a); + + state.selected = new Set(); + state.autoPreselected = false; + state.compareOpen = false; + $('#compareBtn').textContent = '对比已选'; + renderCards(); + $('#dataNotes').innerHTML = `

    数据来源与口径说明

      ${a.dataNotes.map((n) => `
    • ${esc(n)}
    • `).join('')}
    `; +} + +/** + * 顶部提示区(天气预警 + 降级说明)。 + * 单独抽出是因为「保存设置」后需要原地刷新:用户刚填完高德 Key, + * 提示里就该从「去配置」变成「重新分析一次」,而不是让他自己猜。 + */ +function renderAlerts(a) { + const s = a.summary || {}; + const alerts = []; + if (s.weatherAlert) alerts.push(`
    ⚠️ ${esc(s.weatherAlert)}
    `); + + if (a.stats?.amapError) { + // Key 填错比"没取到"更值得优先告诉用户,否则他会反复重试 + alerts.push(`
    ⚠️ ${esc(a.stats.amapError)}
    请确认高德 Key 是「Web服务」类型、已启用且配额未用尽。
    `); + } else if (a.stats?.fallbackOnly) { + const hasAmap = Boolean(state.ai.amapKey) || Boolean(state.config?.features?.amapRoad); + const action = hasAmap + ? '看看这次能否取到实时数据。' + : '可获得更丰富的实时地点。'; + alerts.push(`
    ℹ️ 本次未取到实时周边 POI(公共检索服务波动),清单来自内置地点库,坐标为近似值;天气、空气质量与路况仍是实时数据,不受影响。${action}
    `); + } + + $('#alertBox').innerHTML = alerts.join(''); + $('#alertBox').classList.toggle('hidden', alerts.length === 0); + bindAlertActions(); +} + +/** 提示区里的动作按钮统一在这里绑定,重渲染后不会丢事件 */ +function bindAlertActions() { + $$('#alertBox [data-open-settings]').forEach((b) => b.addEventListener('click', openSettings)); + $$('#alertBox [data-retry-analyze]').forEach((b) => b.addEventListener('click', () => { + b.disabled = true; + runAnalyze(); + })); +} + +/** 已选目的地并排对比:勾完之后用户最想问的是「哪个更合适」 */ +function renderCompare() { + const box = $('#comparePanel'); + const btn = $('#compareBtn'); + if (!box) return; + if (btn) btn.textContent = state.compareOpen ? '收起对比' : '对比已选'; + if (!state.compareOpen) { box.classList.add('hidden'); box.innerHTML = ''; return; } + const list = (state.analysis?.candidates || []) + .filter((c) => state.selected.has(c.id)).slice(0, 4); + if (list.length < 2) { + box.innerHTML = '
    至少勾选 2 个目的地才能并排对比。
    '; + box.classList.remove('hidden'); + return; + } + const rows = [ + ['适配分', (c) => `${c.score} · ${esc(c.verdict || '')}`], + ['位置', (c) => `${compassCN(c.bearingDeg)}方向 · ${fmtKm(c.distanceKm)}`], + ['最快单程', (c) => c.travel?.bestHours != null ? `${fmtMin(c.travel.bestHours * 60)}(${esc(c.travel.bestMode || '')})` : '无可达方案'], + ['天气', (c) => c.weather?.summary ? `${esc(c.weather.summary.dominant?.label || '')} ${c.weather.summary.avgLow}~${c.weather.summary.avgHigh}℃` : '暂无'], + ['空气质量', (c) => c.aqi != null ? `AQI ${c.aqi} · ${esc(c.aqiInfo?.label || '')}` : '暂无'], + ['类型', (c) => `${esc(c.kind || '')}${c.niche ? ' · 小众' : ''}`], + ['数据来源', (c) => `${esc(c.source || '')}${c.region ? ' · ' + esc(c.region) : ''}`], + ]; + box.innerHTML = `

    已选目的地对比

    ${list.length} 个 · 同屏比较关键指标
    +
    ${list.map((c) => ``).join('')} + ${rows.map(([k, fn]) => `${list.map((c) => ``).join('')}`).join('')}
    指标${esc(c.name)}
    ${k}${fn(c)}
    `; + box.classList.remove('hidden'); +} +function toggleCompare() { + state.compareOpen = !state.compareOpen; + renderCompare(); +} + +// ------------------------------------------------------------- 第三步 +function renderSelected() { + const a = state.analysis; + if (!a) return; + const list = a.candidates.filter((c) => state.selected.has(c.id)); + $('#selectedChips').innerHTML = list.length + ? list.map((c) => `${esc(c.name)} ✕`).join('') + : '尚未选择目的地,请返回上一步勾选。'; + $$('#selectedChips .sel-chip').forEach((el) => el.addEventListener('click', () => { + state.selected.delete(el.dataset.id); + renderCards(); + renderSelected(); + })); +} +function aiHeaders() { + return { + 'X-AI-Base-Url': state.ai.baseUrl || '', + 'X-AI-Model': state.ai.model || '', + 'X-AI-Api-Key': state.ai.forceDemo ? '__demo__' : (state.ai.apiKey || '__server__'), + }; +} +/** 数据源覆盖:高德 Key 只影响周边 POI 与驾车路径,与分析请求一起传 */ +function dataHeaders() { + return state.ai.amapKey ? { 'X-AMAP-Key': state.ai.amapKey } : {}; +} +function updateAIStatus() { + const demo = state.ai.forceDemo || (!state.config?.ai.hasKey && !state.ai.apiKey); + const pill = $('#aiStatus'); + pill.className = `status-pill ${demo ? '' : 'live'}`; + pill.innerHTML = `${demo ? '演示模式(未配置密钥)' : `AI 已配置 · ${esc(state.ai.model || '')}`}`; + const cfg = state.config; + $('#aiInline').innerHTML = demo + ? '当前为 演示模式:由本地规则引擎依据同一份清单生成示例方案,用于验证完整流程。填入任意 OpenAI 兼容接口后由大模型生成。' + : `将调用 ${esc(state.ai.baseUrl || cfg?.ai.baseUrl || '')} · 模型 ${esc(state.ai.model || cfg?.ai.model || '')},配合本站预设提示词生成方案。`; +} +let renderTimer = null; +function paintPlan(text) { + const el = $('#planOutput'); + let src; + if (typeof text === 'string') src = text; + else if (state.viewing != null && state.planVersions[state.viewing]) src = state.planVersions[state.viewing].text; + else src = state.planText; + el.innerHTML = mdToHtml(src) + (state.planning ? '▍' : ''); + el.scrollTop = el.scrollHeight; +} +function schedulePaint() { + if (renderTimer) return; + renderTimer = setTimeout(() => { renderTimer = null; paintPlan(); }, 110); +} +/** 把 GCJ-02 经纬度投影成静态地图上的像素坐标(Web Mercator,与高德底图一致) */ +function projectToMapPx(lng, lat, meta) { + const world = 256 * Math.pow(2, meta.zoom); + const toY = (la) => { + const r = (la * Math.PI) / 180; + return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * world; + }; + const cx = ((meta.center.lng + 180) / 360) * world; + const cy = toY(meta.center.lat); + return { + x: meta.width / 2 + (((lng + 180) / 360) * world - cx), + y: meta.height / 2 + (toY(lat) - cy), + }; +} + +/** 底图上的叠加层:编号圆点 + 名称 + 行程连线(自己画,不受 markers 参数限制) */ +function buildMapOverlay(picked, meta) { + const pts = (meta.points || []).map(([lng, lat]) => projectToMapPx(lng, lat, meta)); + if (!pts.length) return ''; + // 第 0 点是出发地(如果有),用空心圈标出,后面依次是行程中的地点 + const originAt = picked.length && picked[0].kind === 'origin' ? 0 : -1; + const line = pts.length > 1 + ? `` + : ''; + + // 标注避让:行程点常挤在 1~2km 内(城市中心步行范围), + // 固定把名字放在点上方会全部叠成一团黑字。 + // 做法:按 y 排序后依次分配标签槽位,同槽位内交替上下方, + // 并记录已占用的标签矩形,冲突时换一个槽。 + // 全部槽位都冲突(极密集)时,只画编号不画名字——宁可少标也不能糊成一团。 + const LABEL_SLOTS = [ + { dx: 0, dy: -18, anchor: 'middle' }, + { dx: 0, dy: 26, anchor: 'middle' }, + { dx: 12, dy: -12, anchor: 'start' }, + { dx: -12, dy: -12, anchor: 'end' }, + { dx: 12, dy: 22, anchor: 'start' }, + { dx: -12, dy: 22, anchor: 'end' }, + ]; + const placed = []; + const collides = (r) => placed.some((q) => !(r.x2 < q.x1 || r.x1 > q.x2 || r.y2 < q.y1 || r.y1 > q.y2)); + + let seq = 0; + const dots = pts.map((p, i) => { + const item = picked[i] || {}; + const nm = (item.name || '').slice(0, 8); + if (i === originAt) { + return ` + + 出发地 + `; + } + seq += 1; + // 给该点挑一个不与已放置标签冲突的槽位 + const w = Math.max(34, nm.length * 11); + let slot = null; + for (const s of LABEL_SLOTS) { + const cx = p.x + s.dx; + const cy = p.y + s.dy; + const x1 = s.anchor === 'middle' ? cx - w / 2 : (s.anchor === 'start' ? cx : cx - w); + const r = { x1, y1: cy - 12, x2: x1 + w, y2: cy + 4 }; + if (!collides(r)) { slot = s; placed.push(r); break; } + } + const label = slot + ? `${esc(nm)}` + : ''; + const tip = `${esc(item.name || '')}`; + return `${tip} + + ${seq} + ${label} + `; + }).join(''); + return `${line}${dots}`; +} + +/** + * 从方案正文里提取「实际安排的地点」。 + * + * 背景:路线图之前只画用户勾选的候选(常常只有 1 个城市), + * 但用户要的是**方案里安排的每个地点**的真实位置与串联线路。 + * + * 提取策略(按可靠度从高到低): + * 1. nearby 列表里的点名地点——这是模型被强制点名的餐饮/景点,名字必定出现; + * 2. 候选清单里被选中的目的地——方案总览与逐日安排一定会提到; + * 3. 正文里加粗的 **地点名**——模型用来强调的具体点位。 + * 再按它们在正文中首次出现的顺序排序,得到行程顺序。 + */ +/** + * 地点名的形态判定:必须是「像地点的名字」。 + * + * 方案正文里加粗的词有一大半不是地点:演示模式、需求备忘、自驾、合计、 + * 若遇降雨或大风……用黑名单永远补不全。改成白名单:名字要以「场所类后缀」 + * 结尾(景区/公园/博物馆/古镇/寺/山/岛……)才算地点。 + */ +const PLACE_SUFFIX = /(景区|风景区|公园|博物馆|纪念馆|展览馆|美术馆|科技馆|体育馆|图书馆|古镇|古村|村|屯|镇|街区|老街|寺|庙|塔|桥|山|岭|峰|湖|江|河|岛|潭|瀑|瀑布|峡谷|溶洞|洞|草原|梯田|湿地|海滩|沙滩|海湾|温泉|漂流|基地|林场|农场|乐园|广场|大厦|酒店|客栈|民宿|山庄|度假区|度假村|渔村|渔港|码头|驿道|遗址|石窟|陵园|公园道|步道|绿道|驿站)/; + +/** 加粗词里常见的「说明性词」:即使带后缀也不是可去的地方 */ +const NOT_PLACE = /^(演示模式|需求备忘|合计|总计|小计|自驾|铁路|公共交通|高铁|客车|班车|合计|注意|说明|备注|触发条件|替换动作|若遇.*|以下.*|建议.*|推荐.*|可选.*|雨天.*|室内.*|户外.*|数据来源.*|出发前.*|门票.*|交通.*|住宿.*|预算.*|风险.*|清单.*|装备.*|证件.*|提醒.*|动作.*|政策.*|预约.*|换乘.*|停车.*|步行.*|骑行.*|拼车.*|包车.*|自驾.*)$/; + +function extractPlanPlaces(text) { + const src = String(text || ''); + if (!src.trim()) return []; + const norm = (s) => String(s || '').replace(/[\s*()()【】\[\],,。.、::·\-—]/g, '').toLowerCase(); + const body = norm(src); + + const pool = []; + const push = (name, extra = {}) => { + const n = String(name || '').trim(); + if (!n || n.length < 2) return; + const key = norm(n); + if (!key) return; + if (pool.some((p) => norm(p.name) === key)) return; + pool.push({ name: n, ...extra }); + }; + + // 1) nearby 点名地点(餐饮/景点):模型被强制点名的,名字必定出现在正文里 + const nb = state.nearby || {}; + for (const x of [...(nb.sights || []), ...(nb.food || [])]) { + if (x && x.name) push(x.name, { kind: x.type || '地点', lat: x.lat, lon: x.lon, from: 'nearby' }); + } + // 2) 已选目的地 + const a = state.analysis; + if (a && Array.isArray(a.candidates)) { + for (const c of a.candidates) { + if (!state.selected.has(c.id)) continue; + push(c.name, { kind: c.kind || '目的地', lat: c.lat, lon: c.lon, from: 'candidate' }); + } + } + // 3) 正文里加粗的点名:只有「像地点」的才算 + const bold = src.match(/\*\*([^*\n]{2,24})\*\*/g) || []; + for (const b of bold) { + const n = b.replace(/\*\*/g, '').trim(); + if (NOT_PLACE.test(n)) continue; + if (/[,。,.\d]/.test(n)) continue; + if (!PLACE_SUFFIX.test(n)) continue; + push(n, { kind: '地点', from: 'bold' }); + } + + // 按在正文中首次出现的位置排序 → 行程顺序 + const withPos = pool.map((p) => { + const k = norm(p.name); + let at = body.indexOf(k); + if (at < 0) { + // 加粗名可能是简称/带修饰,用最长前缀再试一次 + const short = k.slice(0, Math.min(4, k.length)); + at = short ? body.indexOf(short) : -1; + } + return { ...p, at: at < 0 ? 1e9 : at }; + }); + withPos.sort((x, y) => x.at - y.at); + return withPos.filter((p) => p.at < 1e9 || p.lat != null); +} + +/** + * 把提取出的地点解析成真实坐标。 + * 已有坐标的直接用;缺的走服务端按名称定位(高德 POI)。 + */ +async function resolveRoutePoints(places) { + const need = places.filter((p) => p.lat == null || p.lon == null); + const out = places.map((p) => ({ ...p })); + if (!need.length) return { points: out, missing: [] }; + + const a = state.analysis; + const anchor = a && Array.isArray(a.candidates) + ? a.candidates.filter((c) => state.selected.has(c.id)) + .sort((x, y) => (Number(y.score) || 0) - (Number(x.score) || 0))[0] + : null; + + try { + const r = await fetch('/api/route-points', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...dataHeaders() }, + body: JSON.stringify({ + names: need.map((p) => p.name), + city: anchor ? (anchor.city || anchor.district || '') : '', + lat: anchor ? anchor.lat : undefined, + lon: anchor ? anchor.lon : undefined, + }), + }).then((x) => x.json()); + if (!r.available) return { points: out, missing: need.map((p) => p.name), reason: r.reason || '' }; + const byQuery = new Map((r.points || []).map((p) => [p.query, p])); + const missing = []; + for (const p of out) { + if (p.lat != null && p.lon != null) continue; + const hit = byQuery.get(p.name); + if (hit) { + p.lat = hit.lat; p.lon = hit.lon; + p.kind = p.kind || hit.type || '地点'; + p.city = hit.city; p.district = hit.district; + p.resolved = true; + } else { + missing.push(p.name); + } + } + return { points: out, missing }; + } catch (e) { + return { points: out, missing: need.map((p) => p.name), reason: e.message }; + } +} + +/** 静态地图最多能承载的路线点数(服务端 staticMapMeta 同样按 9 点截取) */ +const MAX_ROUTE_POINTS = 9; + +/** + * 路线点抽稀:地图上只画 MAX_ROUTE_POINTS 个点,且不画几乎重合的点。 + * + * 两个问题: + * 1. 超过 9 个点时,图上只画得出前 9 个,但图例还写着 16 个,对不上; + * 2. 方案点名的地点常在同一片街区(如「柳州市」与「市民广场」相距 400m), + * 叠在一起既看不清也连不出线。 + * 所以:先按距离去掉 80m 内几乎重合的点(保留先出现的), + * 再按行程顺序均匀抽样到 9 个,并保证首尾都在。 + * + * 出发地单独算:它是「起点」,不参与抽稀与计数, + * 否则 200km 外的起点会把目的地之间的相对距离压扁。 + */ +function thinRoutePoints(points) { + if (!points.length) return []; + const kept = []; + for (const p of points) { + const last = kept[kept.length - 1]; + if (last && Math.hypot((p.lat - last.lat) * 111, (p.lon - last.lon) * 102) < 0.08) continue; + kept.push(p); + } + if (kept.length <= MAX_ROUTE_POINTS) return kept; + const step = (kept.length - 1) / (MAX_ROUTE_POINTS - 1); + const out = []; + for (let i = 0; i < MAX_ROUTE_POINTS; i += 1) { + const idx = Math.round(i * step); + if (!out.includes(kept[idx])) out.push(kept[idx]); + } + return out; +} + +async function drawRouteMap() { + const wrap = $('#routeMap'); + if (!wrap) return; + const note = $('#routeNote'); + const a = state.analysis; + if (!a) { wrap.innerHTML = ''; return; } + + // 优先用「方案里实际安排的地点」画线路;方案还没生成时才退回已选候选 + let picked = []; + let label = ''; + let missing = []; + let total = 0; + if (state.routePoints && state.routePoints.length) { + total = state.routePoints.length; + picked = thinRoutePoints(state.routePoints); + label = `方案行程 ${total} 个地点`; + } else { + picked = a.candidates.filter((c) => state.selected.has(c.id)).slice(0, 5); + total = picked.length; + label = picked.length ? `已选 ${picked.length} 个目的地` : ''; + } + if (!picked.length) { + wrap.innerHTML = '
    还没有已选目的地。生成方案后,这里会画出方案里每个地点的实际位置与线路。
    '; + if (note) note.textContent = ''; + return; + } + + // 地图只画目的地,不画出发地: + // 出发地往往在 100km 外,把它塞进取景范围会把目的地压成一个点。 + // 起点信息改用文字标注(方位 + 直线距离),在降级示意图里才画出来。 + const origin = a.origin && Number.isFinite(Number(a.origin.lat)) ? a.origin : null; + const originDir = origin && picked[0] + ? (() => { + const dLat = (Number(picked[0].lat) - Number(origin.lat)) * 111; + const dLon = (Number(picked[0].lon) - Number(origin.lon)) * 102; + const deg = (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360; + const arr = ['北', '东北', '东', '东南', '南', '西南', '西', '西北']; + const km = Math.round(Math.hypot(dLat, dLon)); + return `出发地${origin.name || ''}在${arr[Math.round(deg / 45) % 8]}方 ${km}km`; + })() + : ''; + + if (state.routeMapBlob) { URL.revokeObjectURL(state.routeMapBlob); state.routeMapBlob = null; } + wrap.innerHTML = `
    ${svgRoute(picked, origin)}
    `; + if (note) note.textContent = label; + + const hasKey = Boolean(state.ai.amapKey) || Boolean(state.config?.features?.amapRoad); + if (!hasKey) { + wrap.innerHTML = `
    ${svgRoute(picked, origin)}
    +
    配置高德 Key 可显示真实地图(当前为方位示意)。
    `; + if (note) note.textContent = `${label} · 方位示意`; + return; + } + + const pts = picked.map((c) => `${c.lat},${c.lon}`).join(';'); + try { + const r = await fetch(`/api/staticmap?pts=${encodeURIComponent(pts)}&w=640&h=360`, { headers: dataHeaders() }); + if (!(r.headers.get('content-type') || '').startsWith('image')) { + const j = await r.json().catch(() => ({})); + const why = String(j.error || '未知原因').replace(/^高德静态地图:/, '').slice(0, 100); + wrap.innerHTML = `
    ${svgRoute(picked, origin)}
    +
    地图暂不可用,已用方位示意:${esc(why)}
    `; + if (note) note.textContent = `${label} · 方位示意`; + return; + } + // 服务端把取景参数放在响应头里,前端据此把经纬度投影成像素再叠加标记 + let meta = null; + try { + const b64 = r.headers.get('x-map-meta'); + if (b64) meta = JSON.parse(atob(b64)); + } catch { meta = null; } + + const blob = await r.blob(); + state.routeMapBlob = URL.createObjectURL(blob); + const notes = []; + if (originDir) notes.push(originDir); + if (missing.length) { + notes.push(`有 ${missing.length} 个地点未能定位(${esc(missing.slice(0, 3).join('、'))}${missing.length > 3 ? ' 等' : ''}),未画在图上`); + } + if (total > picked.length) { + notes.push(`方案共 ${total} 个地点,图上按行程顺序抽样显示 ${picked.length} 个(重合点已合并)`); + } + const missNote = notes.length + ? `
    ${notes.map((x) => esc(x)).join(';')}。
    ` + : ''; + // 行程点清单:图上最多 9 个点,方案里的地点通常更多, + // 完整清单让用户能看到「每个安排的地点」到底是哪些、按什么顺序。 + const allPoints = state.routePoints || picked; + const listHtml = allPoints.length ? ` +
      ${allPoints.map((p, i) => ` +
    1. ${i + 1}${esc(p.name)}${p.kind ? `${esc(p.kind)}` : ''}
    2. `).join('')}
    ` : ''; + wrap.innerHTML = ` +
    + 行程地图 + ${meta ? buildMapOverlay(picked, meta) : ''} + 高德地图 · ${picked.length} 个地点(编号为行程顺序) +
    ${missNote}${listHtml}`; + if (note) note.textContent = `${label} · 高德底图`; + } catch (e) { + wrap.innerHTML = `
    ${svgRoute(picked, origin)}
    +
    地图请求失败:${esc(e.message)}
    `; + if (note) note.textContent = `${label} · 方位示意`; + } +} + +/** 降级示意图:按真实经纬度等距投影(经度按纬度余弦修正)。 + * 只有 1 个目的地时会带上出发点,否则图上只有一个孤点,看不出"行程"。 */ +function svgRoute(picked, origin) { + const W = 640, H = 380, pad = 62; + const pts = picked.map((c) => ({ lat: Number(c.lat), lon: Number(c.lon), c, kind: 'dest' })); + if (origin) { + pts.unshift({ lat: Number(origin.lat), lon: Number(origin.lon), c: { name: '出发地' }, kind: 'origin' }); + } + + const cLat = pts.reduce((s, p) => s + p.lat, 0) / pts.length; + const cosLat = Math.cos((cLat * Math.PI) / 180) || 1; + const xs = pts.map((p) => p.lon * cosLat); + const minX = Math.min(...xs), maxX = Math.max(...xs); + const minY = Math.min(...pts.map((p) => p.lat)); + const maxY = Math.max(...pts.map((p) => p.lat)); + const spanX = maxX - minX, spanY = maxY - minY; + + let pos; + if (pts.length === 1 || (spanX < 1e-6 && spanY < 1e-6)) { + pos = pts.map((p, i) => ({ p, i, x: W / 2, y: H / 2 })); + } else { + const scale = Math.min( + (W - pad * 2) / Math.max(spanX, 1e-6), + (H - pad * 2) / Math.max(spanY, 1e-6), + ); + const offX = (W - spanX * scale) / 2; + const offY = (H - spanY * scale) / 2; + pos = pts.map((p, i) => ({ + p, i, + x: offX + (xs[i] - minX) * scale, + y: H - offY - (p.lat - minY) * scale, + })); + } + + const line = pos.length > 1 + ? `` + : ''; + + let destSeq = 0; + const nodes = pos.map((p) => { + if (p.p.kind === 'origin') { + return ` + + 出发地 + `; + } + destSeq += 1; + const short = (p.p.c.name || '').slice(0, 10); + const dist = Math.round(Number(p.p.c.distanceKm) || 0); + const anchor = p.x < W * 0.2 ? 'start' : (p.x > W * 0.8 ? 'end' : 'middle'); + const dy = p.y > H * 0.75 ? -15 : 27; + return ` + + + ${destSeq} + ${esc(short)} + 距出发地 ${dist}km + `; + }).join(''); + + const legend = origin + ? '出发地目的地(虚线为行程动线)' + : '已选目的地(编号为推荐顺序,虚线为行程动线)'; + + return ` + ${line}${nodes} + +
    ${legend}
    `; +} + +/** + * 住宿建议:方案定稿后才给。 + * 落脚点取「已选里评分最高的那个」——两天的行程通常围着他展开; + * 再按用户选的价位过滤,价格缺失的单独标注,不硬编一个假价格。 + */ +async function loadStayPlan() { + const box = $('#hotelBox'); + if (!box) return; + state.hotels = []; + const a = state.analysis; + const L = currentLodging(); + const rangeText = (L.min == null && L.max == null) + ? '价位不限' + : (L.min != null && L.max != null ? `¥${L.min}–${L.max}` + : (L.max != null ? `≤¥${L.max}` : `≥¥${L.min}`)); + + if (!a?.candidates?.length || !state.selected.size) { + box.innerHTML = '
    先在清单里勾选目的地。
    '; + return; + } + const picked = a.candidates.filter((c) => state.selected.has(c.id)); + const anchor = picked.slice().sort((x, y) => y.score - x.score)[0]; + const note = $('#stayNote'); + if (note) note.textContent = `围绕「${anchor.name}」· ${rangeText}`; + + // 数据源交给服务端决定:装了 rgh 且已登录就用 RollingGo 的实时报价,否则退回高德 POI + box.innerHTML = '
    正在查住宿…
    '; + try { + const nights = Math.max(1, (Number(a.timeRange?.days) || 2) - 1); + const qs = new URLSearchParams({ + lat: String(anchor.lat), + lon: String(anchor.lon), + radius: '10', + name: anchor.name || '', + checkIn: a.timeRange?.start || '', + nights: String(nights), + }); + if (L.max != null) qs.set('maxPrice', String(L.max)); + const r = await fetch(`/api/hotels?${qs.toString()}`, { headers: dataHeaders() }).then((x) => x.json()); + if (!r.available) { + box.innerHTML = `
    ${esc(r.reason || '住宿数据源不可用')};方案里的住宿预算仍会按你所选价位给出。
    `; + return; + } + if (r.error) { box.innerHTML = `
    住宿查询失败:${esc(r.error)}
    `; return; } + const all = r.hotels || []; + if (!all.length) { + const why = r.reason ? `(${esc(r.reason)})` : ''; + box.innerHTML = `
    「${esc(anchor.name)}」周边 10km 内没查到住宿${why}。
    `; + return; + } + // 数据源降级要说清楚:实时报价没命中,退到了只有位置的 POI 源 + const degraded = r.provider === 'amap' && r.fallbackFrom === 'rollinggo'; + + const inRange = []; + const unknown = []; + for (const h of all) { + if (h.cost == null) { unknown.push(h); continue; } + if (L.min != null && h.cost < L.min) continue; + if (L.max != null && h.cost > L.max) continue; + inRange.push(h); + } + const list = [...inRange, ...unknown].slice(0, 8); + if (!list.length) { + box.innerHTML = `
    周边没有落在 ${esc(rangeText)} 的住宿,可以放宽价位,或把价位改成「不限」。
    `; + return; + } + state.hotels = list; + const priced = all.filter((h) => h.cost != null); + const rangeLine = inRange.length + ? `价位 ${esc(rangeText)} · 命中 ${inRange.length} 家${unknown.length ? ` · ${unknown.length} 家无价格` : ''}` + : `这批酒店都没带价格,无法按 ${esc(rangeText)} 筛选;按评分与距离排序`; + // 价格缺失是数据源覆盖面问题,不是解析失败。说一句就够,细节放 README。 + const noPriceNote = !priced.length + ? (r.provider === 'rollinggo' + ? '
    这批没返回房价,点「去预订」看实时价。
    ' + : '
    当前只能查到酒店位置,没有价格。点「查房价」看实时报价。
    ') + : ''; + const degradedNote = degraded + ? '
    实时报价源这次没命中,已退回到只有位置的酒店数据。
    ' + : ''; + box.innerHTML = ` +
    ${rangeLine}
    +
      + ${list.map((h) => { + // RollingGo 会给 bookingUrl,直接用;高德只有 POI,就给一个地图核对入口 + const safeBooking = /^https?:\/\//i.test(String(h.bookingUrl || '')) ? h.bookingUrl : ''; + const href = safeBooking || `https://uri.amap.com/search?keyword=${encodeURIComponent(h.name)}`; + const dist = h.distanceKm != null ? ` · ${h.distanceKm}km` : ''; + // RollingGo 的 lowestPrice 是每晚均价(3 晚总价 1563 → 显示 ¥521/晚),高德的是人均参考价 + const price = h.cost != null + ? (h.source === 'RollingGo' ? `¥${Math.round(h.cost)}/晚` : `¥${Math.round(h.cost)} 起`) + : '无标注价'; + const star = h.rating ? `★${h.rating} · ` : ''; + return `
    • + ${esc(h.name)} + ${star}${price}${dist} + ${safeBooking ? '去预订' : '查房价'} +
    • `; + }).join('')} +
    + ${degradedNote} + ${noPriceNote} +
    ${r.provider === 'rollinggo' + ? '价格为每晚参考价,实际金额以下单页锁价为准。' + : '当前数据源只提供酒店位置与评分,未提供房价;实际价格请点「查房价」核对。'}
    `; + } catch (e) { + box.innerHTML = `
    住宿查询失败:${esc(e.message)}
    `; + } +} + +async function runPlan(opts = {}) { + const a = state.analysis; + if (!a) return toast('请先完成目的地分析', 'err'); + const shortlist = a.candidates.filter((c) => state.selected.has(c.id)).slice(0, 8) + .map((c) => ({ ...c, direction: compassCN(c.bearingDeg) })); + if (!shortlist.length) return toast('请至少选择一个目的地', 'err'); + const isFollowUp = Boolean(opts.followUp); + // 追加轮把「当前这一版」交给模型当底稿;首次生成则是空的 + const previous = isFollowUp ? state.planText : ''; + + // 先补「可点名的地点」:只给住宿 POI 的话,模型只能写"某某片区 + 自行核实"。 + // 这里对主目的地查一次周边餐饮 / 景点,塞进 nearby 一起交给模型。 + const btn = $('#planBtn'); + btn.disabled = true; + $('#followupBtn').disabled = true; + let nearby = null; + if (!isFollowUp) { + // 已选变化后 state.hotels 会被清空,这里补查一次,避免拿旧住宿生成方案 + if (!(state.hotels || []).length) { + try { await loadStayPlan(); } catch { /* 查不到就按空处理 */ } + } + $('#outputMeta').textContent = '正在获取周边地点…'; + const anchor = shortlist.find((c) => Number.isFinite(Number(c.lat)) && Number.isFinite(Number(c.lon))); + if (anchor) { + try { + const r = await fetch(`/api/nearby-pois?lat=${anchor.lat}&lon=${anchor.lon}&radius=8`, { headers: dataHeaders() }).then((x) => x.json()); + if ((r.food || []).length || (r.sights || []).length) { + nearby = { area: anchor.name, radiusKm: r.radiusKm || 8, food: r.food || [], sights: r.sights || [] }; + } + } catch { /* 取不到就照常生成,不让它阻断流程 */ } + } + } + // 存一份供前端提取方案里的点名地点(路线图要用它们的坐标) + state.nearby = nearby; + + const payload = { + origin: a.origin, + timeRange: a.timeRange, + constraints: a.constraints, + shortlist, + userNeeds: $('#needs').value.trim(), + dataNotes: a.dataNotes, + previous, + followUp: opts.followUp || '', + hotels: state.hotels || [], + nearby, + lodging: currentLodging(), + }; + $('#followupBtn').disabled = true; + state.planning = true; + state.planText = ''; + state.reasoningLen = 0; + state.lastNotice = ''; + state.viewing = null; + if (!isFollowUp) { state.basePlan = ''; state.planRound = 0; state.planVersions = []; } + $('#planOutput').classList.remove('empty'); + $('#outputMeta').textContent = isFollowUp ? '正在基于上一版调整…' : '正在生成…'; + paintPlan(); + let errMsg = null; + try { + await ssePost('/api/plan', payload, aiHeaders(), (ev) => { + if (ev.type === 'meta') { + state.ctxInfo = ev.approxTokens ? `上下文约 ${ev.approxTokens} token` : ''; + $('#outputMeta').textContent = ev.mode === 'demo' + ? `演示模式 · 本地规则引擎${state.ctxInfo ? ` · ${state.ctxInfo}` : ''}` + : `${ev.model}${ev.isFollowUp ? ' · 调整中' : ''}${state.ctxInfo ? ` · ${state.ctxInfo}` : ''}`; + } else if (ev.type === 'delta') { + state.planText += ev.text; + schedulePaint(); + } else if (ev.type === 'reasoning') { + // 推理模型会先吐一堆思考内容,若不提示,用户会以为卡住了 + state.reasoningLen = (state.reasoningLen || 0) + ev.text.length; + $('#outputMeta').textContent = `模型思考中…已 ${state.reasoningLen} 字`; + } else if (ev.type === 'notice') { + // 后端在自动放大 max_tokens 重试,得让用户知道不是卡住了 + state.lastNotice = ev.message; + $('#outputMeta').textContent = ev.message; + } else if (ev.type === 'error') { + errMsg = ev.message; + } else if (ev.type === 'done') { + state.planning = false; + } + }); + } catch (e) { + errMsg = e.message; + } + state.planning = false; + if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } + if (errMsg) { + const prefix = state.lastNotice ? `> ℹ️ ${state.lastNotice}\n\n` : ''; + state.planText += `\n\n${prefix}> ⚠️ 生成中断:${errMsg}\n`; + } + if (!state.planText.trim()) { + state.planText = `> ⚠️ 未获得任何输出。${errMsg ? '' : '点右上角「设置」→「测试 AI 接口」可定位具体原因(地址 / 密钥 / 模型 / 参数不支持)。'}`; + } + // 记录版本:初版留底,追加轮递增 + // 注意:演示模式的方案开头就是「> ⚠️ 演示模式…」的提示块, + // 不能用 startsWith('> ⚠️') 判失败,否则演示模式下永远看不到路线图与住宿。 + const hasContent = Boolean(state.planText.trim()) + && !errMsg + && !state.planText.includes('> ⚠️ 未获得任何输出'); + if (hasContent && !errMsg) { + if (isFollowUp) state.planRound = (state.planRound || 1) + 1; + else { state.planRound = 1; state.basePlan = state.planText; state.planVersions = []; } + state.planVersions.push({ + n: state.planRound, + needs: opts.followUp || '', + text: state.planText, + at: Date.now(), + }); + renderVersionBar(); + } + paintPlan(); + const showFollow = hasContent; + $('#planFollowup').classList.toggle('hidden', !showFollow); + + // 方案定稿后才给路线示意与住宿建议:先有行程,再谈住哪 + if (hasContent) { + const wasHidden = $('#planExtra').classList.contains('hidden'); + $('#planExtra').classList.remove('hidden'); + // 先把方案里点名的地点解析成真实坐标,再画线路。 + // 这一步要等方案正文定稿,所以放在生成之后而不是之前。 + try { + const places = extractPlanPlaces(state.planText); + const r = await resolveRoutePoints(places); + state.routePoints = r.points.filter((p) => p.lat != null && p.lon != null); + state.routeMissing = r.missing || []; + } catch { + state.routePoints = null; + } + // drawRouteMap 是 async:必须 catch,否则它内部任何异常都会变成 + // "未处理的 Promise 拒绝",路线图会无声消失(之前踩过:变量未声明 → ReferenceError) + drawRouteMap().catch((err) => { + const box = $('#routeMap'); + if (box) box.innerHTML = `
    ${svgRoute( + (state.analysis?.candidates || []).filter((c) => state.selected.has(c.id)).slice(0, 5), + )}
    路线图绘制出错,已用方位示意:${esc(err.message)}
    `; + }); + if (wasHidden) loadStayPlan(); + } else { + $('#planExtra').classList.add('hidden'); + } + if (showFollow) { + $('#followupCount').textContent = (state.planRound || 1) > 1 + ? `当前是第 ${state.planRound} 版,可以继续改` + : '会基于当前方案重写完整版本'; + $('#followupBtn').disabled = false; + } + const ctxSuffix = state.ctxInfo ? ` · ${state.ctxInfo}` : ''; + $('#outputMeta').textContent = errMsg + ? '生成中断' + : (showFollow && (state.planRound || 1) > 1 + ? `第 ${state.planRound} 版 · 生成完成${ctxSuffix}` + : `生成完成${ctxSuffix}`); + btn.disabled = false; + $('#followupBtn').disabled = false; + if (errMsg) toast(`生成出错:${errMsg}`, 'err'); + else toast(isFollowUp ? `已更新到第 ${state.planRound} 版` : '行程规划已生成', 'ok'); +} + +// ------------------------------------------------------- 版本与保存 +function loadSavedPlans() { + try { return JSON.parse(localStorage.getItem('tripsense.saved') || '[]') || []; } catch { return []; } +} + +/** 当前正在看的方案文本(可能是历史版),复制/下载/保存都以它为准 */ +function currentPlanText() { + if (state.viewing != null && state.planVersions[state.viewing]) return state.planVersions[state.viewing].text; + return state.planText; +} + +/** 输出头的版本下拉:本次会话各版 + 本地保存的方案,一个控件完成切换与载入 */ +function renderVersionBar() { + const sel = $('#versionSelect'); + if (!sel) return; + const list = state.planVersions; + const saved = loadSavedPlans(); + if (!list.length && !saved.length) { + sel.classList.add('hidden'); + $('#comparePlan').classList.add('hidden'); + $('#savePlan').classList.add('hidden'); + return; + } + let html = ''; + if (list.length) { + html += '' + list.map((v, i) => + ``).join('') + ''; + } + if (saved.length) { + html += '' + saved.map((s, i) => { + const d = new Date(s.at || Date.now()); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return ``; + }).join('') + ''; + } + sel.innerHTML = html; + if (list.length) sel.value = `v${list.length - 1}`; + sel.classList.remove('hidden'); + $('#comparePlan').classList.toggle('hidden', list.length < 2); + $('#savePlan').classList.toggle('hidden', !list.length); +} + +function toggleCompare(force) { + const wrap = $('#compareWrap'); + const out = $('#planOutput'); + const open = typeof force === 'boolean' ? force : wrap.classList.contains('hidden'); + if (!open) { + wrap.classList.add('hidden'); + out.classList.remove('hidden'); + $('#comparePlan').textContent = '对比上一版'; + return; + } + const list = state.planVersions; + const idx = state.viewing != null ? state.viewing : list.length - 1; + if (idx < 1) return toast('至少要有两版才能对比', 'err'); + const cur = list[idx]; + const prev = list[idx - 1]; + $('#compareLeftHead').textContent = `第 ${prev.n} 版${prev.needs ? ' · ' + prev.needs.slice(0, 14) : '(初版)'}`; + $('#compareRightHead').textContent = `第 ${cur.n} 版${cur.needs ? ' · ' + cur.needs.slice(0, 14) : ''}`; + $('#compareLeft').innerHTML = mdToHtml(prev.text); + $('#compareRight').innerHTML = mdToHtml(cur.text); + wrap.classList.remove('hidden'); + out.classList.add('hidden'); + $('#comparePlan').textContent = '退出对比'; +} + +// ------------------------------------------------------------- 设置弹窗 +function openSettings() { + $('#aiBase').value = state.ai.baseUrl || state.config?.ai.baseUrl || ''; + $('#aiModel').value = state.ai.model || state.config?.ai.model || ''; + $('#aiKey').value = state.ai.apiKey || ''; + const amapInput = $('#amapKey'); + if (amapInput) amapInput.value = state.ai.amapKey || ''; + $('#promptPreview').textContent = state.config?.systemPrompt || '(配置未加载)'; + $('#settingsModal').classList.remove('hidden'); +} +function closeSettings() { $('#settingsModal').classList.add('hidden'); } + +/** + * 浏览器只在「安全上下文」里允许 geolocation:HTTPS 或 localhost / 127.0.0.1。 + * 用局域网 IP(http://192.168.x.x:8788)打开时必然被拒,报错还是英文的 + * "Only secure origins are allowed",所以这里提前判断、提前解释。 + */ +function isSecureOrigin() { + if (typeof window.isSecureContext === 'boolean') return window.isSecureContext; + return location.protocol === 'https:' + || /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname); +} + +// ------------------------------------------------------------ 表单记忆 +// 出发地、日期、时长、半径、排除同城、交通与兴趣都存本地, +// 刷新 / 从手机切回来时不必重新填一遍。日期会校验是否已过期。 +const FORM_KEY = 'tripsense.form'; + +function loadForm() { + try { return JSON.parse(localStorage.getItem(FORM_KEY) || 'null') || null; } catch { return null; } +} + +function saveForm() { + const el = (id) => $(id); + const form = { + origin: state.origin ? { + name: state.origin.name, + label: state.origin.label, + lat: state.origin.lat, + lon: state.origin.lon, + admin1: state.origin.admin1 || '', + country: state.origin.country || '', + } : null, + originText: el('#originInput') ? el('#originInput').value.trim() : '', + startDate: el('#startDate') ? el('#startDate').value : '', + endDate: el('#endDate') ? el('#endDate').value : '', + maxHours: el('#maxHours') ? el('#maxHours').value : '', + radius: el('#radius') ? el('#radius').value : '', + excludeLocal: el('#excludeLocal') ? el('#excludeLocal').checked : true, + transport: [...state.transport], + interests: [...state.interests], + lodging: state.lodging, + lodgingCustom: state.lodgingCustom, + }; + try { localStorage.setItem(FORM_KEY, JSON.stringify(form)); } catch { /* 隐私模式写不进就算了 */ } +} + +let formTimer = null; +function saveFormSoon() { + clearTimeout(formTimer); + formTimer = setTimeout(saveForm, 400); +} + +// ------------------------------------------------------------------ init +function init() { + const today = localToday(); + const saved = loadForm(); + + // 日期默认「今天+3」;存过且没过期就沿用,不必每次刷新重选 + const okDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(String(d || '')) && String(d) >= today; + let start = saved && okDate(saved.startDate) ? saved.startDate : addDays(today, 3); + let end = saved && okDate(saved.endDate) ? saved.endDate : addDays(today, 4); + if (end < start) end = start; + $('#startDate').value = start; + $('#endDate').value = end; + $('#startDate').min = today; + $('#endDate').min = today; + ['#startDate', '#endDate'].forEach((sel) => $(sel).addEventListener('change', () => { + state.datesTouched = true; + $('#windowHint').classList.add('hidden'); + saveForm(); + })); + + // 恢复其余选项 + if (saved) { + if (saved.maxHours) { + $('#maxHours').value = saved.maxHours; + $('#hoursVal').textContent = `${saved.maxHours} 小时`; + } + if (saved.radius) $('#radius').value = String(saved.radius); + if (typeof saved.excludeLocal === 'boolean') $('#excludeLocal').checked = saved.excludeLocal; + if (Array.isArray(saved.transport) && saved.transport.length) { + const keep = saved.transport.filter((x) => TRANSPORTS.includes(x)); + if (keep.length) state.transport = new Set(keep); + } + if (Array.isArray(saved.interests)) { + state.interests = new Set(saved.interests.filter((x) => INTERESTS.includes(x))); + } + if (saved.lodging && LODGING.some((l) => l.key === saved.lodging)) state.lodging = saved.lodging; + if (saved.lodgingCustom && typeof saved.lodgingCustom === 'object') { + state.lodgingCustom = { + min: saved.lodgingCustom.min ?? '', + max: saved.lodgingCustom.max ?? '', + }; + } + if (saved.origin && Number.isFinite(saved.origin.lat) && Number.isFinite(saved.origin.lon)) { + state.origin = { ...saved.origin }; + $('#originInput').value = saved.origin.label || saved.origin.name || ''; + // 非安全上下文时保留「定位不可用」的提示,不要被覆盖 + if (isSecureOrigin()) { + $('#originHint').innerHTML = `已定位:${esc(state.origin.label)}(${Number(state.origin.lat).toFixed(3)}, ${Number(state.origin.lon).toFixed(3)})`; + } + } else if (saved.originText) { + $('#originInput').value = saved.originText; + } + } + + $('#maxHours').addEventListener('input', (e) => { + $('#hoursVal').textContent = `${e.target.value} 小时`; + saveFormSoon(); + }); + + // 其余控件变更即存 + ['#radius', '#excludeLocal'].forEach((sel) => $(sel).addEventListener('change', saveForm)); + $('#originInput').addEventListener('change', saveForm); + + const onTransport = (v) => { + if (v === '不限') { state.transport.clear(); state.transport.add('不限'); } + else { + state.transport.delete('不限'); + if (state.transport.has(v)) state.transport.delete(v); else state.transport.add(v); + if (!state.transport.size) state.transport.add('不限'); + } + renderChips($('#transportChips'), TRANSPORTS, state.transport, onTransport); + saveForm(); + }; + renderChips($('#transportChips'), TRANSPORTS, state.transport, onTransport); + const onInterest = (v) => { + if (state.interests.has(v)) state.interests.delete(v); else state.interests.add(v); + renderChips($('#interestChips'), INTERESTS, state.interests, onInterest); + saveForm(); + }; + renderChips($('#interestChips'), INTERESTS, state.interests, onInterest); + renderLodging(); + ['#lodgingMin', '#lodgingMax'].forEach((sel) => { + const el = $(sel); + if (el) el.addEventListener('input', onLodgingInput); + }); + + $('#quickNeeds').innerHTML = QUICK_NEEDS.map((x) => ``).join(''); + $$('#quickNeeds button').forEach((b) => b.addEventListener('click', () => { + const ta = $('#needs'); + const cur = ta.value.trim(); + ta.value = cur ? `${cur};${b.dataset.v}` : b.dataset.v; + ta.focus(); + })); + + $('#aiPresets').innerHTML = AI_PRESETS.map((p, i) => ``).join(''); + $$('#aiPresets button').forEach((b) => b.addEventListener('click', () => { + const p = AI_PRESETS[Number(b.dataset.i)]; + $('#aiBase').value = p.base; + $('#aiModel').value = p.model; + toast(`已填入 ${p.name} 预设,还需填写 API Key`, 'ok'); + })); + + // 出发地 + $('#originInput').addEventListener('input', (e) => { + state.origin = null; + saveFormSoon(); + const q = e.target.value.trim(); + clearTimeout(suggestTimer); + if (!q) { hideSuggest(); return; } + suggestTimer = setTimeout(async () => { + try { + const { list, used, amapError, meteoError, source } = await geocodeLoose(q); + showSuggest(list, q, used, { amapError, meteoError, source }); + } catch { hideSuggest(); } + }, 340); + }); + document.addEventListener('click', (e) => { + if (!e.target.closest('.origin-row') && !e.target.closest('#suggestBox')) hideSuggest(); + }); + // 非安全上下文(局域网 http)时,提前把定位按钮和说明改成「不可用」的状态, + // 而不是等用户点了再弹一条英文报错 + if (!isSecureOrigin()) { + const btn = $('#geoBtn'); + btn.classList.add('geo-off'); + btn.title = '浏览器只允许 HTTPS 或 localhost 页面调用定位'; + const hint = $('#originHint'); + hint.classList.add('hint-warn'); + hint.innerHTML = '当前通过局域网 http 地址访问,浏览器不允许网页定位。直接输入地名即可(下拉里选);确实需要定位请改用 HTTPS,见 README「定位与 HTTPS」。'; + } + + $('#geoBtn').addEventListener('click', () => { + if (!isSecureOrigin()) { + return toast('局域网 http 页面不能调用定位:请直接输入地名,或改用 HTTPS 访问', 'err'); + } + if (!navigator.geolocation) return toast('当前浏览器不支持定位,请手动输入地名', 'err'); + toast('正在获取位置…'); + navigator.geolocation.getCurrentPosition(async (pos) => { + const { latitude, longitude } = pos.coords; + try { + const r = await fetch(`/api/reverse?lat=${latitude}&lon=${longitude}`).then((x) => x.json()); + if (r.place) setOrigin(r.place); + else toast('反向地理编码失败,请手动输入地名', 'err'); + } catch (err) { toast(`定位解析失败:${err.message}`, 'err'); } + }, (err) => { + // 原始 err.message 是 "Only secure origins are allowed..." 之类的英文,对用户没意义 + const reason = err.code === 1 ? '你拒绝了位置权限' + : err.code === 3 ? '定位超时' + : '浏览器未返回位置'; + toast(`${reason},请手动输入地名`, 'err'); + }, + { enableHighAccuracy: false, timeout: 12000, maximumAge: 300000 }); + }); + + $('#analyzeBtn').addEventListener('click', () => runAnalyze()); + $('#backBtn1').addEventListener('click', () => gotoStep(1)); + $('#backBtn2').addEventListener('click', () => gotoStep(2)); + $('#toStep3').addEventListener('click', () => { + if (!state.selected.size) return toast('请先在清单中勾选目的地', 'err'); + renderSelected(); + gotoStep(3); + }); + $$('#sortSeg button').forEach((b) => b.addEventListener('click', () => { + $$('#sortSeg button').forEach((x) => x.classList.remove('active')); + b.classList.add('active'); + state.sort = b.dataset.sort; + renderCards(); + })); + $('#onlyRec').addEventListener('change', (e) => { state.onlyRec = e.target.checked; renderCards(); }); + $('#onlyNiche').addEventListener('change', (e) => { state.onlyNiche = e.target.checked; renderCards(); }); + $('#compareBtn').addEventListener('click', toggleCompare); + + $('#planBtn').addEventListener('click', () => runPlan()); + + // 生成之后可以继续提要求,让模型在上一版上改,而不是只能推倒重来 + $('#followupBtn').addEventListener('click', async () => { + if (state.planning) return; + const extra = $('#followupInput').value.trim(); + if (!extra) { $('#followupInput').focus(); return toast('先写一句想怎么调整', 'err'); } + $('#followupInput').value = ''; + await runPlan({ followUp: extra }); + }); + $('#followupInput').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); $('#followupBtn').click(); } + }); + $('#resetPlan').addEventListener('click', () => { + if (!state.basePlan) return toast('还没有可回退的初版', 'err'); + state.planText = state.basePlan; + state.planRound = 1; + paintPlan(); + $('#outputMeta').textContent = '已回到初版'; + $('#followupCount').textContent = '会基于当前方案重写完整版本'; + toast('已回到初版方案', 'ok'); + }); + $('#versionSelect').addEventListener('change', (e) => { + const v = e.target.value; + if (v === '__clear') { + localStorage.removeItem('tripsense.saved'); + toast('已清空保存的方案', 'ok'); + renderVersionBar(); + return; + } + if (v.startsWith('v')) { + const i = Number(v.slice(1)); + if (!state.planVersions[i]) return; + state.viewing = i; + toggleCompare(false); + paintPlan(); + const ver = state.planVersions[i]; + $('#outputMeta').textContent = `第 ${ver.n} 版${ver.needs ? ' · ' + ver.needs.slice(0, 16) : ''}`; + return; + } + if (v.startsWith('s')) { + const s = loadSavedPlans()[Number(v.slice(1))]; + if (!s) return; + state.viewing = null; + state.planText = s.text; + state.basePlan = s.text; + state.planVersions = [{ n: 1, needs: s.needs || '', text: s.text, at: s.at || Date.now() }]; + state.planRound = 1; + toggleCompare(false); + $('#planOutput').classList.remove('empty'); + paintPlan(s.text); + $('#planFollowup').classList.remove('hidden'); + $('#outputMeta').textContent = '已载入保存的方案'; + renderVersionBar(); + toast('已载入保存的方案,可以继续调整', 'ok'); + } + }); + $('#comparePlan').addEventListener('click', () => toggleCompare()); + $('#savePlan').addEventListener('click', () => { + const list = state.planVersions; + const idx = state.viewing != null ? state.viewing : list.length - 1; + const v = list[idx]; + if (!v) return toast('还没有可保存的方案', 'err'); + const saved = loadSavedPlans(); + saved.unshift({ at: Date.now(), origin: state.analysis?.origin?.label || '', needs: v.needs, text: v.text }); + const trimmed = saved.slice(0, 20); + try { localStorage.setItem('tripsense.saved', JSON.stringify(trimmed)); } + catch { return toast('本地存储已满,先清空一些已保存方案', 'err'); } + renderVersionBar(); + toast(`已保存到本地(共 ${trimmed.length} 个)`, 'ok'); + }); + + $('#copyBtn').addEventListener('click', async () => { + const txt = currentPlanText(); + if (!txt) return toast('还没有可复制的内容', 'err'); + try { await navigator.clipboard.writeText(txt); toast('已复制 Markdown', 'ok'); } + catch { toast('复制失败,请手动选中文本', 'err'); } + }); + $('#downloadBtn').addEventListener('click', () => { + const txt = currentPlanText(); + if (!txt) return toast('还没有可下载的内容', 'err'); + const blob = new Blob([txt], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `TripSense-行程规划-${state.analysis?.timeRange?.start || 'plan'}.md`; + a.click(); + URL.revokeObjectURL(url); + toast('已下载 .md 文件', 'ok'); + }); + + $('#openSettings').addEventListener('click', openSettings); + $('#closeSettings').addEventListener('click', closeSettings); + $('#cancelSettings').addEventListener('click', closeSettings); + $('#modalMask').addEventListener('click', closeSettings); + $('#saveSettings').addEventListener('click', () => { + const prevAmap = state.ai.amapKey || ''; + state.ai.baseUrl = $('#aiBase').value.trim(); + state.ai.model = $('#aiModel').value.trim(); + state.ai.apiKey = $('#aiKey').value.trim(); + const amapEl = $('#amapKey'); + if (amapEl) state.ai.amapKey = amapEl.value.trim(); + state.ai.forceDemo = false; + saveAI(); + updateAIStatus(); + closeSettings(); + const changed = (state.ai.amapKey || '') !== prevAmap; + if (state.analysis) { + if (changed) { + // 旧结果是用旧配置跑出来的,继续显示旧报错会误导 + $('#alertBox').innerHTML = '
    ℹ️ 数据源设置已更新。点 应用新的高德 Key。
    '; + $('#alertBox').classList.remove('hidden'); + bindAlertActions(); + } else { + renderAlerts(state.analysis); + } + } + if (changed && state.ai.amapKey) toast('高德 Key 已保存,点提示里的「重新分析一次」即可生效', 'ok'); + else if (changed && !state.ai.amapKey) toast('已清除高德 Key,恢复免密钥数据源', 'ok'); + else toast('设置已保存', 'ok'); + }); + $('#testAmap').addEventListener('click', async () => { + const key = ($('#amapKey').value || '').trim(); + const out = $('#amapTestResult'); + if (!key) { + out.textContent = '先填 Key 再测'; + out.className = 'field-hint inline bad'; + return; + } + const btn = $('#testAmap'); + btn.disabled = true; + out.textContent = '测试中…'; + out.className = 'field-hint inline'; + try { + const r = await fetch(`/api/geocode?name=${encodeURIComponent('杭州')}`, { headers: { 'X-AMAP-Key': key } }).then((x) => x.json()); + if (r.source === '高德' && r.results?.length) { + // 顺便确认静态地图能不能出图——这是「真实地图图层」的唯一依赖。 + // 用 probe 分别试三种参数组合,能直接定位是哪个参数被高德拒绝。 + let mapMsg = ''; + try { + const p = await fetch('/api/staticmap?probe=1&pts=30.24,120.15&w=320&h=200', { + headers: { 'X-AMAP-Key': key }, + }).then((x) => x.json()); + const arr = p.probe || []; + const ok = arr.filter((x) => x.ok).map((x) => x.mode); + const errOf = (m) => (arr.find((x) => x.mode === m) || {}).error || ''; + if (ok.includes('full')) mapMsg = ' · 地图可用'; + else if (ok.includes('full-encode')) mapMsg = ' · 地图可用(需编码 URL)'; + else if (ok.includes('markers')) mapMsg = ' · 标记可用,连线被拒'; + else if (ok.includes('base')) mapMsg = ' · 底图可用但标记被拒'; + else if (ok.includes('base-encode')) mapMsg = ' · 仅编码底图可用'; + else mapMsg = ` · 地图不可用:${String(errOf('base')).slice(0, 50)}`; + } catch { + mapMsg = ' · 地图图层检测失败'; + } + out.textContent = `可用 · 命中「${r.results[0].label}」${mapMsg}`; + out.className = 'field-hint inline good'; + } else if (r.amapError) { + out.textContent = `不可用:${r.amapError}`; + out.className = 'field-hint inline bad'; + } else { + out.textContent = '没返回高德结果,确认 Key 是「Web服务」类型'; + out.className = 'field-hint inline bad'; + } + } catch (e) { + out.textContent = `测试失败:${e.message}`; + out.className = 'field-hint inline bad'; + } finally { + btn.disabled = false; + } + }); + + // 住宿数据源检测:装了 rgh 并登录就能拿到实时报价 + const provBtn = $('#checkProviders'); + if (provBtn) provBtn.addEventListener('click', async () => { + const out = $('#providerResult'); + provBtn.disabled = true; + out.textContent = '检测中…'; + out.className = 'field-hint inline'; + try { + // 用输入框当前值:还没点保存也能测出来,别让用户以为填了不生效 + const keyEl = $('#amapKey'); + const key = String((keyEl && keyEl.value) || state.ai.amapKey || '').trim(); + const r = await fetch('/api/hotel-providers', { + headers: key ? { 'X-AMAP-Key': key } : {}, + }).then((x) => x.json()); + const rg = r.rollinggo || {}; + const amap = r.amap + ? `高德 Key 已配置${r.amapSource === '页面设置' ? '(页面填写)' : ''}` + : '未配高德 Key'; + if (rg.ready) { + out.textContent = `RollingGo 可用(实时报价)· ${amap}`; + out.className = 'field-hint inline good'; + } else if (rg.installed) { + out.textContent = `${rg.reason || 'rgh 未登录'} · ${amap}`; + out.className = 'field-hint inline bad'; + } else { + out.textContent = `未安装 rgh(npm i -g @rollinggo/hotel)· ${amap}`; + out.className = 'field-hint inline bad'; + } + } catch (e) { + out.textContent = `检测失败:${e.message}`; + out.className = 'field-hint inline bad'; + } finally { + provBtn.disabled = false; + } + }); + + $('#testAi').addEventListener('click', async () => { + const out = $('#aiTestResult'); + const btn = $('#testAi'); + const baseUrl = $('#aiBase').value.trim(); + const apiKey = $('#aiKey').value.trim(); + const model = $('#aiModel').value.trim(); + if (!baseUrl) { + out.textContent = '先填接口地址'; + out.className = 'field-hint inline bad'; + return; + } + btn.disabled = true; + out.textContent = '测试中…'; + out.className = 'field-hint inline'; + try { + const r = await fetch('/api/ai-test', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...aiHeaders() }, + body: JSON.stringify({ baseUrl, apiKey, model }), + }).then((x) => x.json()); + if (r.ok) { + const bits = [`可用 · ${r.ms}ms`]; + if (r.model) bits.push(r.model); + if (r.reasoningOnly) bits.push('只返回思考内容'); + if (r.note) bits.push(r.note); + out.textContent = bits.join(' · '); + out.className = 'field-hint inline good'; + } else { + out.textContent = r.message || '不可用'; + out.className = 'field-hint inline bad'; + } + } catch (e) { + out.textContent = `测试失败:${e.message}`; + out.className = 'field-hint inline bad'; + } finally { + btn.disabled = false; + } + }); + + $('#demoMode').addEventListener('click', () => { + state.ai.forceDemo = !state.ai.forceDemo; + saveAI(); + updateAIStatus(); + closeSettings(); + toast(state.ai.forceDemo ? '已切换为演示模式' : '已取消强制演示模式', 'ok'); + }); + $('#copyPrompt').addEventListener('click', async () => { + try { await navigator.clipboard.writeText(state.config?.systemPrompt || ''); toast('预设提示词已复制', 'ok'); } + catch { toast('复制失败', 'err'); } + }); + + // 载入服务端配置 + fetch('/api/config').then((r) => r.json()).then((cfg) => { + state.config = cfg; + if (!state.ai.baseUrl) state.ai.baseUrl = cfg.ai.baseUrl; + if (!state.ai.model) state.ai.model = cfg.ai.model; + $('#promptPreview').textContent = cfg.systemPrompt; + updateAIStatus(); + }).catch(() => { + $('#aiStatus').innerHTML = '配置读取失败'; + $('#promptPreview').textContent = '(无法读取服务端配置)'; + $('#aiInline').innerHTML = '服务端配置读取失败,可点击右上角「设置」手动填写接口地址、密钥与高德 Key。'; + }); + + renderVersionBar(); +} + +async function runAnalyze(force) { + // 注意:事件监听会把 Event 对象传进来,Boolean(event) 恒为 true, + // 会让每次点击都变成「强制刷新」,缓存永远不命中。这里只认严格的 true。 + const forceRefresh = force === true; + const start = $('#startDate').value; + const end = $('#endDate').value; + if (!state.origin) return toast('请先选择出发地:输入地名后从下拉选择,或点击「使用当前位置」', 'err'); + if (!start || !end) return toast('请选择出行起止日期', 'err'); + if (end < start) return toast('结束日期不能早于开始日期', 'err'); + const btn = $('#analyzeBtn'); + btn.disabled = true; + state.analysis = null; + const wrap = $('#progressWrap'); + wrap.classList.remove('hidden'); + setProgress(3, '开始分析…', 'origin'); + const payload = { + origin: state.origin, + startDate: start, + endDate: end, + maxOneWayHours: Number($('#maxHours').value), + radiusKm: Number($('#radius').value), + excludeLocal: $('#excludeLocal').checked, + transportPrefs: [...state.transport], + interests: [...state.interests], + // 强制刷新:跳过服务端缓存,重新打数据源 + forceRefresh, + }; + let errMsg = null; + let got = false; + try { + await ssePost('/api/analyze', payload, dataHeaders(), (ev) => { + if (ev.type === 'progress') setProgress(ev.pct, ev.text, ev.stage); + else if (ev.type === 'result') { state.analysis = ev.data; got = true; } + else if (ev.type === 'error') errMsg = ev.message; + }); + } catch (e) { errMsg = e.message; } + btn.disabled = false; + if (errMsg) toast(`分析失败:${errMsg}`, 'err'); + if (got && state.analysis) { + const cache = state.analysis.cache || {}; + if (cache.hit) { + const mins = Math.max(1, Math.round((cache.ageMs || 0) / 60000)); + setProgress(100, `用了缓存(${mins} 分钟前的数据)`, 'done'); + toast(`已复用 ${mins} 分钟前的分析结果,天气与路况不会更旧`, 'ok'); + } else { + setProgress(100, '分析完成', 'done'); + } + renderStep2(); + gotoStep(2); + setTimeout(() => wrap.classList.add('hidden'), 800); + renderCacheBar(); + } else { + wrap.classList.add('hidden'); + } +} + +/** + * 缓存提示条:命中缓存时给一个「重新获取最新数据」的出口。 + * 用户改了条件但结果看着像旧的时,可以直接强刷。 + */ +function renderCacheBar() { + const bar = $('#cacheBar'); + if (!bar) return; + const cache = state.analysis?.cache || {}; + if (!cache.hit) { bar.classList.add('hidden'); return; } + const mins = Math.max(1, Math.round((cache.ageMs || 0) / 60000)); + bar.classList.remove('hidden'); + bar.innerHTML = `这份清单来自 ${mins} 分钟前的缓存(同一组条件不会重复请求数据源)。 + `; + $('#forceRefresh').addEventListener('click', () => runAnalyze(true)); +} + +init(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..aa066b3 --- /dev/null +++ b/public/index.html @@ -0,0 +1,324 @@ + + + + + + + 行知 · 周边出行顾问 | TripSense + + + +
    +
    +
    + ◈ +
    行知TripSense · 周边出行顾问
    +
    +
    + 读取配置中… + +
    +
    +
    + +
    +
    +
    +
    周边出行 · 按时间 · 按位置 · 按天气与交通
    +

    这个时间,去哪儿最合适?

    +

    填入出行时间与出发地,系统检索周边目的地,叠加逐日天气、空气质量、公路与铁路可达性,给出可解释的备选清单。再补充你的具体需求,由 AI 生成可执行的逐日行程。

    +
    +
      +
    1. 1出行条件
    2. +
    3. 2备选清单
    4. +
    5. 3行程规划
    6. +
    +
    + + +
    +
    +

    ① 出行条件

    +

    出发地支持输入联想或浏览器定位;时间范围决定天气查询窗口与行程节奏。

    +
    + +
    +
    + +
    + + +
    + +

    可直接输入地名并从下拉中选择,或点击定位(浏览器会请求位置权限)。

    +
    + +
    + + +
    +
    + + +
    + + + +
    + + +
    0.5h6h
    +
    +
    + + +
    + +
    + +
    + +
    + +
    +
    +
    + +
    +
    +
    + +
    + + 首次分析需检索多个数据源,通常 5~15 秒。 +
    + + +
    + + +
    +
    +
    +

    ② 备选目的地清单

    +

    按适配分排序,标注每一项的数据来源与估算内容。

    +
    +
    + + +
    +
    + + + + + + +
    +
    +

    出行方位罗盘 点击圆点定位卡片

    +
    +
    + 适配分 ≥ 60 + 45 ~ 59 + < 45 + 方位=相对出发地,半径=距离 +
    +
    +
    +
    + +
    +
    + + + + +
    + + + + 已选 0 个 +
    + + + +
    +
    +
    + + +
    +
    +
    +

    ③ 具体需求与 AI 行程规划

    +

    清单数据 + 你的需求 + 本站预设提示词 → 任意 OpenAI 兼容接口(未配置时使用演示模式)。

    +
    + +
    + +
    +
    + +
    + + + + +
    + 住宿价位 +
    +
    + +

    + +
    +
    + +

    生成方案后,会据此给出住宿建议与路线示意图。

    +
    + +
    +
    + 等待生成 +
    + + + + + +
    +
    + +
    +
    +
    ✦
    +

    方案会显示在这里

    +

    包含方案总览、逐日安排表、天气与装备、交通方案、预算粗估、出发前清单和风险备选。

    +
    +
    + + +
    +
    + + + +
    + +
    +
    行知 · TripSense — 周边出行决策原型
    +
    数据:Open-Meteo(天气/空气质量/地理编码)· OpenStreetMap / Photon(周边地点)· OSRM(公路)· 内置知名 + 小众地点库 · 可选高德 / 铁路接口 · OpenAI 兼容模型
    +
    天气为预报或历史气候参考,铁路与部分路网耗时为估算;票价、班次、门票、开放时间请临近出发前在官方渠道核对。本工具不构成出行安全承诺。
    +
    +
    + + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..b30aa7a --- /dev/null +++ b/public/styles.css @@ -0,0 +1,696 @@ +/* ============================================================ + 行知 · TripSense + 简约主题:白底 + 中性灰 + 单一蓝色强调,细边框、轻阴影、圆角。 + 零依赖、无第三方 UI 框架。 + ============================================================ */ + +:root { + --paper: #ffffff; + --paper-2: #f6f7f9; + --sheet: #ffffff; + --sheet-2: #fafbfc; + --ink: #171a1f; + --ink-soft: #4d545c; + --muted: #8a929b; + --rule: #e8eaed; + --rule-strong: #d5d9de; + --accent: #2f6fed; + --accent-dark: #1f57c9; + --accent-soft: #eef3fe; + --moss: #2e7d5b; + --gold: #a1741f; + --bad: #c0392b; + --good: #2e7d5b; + --mid: #a1741f; + --low: #c0392b; + --shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 10px 28px rgba(16, 24, 40, .06); + --shadow-sm: 0 1px 2px rgba(16, 24, 40, .06); + --radius: 10px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif; + /* 简约模式:标题也用无衬线,不再用宋体 */ + --serif: var(--font); + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + font-family: var(--font); + color: var(--ink); + background: var(--paper); + -webkit-font-smoothing: antialiased; + line-height: 1.6; + font-size: 14px; +} +.wrap { max-width: 1180px; margin: 0 auto; padding: 0 24px; } +button, input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; } +button { cursor: pointer; } +a { color: var(--accent); } + +/* ---------------------------------------------------------- topbar */ +.topbar { + position: sticky; top: 0; z-index: 40; + background: var(--sheet); + border-bottom: 1px solid var(--rule); +} +.topbar-inner { height: 62px; display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.brand { display: flex; align-items: center; gap: 11px; } +.brand-mark { + width: 32px; height: 32px; border-radius: 9px; + display: grid; place-items: center; + background: var(--accent); color: #fff; font-size: 17px; +} +.brand-text { display: flex; flex-direction: column; line-height: 1.25; } +.brand-text strong { font-family: var(--font); font-size: 17px; font-weight: 700; letter-spacing: .02em; color: var(--ink); } +.brand-text span { font-size: 10.5px; color: var(--muted); letter-spacing: .02em; } +.topbar-meta { display: flex; align-items: center; gap: 12px; } +.status-pill { + display: inline-flex; align-items: center; gap: 7px; + font-size: 11.5px; color: var(--ink-soft); + background: var(--paper-2); border: 1px solid var(--rule); + padding: 6px 11px; border-radius: 999px; +} +.status-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--gold); } +.status-pill.live i { background: var(--good); } + +/* ---------------------------------------------------------- buttons */ +.btn-primary, .btn-outline, .btn-ghost { + border-radius: 8px; border: 1px solid var(--rule-strong); + transition: .15s ease; font-weight: 600; +} +.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); padding: 11px 20px; box-shadow: none; } +.btn-primary:hover { background: var(--accent-dark); border-color: var(--accent-dark); box-shadow: 0 4px 12px rgba(47, 111, 237, .28); } +.btn-primary:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; } +.btn-primary span { margin-left: 6px; } +.btn-outline { background: var(--sheet); color: var(--ink-soft); border-color: var(--rule-strong); padding: 10px 16px; } +.btn-outline:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } +.btn-ghost { background: transparent; color: var(--ink-soft); border-color: var(--rule); padding: 7px 13px; } +.btn-ghost:hover { background: var(--paper-2); border-color: var(--rule-strong); } +.topbar .btn-ghost { color: var(--ink-soft); border-color: var(--rule-strong); } +.topbar .btn-ghost:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } +.sm { padding: 6px 12px; font-size: 12px; } +.full { width: 100%; } +.icon-btn { border: 0; background: transparent; color: var(--muted); font-size: 17px; padding: 4px 8px; border-radius: 0; } +.icon-btn:hover { background: var(--paper-2); color: var(--ink); } + +/* ---------------------------------------------------------- hero */ +.hero { + padding: 42px 0 26px; display: flex; justify-content: space-between; + align-items: flex-end; gap: 40px; flex-wrap: wrap; + border-bottom: 1px solid var(--rule); margin-bottom: 24px; +} +.hero-text { max-width: 700px; } +.eyebrow { font-family: var(--mono); font-size: 11px; letter-spacing: .12em; color: var(--accent); margin-bottom: 10px; } +.hero h1 { margin: 0 0 12px; font-family: var(--serif); font-size: clamp(28px, 3.6vw, 44px); line-height: 1.24; letter-spacing: .02em; } +.hero p { margin: 0; color: var(--ink-soft); font-size: 14px; max-width: 660px; } +.stepper { list-style: none; display: flex; gap: 9px; margin: 0; padding: 0; } +.stepper li { + display: flex; align-items: center; gap: 8px; position: relative; + padding: 7px 13px 7px 8px; border: 1px solid var(--rule-strong); + background: var(--sheet); color: var(--muted); font-family: var(--mono); font-size: 11.5px; + transition: .18s ease; border-radius: 0; +} +.stepper li + li::before { content: ''; position: absolute; left: -10px; top: 50%; width: 9px; height: 1px; background: var(--rule-strong); } +.stepper li b { width: 20px; height: 20px; display: grid; place-items: center; background: var(--paper-2); font-size: 10px; color: var(--ink-soft); } +.stepper li.active { background: var(--ink); border-color: var(--ink); color: #fbf8f0; font-weight: 600; } +.stepper li.active b { background: var(--accent); color: #fff; } +.stepper li.done { color: var(--moss); border-color: #a9bda3; } + +/* ---------------------------------------------------------- cards */ +.card { + background: var(--sheet); border: 1px solid var(--rule-strong); + border-radius: var(--radius); box-shadow: var(--shadow); + padding: 26px 28px; margin-bottom: 22px; +} +.step-panel { display: none; } +.step-panel.active { display: block; animation: fadeUp .35s ease both; } +@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +.card-head { margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--rule); } +.card-head.row { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; } +.card-head h2 { margin: 0 0 6px; font-family: var(--serif); font-size: 21px; letter-spacing: .01em; } +.card-head h2 .num { color: var(--accent); margin-right: 6px; font-family: var(--mono); } +.card-head p { margin: 0; color: var(--muted); font-size: 13px; } +.head-actions { display: flex; gap: 9px; } + +/* ---------------------------------------------------------- form */ +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 22px; } +.field.span2 { grid-column: 1 / -1; } +.field label { display: block; font-weight: 600; font-size: 12.5px; margin-bottom: 8px; } +.field label em { font-style: normal; color: var(--accent); font-weight: 600; margin-left: 6px; font-size: 12px; } +.label-note { font-weight: 400; color: var(--muted); font-size: 12px; margin-left: 6px; } +.field input[type="text"], .field input[type="date"], .field select, .field textarea, .modal-box input { + width: 100%; padding: 10px 13px; border: 1px solid var(--rule-strong); + border-radius: var(--radius); background: #fffdf7; outline: none; transition: .15s; +} +.field input:focus, .field select:focus, .field textarea:focus, .modal-box input:focus { + border-color: var(--accent); box-shadow: 0 0 0 2px rgba(176, 74, 43, .14); +} +.field textarea { resize: vertical; min-height: 170px; line-height: 1.7; } +.origin-row { display: flex; gap: 10px; } +.origin-row input { flex: 1; } +.origin-row .btn-outline { white-space: nowrap; } +.hint { color: var(--muted); font-size: 12px; margin: 8px 0 0; } +/* 定位在非安全上下文不可用时的提示,用赭橙提醒但不至于像报错 */ +.hint-warn { color: var(--accent); border-left: 3px solid var(--accent); padding-left: 9px; line-height: 1.6; } +/* 分析结果缓存提示条:告知"这是缓存"并给一个强刷出口 */ +.cache-bar { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 9px 13px; margin: 10px 0; + background: var(--accent-soft); border: 1px solid #cdddfa; border-radius: var(--radius); + font-size: 12.5px; color: var(--accent-dark); +} +.cache-bar .link-btn { + border: 0; background: none; padding: 0; cursor: pointer; + color: var(--accent); font: inherit; font-weight: 700; + text-decoration: underline; text-underline-offset: 3px; +} +.cache-bar .link-btn:hover { color: var(--accent-dark); } +.btn-outline.geo-off { opacity: .5; border-style: dashed; cursor: not-allowed; } +.btn-outline.geo-off:hover { opacity: .5; border-color: var(--rule-strong); color: var(--ink-soft); } +.window-hint { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 9px 13px; background: var(--accent-soft); border: 1px solid #dcbfa9; + border-radius: var(--radius); font-family: var(--mono); font-size: 12px; color: var(--accent-dark); +} +.window-hint b { font-weight: 700; } +.window-hint button { border: 1px solid var(--accent); background: var(--sheet); color: var(--accent); border-radius: 0; padding: 3px 10px; font-family: var(--mono); font-size: 11px; font-weight: 600; } +.window-hint button:hover { background: var(--accent); color: #fff; } +/* 定位参照物:不设 relative 的话,联想下拉的 100% 会按更外层容器算,右边缘溢出卡片 */ +.origin-field { position: relative; } +.suggest { + position: absolute; z-index: 20; margin-top: 4px; left: 0; right: 0; width: auto; + background: var(--sheet); border: 1px solid var(--ink); border-radius: 0; + box-shadow: 5px 5px 0 rgba(32, 44, 41, .16); overflow: hidden; +} +.suggest-item { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid var(--rule); display: flex; justify-content: space-between; gap: 10px; } +.suggest-item:last-child { border-bottom: 0; } +.suggest-item:hover { background: var(--accent-soft); } +.suggest-item small { color: var(--muted); font-family: var(--mono); } +/* 查不到时的明确反馈,替代过去的静默隐藏 */ +.suggest-empty { padding: 12px 14px; font-size: 12px; color: var(--muted); line-height: 1.65; } +.suggest-note { padding: 8px 14px; font-family: var(--mono); font-size: 10.5px; color: var(--accent); background: var(--accent-soft); border-bottom: 1px solid var(--rule); } +input[type="range"] { width: 100%; accent-color: var(--accent); } +.range-scale { display: flex; justify-content: space-between; color: var(--muted); font-family: var(--mono); font-size: 11px; margin-top: 2px; } +.chips { display: flex; flex-wrap: wrap; gap: 7px; } +.chip { + padding: 6px 13px; border: 1px solid var(--rule-strong); border-radius: 0; + background: var(--sheet); color: var(--ink-soft); font-family: var(--mono); font-size: 12px; transition: .14s; +} +.chip:hover { border-color: var(--accent); color: var(--accent); } +.chip.on { background: var(--ink); border-color: var(--ink); color: #fbf8f0; font-weight: 600; } +.form-actions { margin-top: 24px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } + +/* ---------------------------------------------------------- progress */ +.progress-wrap { margin-top: 22px; padding: 16px 18px; background: var(--sheet-2); border: 1px solid var(--rule-strong); border-radius: var(--radius); } +.progress-head { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 12px; margin-bottom: 9px; } +.progress-head span { color: var(--ink-soft); } +.progress-head b { color: var(--accent); } +.progress-bar { height: 8px; background: var(--paper-2); border: 1px solid var(--rule); overflow: hidden; } +.progress-bar i { display: block; height: 100%; width: 0; background: repeating-linear-gradient(90deg, var(--accent) 0 10px, var(--accent-dark) 10px 12px); transition: width .35s ease; } +.progress-stages { display: flex; flex-wrap: wrap; gap: 7px 16px; margin-top: 12px; } +.progress-stages span { font-family: var(--mono); font-size: 11px; color: var(--muted); } +.progress-stages span.done { color: var(--moss); } +.progress-stages span.now { color: var(--accent); font-weight: 600; } + +/* ---------------------------------------------------------- alert */ +.alert { padding: 11px 15px; border: 1px solid; border-left: 4px solid; border-radius: 0; margin-bottom: 18px; font-size: 12.5px; } +.alert.warn { background: #f8ecdc; border-color: #d9b98e; border-left-color: var(--gold); color: #7d4d1c; } +.alert.info { background: #e9eee4; border-color: #a9bda3; border-left-color: var(--moss); color: #3f5a41; } + +/* ---------------------------------------------------------- result top */ +.result-top { display: grid; grid-template-columns: 400px 1fr; gap: 22px; margin-bottom: 22px; } +.compass-panel, .summary-panel { background: var(--sheet-2); border: 1px solid var(--rule-strong); border-radius: var(--radius); padding: 18px; } +.compass-panel h3 { margin: 0 0 6px; font-family: var(--serif); font-size: 14px; } +.compass-panel h3 span { font-weight: 400; color: var(--muted); font-size: 11px; margin-left: 6px; font-family: var(--mono); } +#compass svg { width: 100%; height: auto; display: block; } +.legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 8px; font-family: var(--mono); font-size: 10.5px; color: var(--muted); } +.legend span { display: inline-flex; align-items: center; gap: 5px; } +.lg { width: 9px; height: 9px; display: inline-block; } +.lg.good { background: var(--good); } .lg.mid { background: var(--mid); } .lg.low { background: var(--low); } +.legend-note { margin-left: auto; } +.summary-panel { display: flex; flex-direction: column; justify-content: center; } +.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.stat { padding: 12px 14px; background: var(--sheet); border: 1px solid var(--rule); border-radius: 0; } +.stat .k { font-family: var(--mono); font-size: 11px; color: var(--muted); } +.stat .v { font-family: var(--serif); font-size: 26px; font-weight: 700; color: var(--accent); line-height: 1.3; } +.stat .v small { font-family: var(--mono); font-size: 11px; font-weight: 500; color: var(--muted); margin-left: 3px; } +.stat .d { font-size: 11px; color: var(--muted); margin-top: 2px; } +.summary-lines { margin-top: 14px; display: flex; flex-direction: column; gap: 5px; } +.summary-lines div { font-size: 12px; color: var(--ink-soft); } +.summary-lines b { color: var(--ink); font-family: var(--mono); font-size: 11px; margin-right: 6px; } + +/* ---------------------------------------------------------- toolbar */ +.toolbar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding-bottom: 14px; border-bottom: 1px solid var(--rule); margin-bottom: 18px; } +.seg { display: inline-flex; background: var(--paper-2); border: 1px solid var(--rule-strong); border-radius: 0; padding: 2px; gap: 2px; } +.seg button { border: 0; background: transparent; padding: 6px 12px; border-radius: 0; font-family: var(--mono); font-size: 11.5px; color: var(--ink-soft); } +.seg button.active { background: var(--ink); color: #fbf8f0; font-weight: 600; } +.check { font-size: 12px; color: var(--ink-soft); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; } +.check input { accent-color: var(--accent); } +/* 「排除同城」这一行:整行浅底,说明文字另起一行,避免和后面的 chip 混在一起 */ +.inline-check { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px; + padding: 10px 13px; border: 1px solid var(--rule); background: var(--sheet-2); + font-size: 12.5px; margin-bottom: 0; +} +.inline-check input { margin: 0; } +.inline-check .label-note { flex-basis: 100%; margin-left: 22px; font-size: 11px; line-height: 1.6; } +.selected-count { margin-left: auto; font-family: var(--mono); font-size: 12px; color: var(--accent); font-weight: 600; } +.preselect-hint { font-family: var(--mono); font-size: 11.5px; color: var(--muted); margin: -6px 0 14px; padding-left: 10px; border-left: 3px solid var(--rule-strong); } + +/* ---------------------------------------------------------- compare */ +.compare-panel { border: 1px solid var(--rule-strong); border-left: 4px solid var(--accent); background: var(--sheet-2); border-radius: 0; padding: 16px 18px; margin-bottom: 18px; box-shadow: var(--shadow-sm); } +.compare-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 12px; } +.compare-head h4 { margin: 0; font-family: var(--serif); font-size: 15px; } +.compare-head span { font-family: var(--mono); font-size: 11px; color: var(--muted); } +.compare-scroll { overflow-x: auto; } +.compare-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.compare-table th, .compare-table td { border: 1px solid var(--rule); padding: 8px 10px; text-align: left; white-space: nowrap; } +.compare-table thead th { background: var(--ink); color: #fbf8f0; font-family: var(--mono); font-weight: 600; } +.compare-table tbody th { background: var(--paper-2); font-family: var(--mono); font-size: 11px; font-weight: 600; color: var(--ink-soft); } +.compare-empty { font-size: 12.5px; color: var(--muted); } + +/* ---------------------------------------------------------- dest cards */ +/* align-items:start 是关键:一张卡片展开逐日天气时,不应把同一行另一张卡片拉高 */ +.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; } +.dest-card { + border: 1px solid var(--rule-strong); border-left: 4px solid var(--rule-strong); + border-radius: 0; background: var(--sheet); + padding: 16px 18px; box-shadow: var(--shadow-sm); + transition: .16s ease; scroll-margin-top: 80px; +} +.dest-card:hover { border-color: var(--ink); } +.dest-card.picked { border-left-color: var(--accent); background: linear-gradient(180deg, #fdf3ea, var(--sheet) 90px); } +.dest-card.flash { animation: flash 1.2s ease; } +@keyframes flash { 0%, 100% { box-shadow: var(--shadow-sm); } 30% { box-shadow: 0 0 0 4px rgba(176, 74, 43, .24); } } +.card-top { display: flex; align-items: center; gap: 7px; margin-bottom: 10px; } +.rank { font-family: var(--mono); font-size: 11px; font-weight: 700; color: var(--ink-soft); background: var(--paper-2); padding: 3px 8px; border: 1px solid var(--rule); } +.verdict { font-family: var(--mono); font-size: 10.5px; font-weight: 600; padding: 3px 8px; border: 1px solid; } +.verdict.v1 { background: #e3ead9; color: var(--moss); border-color: #b3c5a6; } +.verdict.v2 { background: #e8eee1; color: #41603f; border-color: #b9c9ae; } +.verdict.v3 { background: #f6ecd7; color: #8a6a20; border-color: #ddc79a; } +.verdict.v4 { background: #f6e2df; color: var(--bad); border-color: #dcb7b2; } +.niche-badge { font-family: var(--mono); font-size: 10px; padding: 3px 7px; border: 1px solid var(--accent); color: var(--accent); background: var(--accent-soft); } +.pick-btn { margin-left: auto; border: 1px solid var(--rule-strong); background: var(--sheet); border-radius: 0; padding: 5px 11px; font-family: var(--mono); font-size: 11.5px; color: var(--ink-soft); } +.pick-btn:hover { border-color: var(--accent); color: var(--accent); } +.pick-btn.on { background: var(--ink); border-color: var(--ink); color: #fbf8f0; } +.card-title h4 { margin: 0; font-family: var(--serif); font-size: 18px; letter-spacing: .01em; } +.card-title .sub { font-family: var(--mono); font-size: 11px; color: var(--muted); margin-top: 3px; } +.card-body { display: flex; gap: 16px; margin: 14px 0 12px; align-items: center; } +.score-ring { flex: 0 0 78px; text-align: center; } +.score-ring svg { width: 72px; height: 72px; } +.score-ring .score-num { font-family: var(--serif); font-size: 21px; font-weight: 800; fill: var(--ink); } +.score-ring .score-cap { font-family: var(--mono); font-size: 8px; fill: var(--muted); } +.metrics { flex: 1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px 12px; } +.metric { min-width: 0; } +.metric .k { display: block; font-family: var(--mono); font-size: 10px; color: var(--muted); letter-spacing: .03em; } +.metric .v { display: block; font-size: 12.5px; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.metric .s { display: block; font-size: 10.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.metric .s.est { color: var(--accent); } +.reasons { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 5px; } +.reasons li { font-size: 12px; color: var(--ink-soft); padding-left: 15px; position: relative; } +.reasons li::before { content: "✓"; position: absolute; left: 0; color: var(--moss); font-size: 11px; } +.warnings { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.warnings span { font-family: var(--mono); font-size: 10.5px; background: #f8ecdc; color: #8a5f1c; border: 1px solid #ddc79a; padding: 3px 8px; } +.empty-mini { font-family: var(--mono); font-size: 12px; color: var(--muted); padding: 20px 0; text-align: center; } + +/* 逐日天气:自定义展开箭头 + 明确展开态 + 窄卡片内横向滚动 */ +.daily { border-top: 1px solid var(--rule); margin-top: 10px; padding-top: 9px; } +.daily summary { + font-family: var(--mono); font-size: 11.5px; color: var(--accent); cursor: pointer; + list-style: none; display: flex; align-items: center; gap: 7px; + padding: 2px 0; user-select: none; +} +.daily summary::-webkit-details-marker { display: none; } +.daily summary::before { content: '▸'; display: inline-block; font-size: 10px; transition: transform .18s ease; } +.daily[open] summary::before { transform: rotate(90deg); } +.daily[open] summary { color: var(--ink); font-weight: 600; } +.daily table { + width: 100%; border-collapse: collapse; margin-top: 9px; font-family: var(--mono); font-size: 10.5px; + display: block; overflow-x: auto; white-space: nowrap; +} +.daily th, .daily td { padding: 5px 7px; border-bottom: 1px solid var(--rule); text-align: left; } +.daily th { color: var(--muted); font-weight: 500; background: var(--sheet-2); } + +/* ---------------------------------------------------------- notes */ +.notes { margin-top: 20px; padding: 14px 16px; background: var(--sheet-2); border: 1px solid var(--rule); border-left: 4px solid var(--rule-strong); } +.notes h4 { margin: 0 0 8px; font-family: var(--serif); font-size: 13px; } +.notes ul { margin: 0; padding-left: 18px; } +.notes li { font-size: 11.5px; color: var(--muted); margin-bottom: 3px; } + +/* ---------------------------------------------------------- plan */ +.plan-grid { display: grid; grid-template-columns: 380px 1fr; gap: 22px; } +.plan-left label { display: block; font-weight: 600; font-size: 12.5px; margin: 0 0 8px; } +.plan-left label:not(:first-child) { margin-top: 20px; } +.selected-chips { display: flex; flex-wrap: wrap; gap: 7px; min-height: 34px; } +.sel-chip { font-family: var(--mono); font-size: 11.5px; padding: 5px 10px; background: var(--accent-soft); color: var(--accent-dark); border: 1px solid #dcbfa9; } +.quick-needs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.quick-needs button { font-family: var(--mono); font-size: 11px; padding: 5px 10px; border: 1px solid var(--rule-strong); background: var(--sheet); border-radius: 0; color: var(--ink-soft); } +.quick-needs button:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } +.ai-inline { margin: 16px 0; padding: 10px 12px; border-left: 3px solid var(--accent); background: var(--sheet-2); border-radius: 0; font-size: 11.5px; color: var(--ink-soft); line-height: 1.7; } +.ai-inline b { color: var(--accent); } +.plan-right { border: 1px solid var(--rule-strong); border-radius: 0; overflow: hidden; display: flex; flex-direction: column; background: var(--sheet); min-height: 560px; } +.output-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 9px 14px; background: var(--paper-2); border-bottom: 1px solid var(--rule); font-size: 11.5px; color: var(--ink-soft); } +.output-head > div { display: flex; gap: 7px; } +.output-head .btn-ghost { color: var(--ink-soft); border-color: var(--rule-strong); background: var(--sheet); } +.output-head .btn-ghost:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } +.markdown { padding: 22px 26px; flex: 1; overflow-y: auto; max-height: 760px; font-size: 13.5px; line-height: 1.85; } + +/* 生成之后的「继续调整」区:贴在输出框底部,像对话的输入条 */ +.followup { + border-top: 1px dashed var(--rule-strong); + padding: 12px 14px 13px; + background: var(--sheet-2); + flex: 0 0 auto; +} +.followup-row { display: flex; gap: 9px; } +.followup-row input { + flex: 1; min-width: 0; padding: 9px 12px; border: 1px solid var(--rule-strong); + background: var(--sheet); font-family: inherit; font-size: 12.5px; border-radius: 0; color: var(--ink); +} +.followup-row input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } +.followup-hint { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + margin-top: 8px; font-family: var(--mono); font-size: 10.5px; color: var(--muted); +} +.followup-hint .btn-ghost { padding: 3px 8px; font-size: 10.5px; } +.markdown.empty { display: flex; align-items: center; justify-content: center; } +.empty-state { text-align: center; color: var(--muted); max-width: 320px; } +.empty-icon { font-size: 32px; color: var(--accent); opacity: .5; margin-bottom: 8px; } +.empty-state h4 { margin: 0 0 6px; color: var(--ink-soft); font-family: var(--serif); font-size: 16px; } +.empty-state p { font-size: 12.5px; } + +/* markdown output */ +.markdown h1, .markdown h2, .markdown h3, .markdown h4 { font-family: var(--serif); line-height: 1.4; margin: 22px 0 10px; } +.markdown h1 { font-size: 22px; } +.markdown h2 { font-size: 18px; color: var(--ink); border-bottom: 2px solid var(--ink); padding-bottom: 6px; } +.markdown h3 { font-size: 15px; } .markdown h4 { font-size: 14px; } +.markdown p { margin: 9px 0; } +.markdown ul, .markdown ol { padding-left: 22px; margin: 9px 0; } +.markdown li { margin: 4px 0; } +.markdown blockquote { margin: 12px 0; padding: 10px 14px; background: var(--accent-soft); border-left: 4px solid var(--accent); border-radius: 0; color: #7d4d1c; font-size: 12.5px; } +.markdown blockquote p { margin: 0; } +.markdown table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12.5px; display: block; overflow-x: auto; } +.markdown th, .markdown td { border: 1px solid var(--rule); padding: 7px 10px; text-align: left; white-space: nowrap; } +.markdown th { background: var(--paper-2); font-weight: 600; } +.markdown tr:nth-child(even) td { background: var(--sheet-2); } +.markdown code { background: var(--paper-2); padding: 1px 5px; border-radius: 0; font-family: var(--mono); font-size: 11.5px; } +.markdown pre { background: var(--ink); color: #e8e4d8; padding: 14px; border-radius: 0; overflow-x: auto; font-size: 12px; } +.markdown pre code { background: transparent; color: inherit; padding: 0; } +.markdown hr { border: 0; border-top: 1px solid var(--rule-strong); margin: 20px 0; } +.markdown strong { color: var(--ink); } +.markdown a { word-break: break-all; } +.cursor { color: var(--accent); animation: blink 1s step-end infinite; } +@keyframes blink { 50% { opacity: 0; } } + +/* ---------------------------------------------------------- footer */ +.footer { padding: 18px 0 40px; color: var(--muted); font-family: var(--mono); font-size: 10.5px; line-height: 1.9; border-top: 1px solid var(--rule-strong); } +.footer strong { color: var(--ink-soft); } +.footer .disclaimer { margin-top: 6px; color: #93988c; } + +/* ---------------------------------------------------------- modal */ +.modal { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; } +.modal.hidden, .hidden { display: none !important; } +.modal-mask { position: absolute; inset: 0; background: rgba(32, 44, 41, .58); } +.modal-box { + position: relative; width: min(560px, calc(100vw - 32px)); max-height: 88vh; overflow-y: auto; + background: var(--sheet); border: 1px solid var(--ink); border-radius: 0; padding: 24px 26px; + box-shadow: 8px 8px 0 rgba(32, 44, 41, .2); +} +.modal-head { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--rule); padding-bottom: 12px; } +.modal-head h3 { margin: 0; font-family: var(--serif); font-size: 18px; } +.modal-head h3 span { font-family: var(--mono); font-size: 10.5px; color: var(--muted); font-weight: 400; margin-left: 6px; } +.modal-desc { font-size: 12px; color: var(--muted); margin: 12px 0 20px; line-height: 1.75; } +.modal-desc code { background: var(--paper-2); padding: 1px 5px; font-family: var(--mono); font-size: 11px; } +.modal-box label { display: block; font-size: 12px; font-weight: 600; margin: 14px 0 7px; } + +/* 数据源增强区块:与 AI 接口分开,避免用户以为只能配模型 */ +.modal-divider { display: flex; align-items: center; gap: 10px; margin: 22px 0 4px; } +.modal-divider::before, .modal-divider::after { content: ''; height: 1px; background: var(--rule); flex: 1; } +.modal-divider span { font-family: var(--mono); font-size: 10.5px; letter-spacing: .1em; color: var(--muted); white-space: nowrap; } +.field-link { font-family: var(--mono); font-size: 10.5px; font-weight: 400; color: var(--accent); margin-left: 6px; text-decoration: none; border-bottom: 1px solid currentColor; } +.field-link:hover { color: var(--accent-dark); } +.field-hint { font-size: 11px; color: var(--muted); line-height: 1.65; margin: 7px 0 0; } +.field-row-inline { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; } +.field-hint.inline { margin: 0; } +.field-hint.good { color: var(--moss); font-weight: 600; } +.field-hint.bad { color: var(--accent); font-weight: 600; } +.alert-link { font: inherit; font-weight: 600; color: inherit; background: none; border: 0; padding: 0; margin: 0 2px; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } +.alert-link:hover { color: var(--accent-dark); } +.alert-link[disabled] { opacity: .5; cursor: default; } +.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; } +.presets button { font-family: var(--mono); font-size: 11px; padding: 5px 10px; border: 1px solid var(--rule-strong); background: var(--sheet); border-radius: 0; color: var(--ink-soft); } +.presets button:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } +.prompt-details { margin-top: 18px; border: 1px solid var(--rule-strong); border-radius: 0; padding: 10px 13px; } +.prompt-details summary { font-family: var(--mono); font-size: 12px; color: var(--accent); cursor: pointer; font-weight: 600; } +.prompt-actions { margin: 10px 0 8px; } +.prompt-details pre { max-height: 230px; overflow: auto; background: var(--sheet-2); padding: 12px; border: 1px solid var(--rule); font-family: var(--mono); font-size: 11px; line-height: 1.65; white-space: pre-wrap; color: var(--ink-soft); } +.modal-actions { display: flex; gap: 10px; margin-top: 22px; } + +/* ---------------------------------------------------------- toast */ +.toast { + position: fixed; z-index: 80; left: 50%; bottom: 28px; transform: translateX(-50%); + background: var(--ink); color: #fbf8f0; padding: 11px 18px; border: 1px solid var(--accent); + font-family: var(--mono); font-size: 12.5px; box-shadow: 5px 5px 0 rgba(32, 44, 41, .22); animation: toastIn .25s ease; +} +.toast.err { background: var(--bad); border-color: #7d2a24; } +.toast.ok { background: var(--moss); border-color: #35513a; } +@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } } + +/* ---------------------------------------------------------- responsive */ +@media (max-width: 980px) { + .result-top, .plan-grid { grid-template-columns: 1fr; } + .compass-panel { max-width: 420px; } +} +@media (max-width: 720px) { + .wrap { padding: 0 14px; } + .card { padding: 20px 16px; } + .form-grid, .cards { grid-template-columns: 1fr; } + .field.span2 { grid-column: auto; } + .hero { padding-top: 28px; } + .stepper { width: 100%; overflow-x: auto; } + /* 保持横向排列:一旦变成纵向,联想下拉会被推到定位按钮下方, + 手机键盘弹出时正好落在视口外,用户会以为"没有联想" */ + .origin-row { flex-direction: row; align-items: stretch; } + .origin-row .btn-outline { + flex: 0 0 auto; min-width: 48px; padding: 0 12px; font-size: 0; + } + .origin-row .btn-outline::before { content: '📍'; font-size: 16px; } + /* 下拉限高 + 可滚动,保证在手机视口内能看到、能选 */ + .suggest { max-height: 46vh; overflow-y: auto; -webkit-overflow-scrolling: touch; } + .summary-grid { grid-template-columns: repeat(2, 1fr); } + .head-actions { width: 100%; } + .selected-count { margin-left: 0; } +} + +/* ============================================================ + 简约化收尾:圆角、需求框、chips + ============================================================ */ + +.card, .dest-card, .compass-panel, .plan-right, .modal-box, .stat { + border-radius: var(--radius); +} +.card { box-shadow: var(--shadow-sm); } + +.field input[type="text"], .field input[type="date"], .field select, +.field textarea, .modal-box input, .followup-row input { + border-radius: 8px; + border-color: var(--rule-strong); + background: var(--sheet); +} + +/* 「你的具体需求」之前不在 .field 里,完全没吃到输入框样式,所以又小又没边界 */ +.plan-left textarea { + display: block; width: 100%; min-height: 200px; + padding: 12px 14px; line-height: 1.7; font-size: 13px; + color: var(--ink); background: var(--sheet); + border: 1px solid var(--rule-strong); border-radius: 8px; + resize: vertical; transition: border-color .15s, box-shadow .15s; +} +.plan-left textarea::placeholder { color: var(--muted); } +.plan-left textarea:focus { + outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.chip, .quick-needs button, .presets button, .stepper li { border-radius: 999px; } +.chip { padding: 6px 14px; } +.sel-chip { border-radius: 999px; border-color: var(--rule-strong); background: var(--accent-soft); color: var(--accent-dark); } + +.output-head { border-radius: var(--radius) var(--radius) 0 0; } +.followup { border-radius: 0 0 var(--radius) var(--radius); } +.alert { border-radius: 8px; } + +/* ------------------------------ 版本切换 / 对比 */ +.head-tools { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; justify-content: flex-end; } +.version-select { + font-size: 11.5px; padding: 5px 8px; border: 1px solid var(--rule-strong); + border-radius: 6px; background: var(--sheet); color: var(--ink); + max-width: 200px; cursor: pointer; +} +.version-select:focus { outline: none; border-color: var(--accent); } + +.compare-wrap { + display: grid; grid-template-columns: 1fr 1fr; gap: 1px; + background: var(--rule); flex: 1; min-height: 0; overflow: hidden; +} +.compare-col { display: flex; flex-direction: column; background: var(--sheet); min-width: 0; } +.compare-col-head { + padding: 8px 14px; font-size: 11.5px; font-weight: 600; color: var(--ink-soft); + background: var(--sheet-2); border-bottom: 1px solid var(--rule); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.compare-col .markdown { max-height: none; padding: 16px 18px; font-size: 12.5px; } + +@media (max-width: 900px) { + .compare-wrap { grid-template-columns: 1fr; } + .version-select { max-width: 150px; } +} + +/* ------------------------------ 周边住宿参考 */ +.hotel-box { margin-top: 14px; } +.hotel-hint { font-size: 11.5px; color: var(--muted); line-height: 1.6; } +.hotel-details { border: 1px solid var(--rule); border-radius: 8px; background: var(--sheet-2); overflow: hidden; } +.hotel-details summary { + padding: 8px 11px; font-size: 11.5px; font-weight: 600; color: var(--ink-soft); + cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; +} +.hotel-details summary::-webkit-details-marker { display: none; } +.hotel-details summary::before { content: '▸'; font-size: 9px; color: var(--muted); transition: transform .16s; } +.hotel-details[open] summary::before { transform: rotate(90deg); } +.hotel-details summary span { font-weight: 400; color: var(--muted); margin-left: auto; } +.hotel-list { list-style: none; margin: 0; padding: 0 11px 8px; max-height: 200px; overflow-y: auto; } +.hotel-list li { + display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; + border-top: 1px dashed var(--rule); font-size: 11.5px; +} +.hotel-list .hn { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hotel-list .hm { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } +.hotel-note { padding: 0 11px 9px; font-size: 10.5px; color: var(--muted); line-height: 1.55; } + +/* ------------------------------ 强调色统一为蓝色,去掉散落的黑块 */ +.stepper li.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.chip.on { background: var(--accent); border-color: var(--accent); color: #fff; } +.seg button.active { background: var(--accent); color: #fff; border-color: var(--accent); } +.pick-btn.on { background: var(--accent); border-color: var(--accent); color: #fff; } +.compare-table thead th { background: var(--paper-2); color: var(--ink-soft); } + +/* 输出头按钮多,允许换行,否则会把右侧按钮挤出容器 */ +.output-head { flex-wrap: wrap; row-gap: 7px; } +.head-tools { flex-wrap: wrap; row-gap: 6px; } +.version-select { max-width: 160px; } +.output-head .btn-ghost { padding: 5px 10px; } + +/* ------------------------------ 住宿价位选择 */ +.lodging-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 14px; } +.lodging-label { font-size: 12px; font-weight: 600; color: var(--ink-soft); } +.lodging-row .chips .chip { font-size: 11.5px; padding: 5px 11px; } + +/* ------------------------------ 路线图 + 住宿(方案定稿后出现) */ +.plan-extra { display: grid; grid-template-columns: 1.15fr 1fr; gap: 18px; margin-top: 20px; } +.extra-card { + border: 1px solid var(--rule); border-radius: var(--radius); background: var(--sheet); + box-shadow: var(--shadow-sm); overflow: hidden; +} +.extra-head { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 10px 14px; border-bottom: 1px solid var(--rule); background: var(--sheet-2); + font-size: 12.5px; font-weight: 600; color: var(--ink-soft); +} +.extra-note { font-weight: 400; font-size: 11px; color: var(--muted); } + +.route-wrap { padding: 10px 12px 12px; } +.route-svg { width: 100%; height: auto; display: block; } +.rm-scale { font-size: 9px; fill: #b6bec8; } +.rm-compass { font-size: 10px; fill: var(--muted); font-weight: 700; letter-spacing: .06em; } +.rm-idx { font-size: 8.5px; fill: #fff; font-weight: 700; } +.rm-name { font-size: 11.5px; fill: var(--ink); font-weight: 600; } +.rm-dist { font-size: 9.5px; fill: var(--muted); } +.rm-origin { font-size: 10.5px; fill: var(--ink-soft); font-weight: 600; } +.route-legend { + display: flex; align-items: center; gap: 14px; flex-wrap: wrap; + padding: 6px 2px 0; font-size: 10.5px; color: var(--muted); +} +.route-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; } +.route-legend .dot.origin { background: #171a1f; } +.route-legend .dot.dest { background: var(--accent); } +.route-provider { margin-left: auto; } + +/* 行程点清单:地图上最多画 9 个点,方案里的地点通常更多, + 完整清单让用户能看到「每个安排的地点」是哪些、按什么顺序 */ +.route-stops { + list-style: none; margin: 10px 0 0; padding: 0; + display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 4px 10px; +} +.route-stops li { + display: flex; align-items: baseline; gap: 6px; + font-size: 11.5px; color: var(--ink-soft); line-height: 1.5; +} +.route-stops li b { + flex: 0 0 auto; width: 16px; height: 16px; border-radius: 50%; + background: var(--accent); color: #fff; + font-size: 9.5px; font-weight: 600; text-align: center; line-height: 16px; +} +.route-stops li .sn { flex: 1 1 auto; } +.route-stops li em { + flex: 0 0 auto; font-style: normal; font-size: 10px; color: var(--muted); +} + +.hotel-box { padding: 12px 14px; margin-top: 0; } +.extra-card .hotel-list { padding: 0 0 6px; } +.extra-card .hotel-note { padding: 0; } +.stay-range { font-size: 11px; color: var(--muted); margin-bottom: 8px; } +.stay-range b { color: var(--ink-soft); } + +/* ------------------------------------------------- 住宿自定义价位 */ +.lodging-custom { display: flex; align-items: center; gap: 7px; margin-top: 9px; flex-wrap: wrap; } +.lodging-custom .cny, .lodging-custom .to { font-size: 12px; color: var(--muted); } +.lodging-custom input { + width: 96px; padding: 7px 9px; font-size: 12.5px; + border: 1px solid var(--rule-strong); border-radius: 7px; background: #fff; color: var(--ink); + font-variant-numeric: tabular-nums; +} +.lodging-custom input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(47, 111, 237, .12); } +.lodging-custom .unit { font-size: 11px; color: var(--muted); } +.lodging-tip { margin: 7px 0 0; font-size: 11px; color: var(--muted); line-height: 1.5; } + +/* ------------------------------------------------- 住宿价格缺失与外部核对 */ +.hotel-list .hn { flex: 1; min-width: 0; } +.hotel-list .hm-link { + font-size: 10.5px; color: var(--accent); text-decoration: none; + border-bottom: 1px solid transparent; white-space: nowrap; +} +.hotel-list .hm-link:hover { border-bottom-color: var(--accent); } +.price-warn { + font-size: 10.5px; line-height: 1.65; color: #8a5a00; + background: #fff8e6; border: 1px solid #f0dcae; border-radius: 7px; + padding: 8px 10px; margin: 0 0 8px; +} +.price-warn code { font-family: var(--mono); font-size: 10px; background: #fff; padding: 0 3px; border-radius: 3px; } + +/* ------------------------------------------------- 真实地图图层 */ +.map-layer { + position: relative; border-radius: 8px; overflow: hidden; + border: 1px solid var(--rule); background: var(--paper-2); +} +.map-layer img { display: block; width: 100%; height: auto; } +/* 标记与连线是前端叠加的 SVG,跟底图同 viewBox,自动对齐 */ +.map-overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } +.map-name { + font-size: 11px; fill: #171a1f; font-weight: 600; + paint-order: stroke; stroke: rgba(255, 255, 255, .95); stroke-width: 3.5px; stroke-linejoin: round; +} +.map-legend { + position: absolute; left: 8px; bottom: 8px; + font-size: 10px; color: #fff; background: rgba(23, 26, 31, .62); + padding: 3px 8px; border-radius: 4px; +} +.route-fallback .route-svg { border-radius: 8px; background: #fbfcfd; border: 1px solid var(--rule); } +.map-hint { font-size: 10.5px; color: var(--muted); margin-top: 7px; line-height: 1.6; } + +/* 两张卡内容长度差很多,按 start 对齐,别互相拉伸 */ +.plan-extra { align-items: start; } + +@media (max-width: 900px) { + .plan-extra { grid-template-columns: 1fr; } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..d5489e5 --- /dev/null +++ b/server.js @@ -0,0 +1,615 @@ +'use strict'; + +/** + * TripSense · 周边出行顾问 + * 零依赖 Node HTTP 服务:静态托管 + 数据代理 + SSE 流式响应 + * 启动:node server.js (默认 http://0.0.0.0:8787,同网段设备可直接访问) + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const cfg = require('./config'); +const S = require('./lib/sources'); +const R = require('./lib/hotels-rollinggo'); +const { analyze } = require('./lib/analyze'); +const { planEvents, resolveAI, testAI } = require('./lib/planner'); +const C = require('./lib/cache'); + +/** + * 取「目的地所属城市名」,用于酒店库的城市兜底查询。 + * + * 反向地理编码偶尔会因为网络抖动失败,reverseGeocode 会兜底返回「当前位置」—— + * 那是假地名,拿去查酒店库必然查空,然后被误判成「这家数据源没数据」。 + * 所以这里把假地名一律视为失败,并且重试一次。 + */ +async function resolveCityName(lat, lon, gcfg) { + for (let i = 0; i < 2; i += 1) { + try { + const rev = await S.reverseGeocode(lat, lon, gcfg); + const name = String(rev?.name || '').replace(/\s+/g, ''); + if (!name || name === '当前位置') continue; + return name; + } catch { /* 重试 */ } + } + return ''; +} + +const PUBLIC_DIR = path.join(__dirname, 'public'); +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.woff2': 'font/woff2', + '.map': 'application/json; charset=utf-8', +}; + +function cors(res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-AI-Base-Url, X-AI-Api-Key, X-AI-Model, X-AMAP-Key'); +} + +function json(res, code, obj) { + cors(res); + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(obj)); +} + +function readBody(req, limit = 1024 * 1024) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on('data', (c) => { + size += c.length; + if (size > limit) { reject(new Error('请求体过大')); req.destroy(); return; } + chunks.push(c); + }); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve({}); + try { resolve(JSON.parse(raw)); } catch (e) { reject(new Error('请求体不是合法 JSON')); } + }); + req.on('error', reject); + }); +} + +function startSSE(res) { + cors(res); + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write(': connected\n\n'); +} +function sse(res, obj) { + if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`); +} + +function serveStatic(req, res, pathname) { + const rel = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, ''); + const filePath = path.resolve(PUBLIC_DIR, rel); + if (!filePath.startsWith(PUBLIC_DIR + path.sep) && filePath !== path.join(PUBLIC_DIR, 'index.html')) { + return json(res, 403, { error: 'Forbidden' }); + } + fs.stat(filePath, (err, stat) => { + if (err || !stat.isFile()) { + // SPA 兜底 + const fallback = path.join(PUBLIC_DIR, 'index.html'); + return fs.readFile(fallback, (e2, buf) => { + if (e2) return json(res, 404, { error: 'Not found' }); + cors(res); + res.writeHead(200, { 'Content-Type': MIME['.html'] }); + res.end(buf); + }); + } + cors(res); + res.writeHead(200, { + 'Content-Type': MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream', + 'Cache-Control': 'no-cache', + }); + fs.createReadStream(filePath).pipe(res); + }); +} + +// ------------------------------------------------------------------ 路由 + +async function handle(req, res) { + const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const pathname = u.pathname; + if (req.method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return; } + + if (pathname === '/api/health') { + return json(res, 200, { ok: true, service: 'tripsense', time: new Date().toISOString(), cache: C.stats() }); + } + + // 缓存管理:查看 / 清空(本地调试用,不对外暴露敏感信息) + if (pathname === '/api/cache' && req.method === 'GET') { + return json(res, 200, { ...C.stats(), note: '分析/地理编码/周边POI/住宿/地图底图均已缓存' }); + } + if (pathname === '/api/cache' && req.method === 'DELETE') { + C.clear(); + return json(res, 200, { ok: true, cleared: true }); + } + + if (pathname === '/api/config') { + const ai = resolveAI(req.headers, cfg); + return json(res, 200, { + service: '行知 · 周边出行顾问 (TripSense)', + ai: { + baseUrl: ai.baseUrl, + model: ai.model, + hasKey: Boolean(ai.apiKey), + mode: ai.apiKey ? 'live' : 'demo', + configuredByEnv: Boolean(cfg.AI.API_KEY), + }, + features: { + amapRoad: Boolean(cfg.AMAP_KEY), + railApi: Boolean(cfg.RAIL_API_URL), + }, + limits: cfg.SEARCH, + systemPrompt: cfg.SYSTEM_PROMPT, + sources: [ + { name: 'Open-Meteo', use: '地理编码 / 天气预报 / 历史气候 / 空气质量', key: false }, + { name: 'Photon (OpenStreetMap)', use: '周边景点、公园、博物馆、山峰、邻近城市检索', key: false }, + { name: 'OSRM', use: '公路驾车里程与耗时矩阵', key: false }, + { name: 'BigDataCloud / Photon Reverse', use: '浏览器定位后的反向地理编码', key: false }, + { name: '高德 Web 服务', use: '可选:国内驾车路径(需 AMAP_KEY)', key: true }, + { name: '铁路接口', use: '可选:真实铁路/高铁耗时(需 RAIL_API_URL)', key: true }, + { name: 'OpenAI 兼容接口', use: '结合用户需求生成行程规划(需 AI_API_KEY)', key: true }, + ], + }); + } + + if (pathname === '/api/geocode' && req.method === 'GET') { + const name = (u.searchParams.get('name') || '').trim(); + if (!name) return json(res, 400, { error: '缺少 name 参数' }); + // 高德对中文地名的覆盖远好于 Open-Meteo(后者连「河池」都查不到), + // 所以有 Key 时优先走高德,取不到再退回 Open-Meteo。 + const amapKey = String(req.headers['x-amap-key'] || '').trim(); + const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg; + // 地名是稳定的静态信息,缓存 7 天 + const out = await C.wrap(`geo:${name}:${gcfg.AMAP_KEY ? 'a' : 'o'}`, async () => { + let results = []; + let source = ''; + let amapError = ''; + let meteoError = ''; + if (gcfg.AMAP_KEY) { + try { + results = await S.amapGeocode(name, gcfg); + if (results.length) source = '高德'; + } catch (err) { amapError = err.message; } + } + if (!results.length) { + try { + results = await S.geocode(name, gcfg, 8); + if (results.length) source = 'Open-Meteo'; + } catch (err) { meteoError = err.message; } + } + return { + results, source, + amapError: amapError || undefined, + meteoError: meteoError || undefined, + }; + }, { ttlMs: 7 * 24 * 3600 * 1000 }); + return json(res, 200, out); + } + + if (pathname === '/api/reverse' && req.method === 'GET') { + const lat = Number(u.searchParams.get('lat')); + const lon = Number(u.searchParams.get('lon')); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: '缺少合法 lat/lon' }); + // 反向地理编码结果稳定,缓存 7 天 + const k = `rev:${lat.toFixed(3)},${lon.toFixed(3)}`; + const out = await C.wrap(k, async () => ({ place: await S.reverseGeocode(lat, lon, cfg) }), + { ttlMs: 7 * 24 * 3600 * 1000 }); + return json(res, 200, out); + } + + if (pathname === '/api/best-window' && req.method === 'GET') { + const lat = Number(u.searchParams.get('lat')); + const lon = Number(u.searchParams.get('lon')); + const days = Math.min(5, Math.max(1, Number(u.searchParams.get('days')) || 2)); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: '缺少合法 lat/lon' }); + // 天气窗口随预报更新,缓存 3 小时 + const out = await C.wrap(`win:${lat.toFixed(3)},${lon.toFixed(3)},${days}`, async () => ({ + window: await S.bestWeatherWindow(lat, lon, days, cfg), + }), { ttlMs: 3 * 3600 * 1000 }); + return json(res, 200, out); + } + + // 周边住宿参考:优先 RollingGo(有实时报价),否则退回高德 POI(无房态无价格) + if (pathname === '/api/hotels' && req.method === 'GET') { + const lat = Number(u.searchParams.get('lat')); + const lon = Number(u.searchParams.get('lon')); + const radius = Math.min(20, Math.max(1, Number(u.searchParams.get('radius')) || 10)); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: '缺少合法 lat/lon' }); + + const name = (u.searchParams.get('name') || '').trim(); + const checkIn = (u.searchParams.get('checkIn') || '').trim(); + const nights = Math.min(30, Math.max(1, Number(u.searchParams.get('nights')) || 1)); + const maxPrice = Number(u.searchParams.get('maxPrice')); + const amapKey = String(req.headers['x-amap-key'] || '').trim(); + const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg; + const wantRolling = cfg.HOTEL_PROVIDER !== 'amap'; + + // 房价随时间波动,缓存 30 分钟; + // 查询本身要 spawn rgh CLI(慢),命中缓存能省掉这几百毫秒到数秒。 + const ck = `hotel:${name}:${lat.toFixed(3)},${lon.toFixed(3)}:${radius}:${checkIn}:${nights}:${maxPrice || ''}:${gcfg.AMAP_KEY ? 'a' : 'o'}`; + const out = await C.wrap(ck, async () => { + // 1) RollingGo:只读子命令,未装/未登录会自动跳过 + if (wantRolling && name) { + const av = await R.availability(cfg); + if (av.ready) { + let rr = await R.searchHotels({ + name, lat, lon, checkIn, nights, + maxPricePerNight: Number.isFinite(maxPrice) && maxPrice > 0 ? maxPrice : null, + distanceMeters: radius * 1000, + size: 8, + }, cfg); + // 景区名在酒店库里常常查不到(酒店是按城市建的), + // 这时用反向地理编码拿到所属城市再查一次,再按坐标距离过滤,而不是直接放弃。 + let usedArea = name; + if (!rr.hotels.length && !rr.error) { + const city = await resolveCityName(lat, lon, gcfg); + if (city && city !== name) { + const rr2 = await R.searchHotels({ + name: city, lat, lon, checkIn, nights, + maxPricePerNight: Number.isFinite(maxPrice) && maxPrice > 0 ? maxPrice : null, + distanceMeters: Math.max(radius, 20) * 1000, + placeType: '城市', + size: 8, + }, cfg); + if (rr2.hotels.length) { rr = rr2; usedArea = city; } + } + } + if (rr.hotels.length) { + return { + hotels: rr.hotels, + priceSource: 'RollingGo 实时报价', + provider: 'rollinggo', + available: true, + radiusKm: radius, + area: usedArea, + }; + } + // 有 Key 时仍可退回高德;没有 Key 就把 RollingGo 的原因带回去 + if (!gcfg.AMAP_KEY) { + return { + hotels: [], + provider: 'rollinggo', + available: true, + reason: rr.error || rr.message || 'RollingGo 没有返回结果', + }; + } + } else if (!gcfg.AMAP_KEY && cfg.HOTEL_PROVIDER === 'rollinggo') { + return { hotels: [], provider: 'rollinggo', available: false, reason: av.reason }; + } + } + + if (!gcfg.AMAP_KEY) { + const av = await R.availability(cfg); + return { + hotels: [], available: false, + reason: av.ready ? '未提供目的地名称' : `未配置高德 Key,且 ${av.reason || 'RollingGo 不可用'}`, + }; + } + try { + const r = await S.amapHotels(lat, lon, radius, gcfg); + // 高德价格字段覆盖很差:配了价格源就补一次,补不到保持 null,不猜价 + const merged = await S.mergeHotelPrices(r.hotels, gcfg); + return { + hotels: merged.hotels, + priceSource: merged.priceSource, + provider: 'amap', + available: true, + radiusKm: radius, + // 告诉前端这是降级来的,用户才知道为什么没有价格 + fallbackFrom: wantRolling && name ? 'rollinggo' : '', + }; + } catch (err) { + return { hotels: [], provider: 'amap', available: true, error: err.message }; + } + }, { ttlMs: 30 * 60 * 1000 }); + return json(res, 200, out); + } + + // 住宿数据源可用性:给设置页显示 rgh 装没装、登没登录 + // 目的地周边的可点名地点(餐饮 / 景点 / 博物馆),供 AI 规划使用 + if (pathname === '/api/nearby-pois' && req.method === 'GET') { + const lat = Number(u.searchParams.get('lat')); + const lon = Number(u.searchParams.get('lon')); + const radius = Math.min(20, Math.max(1, Number(u.searchParams.get('radius')) || 8)); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return json(res, 400, { error: '缺少合法 lat/lon' }); + const amapKey = String(req.headers['x-amap-key'] || '').trim(); + const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg; + if (!gcfg.AMAP_KEY) { + return json(res, 200, { available: false, reason: '未配置高德 Key', food: [], sights: [] }); + } + // 周边 POI 是慢变数据,缓存 6 小时 + const k = `near:${lat.toFixed(3)},${lon.toFixed(3)},${radius},${gcfg.AMAP_KEY.slice(-6)}`; + try { + const out = await C.wrap(k, async () => { + const r = await S.amapNearbyPois(lat, lon, radius, gcfg); + return { ...r, radiusKm: radius }; + }, { ttlMs: 6 * 3600 * 1000 }); + return json(res, 200, out); + } catch (err) { + return json(res, 200, { available: true, error: err.message, food: [], sights: [] }); + } + } + + // 路线点解析:把方案正文里点名的地点逐个定位成坐标。 + // 前端把方案里出现的地点名按顺序 POST 过来,服务端用高德 POI 定位, + // 返回有序路线点(画线路用)。名称查不到就返回 null,前端跳过并在图上注明。 + if (pathname === '/api/route-points' && req.method === 'POST') { + let body; + try { body = await readBody(req); } catch (err) { return json(res, 400, { error: err.message }); } + const names = Array.isArray(body.names) + ? body.names.map((s) => String(s || '').trim()).filter(Boolean).slice(0, 20) + : []; + if (!names.length) return json(res, 400, { error: '缺少 names' }); + const amapKey = String(req.headers['x-amap-key'] || '').trim(); + const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg; + if (!gcfg.AMAP_KEY) { + return json(res, 200, { available: false, reason: '未配置高德 Key', points: [] }); + } + const city = String(body.city || '').trim(); + const near = (Number.isFinite(Number(body.lat)) && Number.isFinite(Number(body.lon))) + ? { lat: Number(body.lat), lon: Number(body.lon) } : null; + const points = []; + const missing = []; + for (const nm of names) { + // 串行 + 间隔:高德并发 5 就会 CUQPS_HAS_EXCEEDED_THE_LIMIT + // eslint-disable-next-line no-await-in-loop + const hit = await S.amapResolvePlace(nm, city, gcfg, near).catch((e) => { + console.warn('[route-points]', nm, e.message); + return null; + }); + if (hit) points.push({ query: nm, ...hit }); + else missing.push(nm); + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, 120)); + } + return json(res, 200, { available: true, points, missing }); + } + + if (pathname === '/api/hotel-providers' && req.method === 'GET') { + const av = await R.availability(cfg, true); + // 设置页里填的 Key 只存在浏览器,必须由请求头带上来,否则会误报「未配高德 Key」 + const amapKey = String(req.headers['x-amap-key'] || '').trim(); + return json(res, 200, { + provider: cfg.HOTEL_PROVIDER, + amap: Boolean(amapKey) || Boolean(cfg.AMAP_KEY), + amapSource: amapKey ? '页面设置' : (cfg.AMAP_KEY ? '服务端环境变量' : ''), + rollinggo: { installed: Boolean(av.installed), ready: av.ready, reason: av.reason }, + readonly: true, + note: '仅使用 status / search-hotels / hotel-detail 三个只读子命令,不会下单或取消订单', + }); + } + + // 真实地图底图:服务端代理高德静态地图,Key 不进前端 URL。 + // 只返回纯底图(不含 markers/paths),标记与连线由前端按 X-Map-Meta 投影后叠加。 + if (pathname === '/api/staticmap' && req.method === 'GET') { + const raw = (u.searchParams.get('pts') || '').trim(); + if (!raw) return json(res, 400, { error: '缺少 pts 参数(lat,lon;lat,lon)' }); + const pts = raw.split(';').map((s) => { + const [la, lo] = s.split(',').map(Number); + return Number.isFinite(la) && Number.isFinite(lo) ? { lat: la, lon: lo } : null; + }).filter(Boolean); + if (!pts.length) return json(res, 400, { error: 'pts 不是合法坐标' }); + + const amapKey = String(req.headers['x-amap-key'] || '').trim(); + const gcfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg; + if (!gcfg.AMAP_KEY) return json(res, 200, { error: '未配置高德 Key' }); + + const width = Number(u.searchParams.get('w')) || 640; + const height = Number(u.searchParams.get('h')) || 360; + const meta = S.staticMapMeta(pts, gcfg, { width, height }); + const mapUrl = S.staticMapUrl(pts, gcfg, { width, height }); + if (!mapUrl || !meta) return json(res, 200, { error: '无法构造地图 URL' }); + + try { + // 静态地图底图几乎不变,缓存 7 天(PNG 二进制直接存 Buffer) + const k = `map:${pts.map((p) => `${p.lat.toFixed(3)},${p.lon.toFixed(3)}`).join(';')}:${width}x${height}:${gcfg.AMAP_KEY.slice(-6)}`; + let hit = C.get(k); + if (!hit) { + const r = await fetch(mapUrl, { signal: AbortSignal.timeout(12000) }); + const ct = r.headers.get('content-type') || ''; + if (!ct.startsWith('image')) { + const txt = await r.text().catch(() => ''); + return json(res, 200, { + error: `高德静态地图:${txt.replace(/<[^>]+>/g, ' ').trim().slice(0, 200) || `HTTP ${r.status}`}`, + }); + } + const buf = Buffer.from(await r.arrayBuffer()); + C.set(k, { buf, ct }, 7 * 24 * 3600 * 1000); + hit = { result: { buf, ct } }; + } + const { buf, ct } = hit.result; + cors(res); + res.writeHead(200, { + 'Content-Type': ct, + 'Cache-Control': 'public, max-age=604800', + 'Content-Length': buf.length, + // 前端靠它把经纬度投影成像素(center / zoom / size / GCJ-02 坐标) + 'X-Map-Meta': Buffer.from(JSON.stringify(meta), 'utf8').toString('base64'), + }); + return res.end(buf); + } catch (err) { + return json(res, 200, { error: `静态地图请求失败:${err.message}` }); + } + } + if (pathname === '/api/analyze' && req.method === 'POST') { + let body; + try { body = await readBody(req); } catch (err) { return json(res, 400, { error: err.message }); } + startSSE(res); + let closed = false; + res.on('close', () => { closed = true; }); + const progress = (stage, pct, text) => { + if (!closed) sse(res, { type: 'progress', stage, pct, text }); + }; + try { + // 前端设置里填的高德 Key 优先于服务端环境变量(仅影响周边 POI 与驾车路径) + const amapKey = String(req.headers['x-amap-key'] || '').trim(); + const runCfg = amapKey ? { ...cfg, AMAP_KEY: amapKey } : cfg; + + // 缓存命中就直接复用,不再打十几次外部请求(天气/路况几十分钟内不会变) + const force = body.forceRefresh === true || body.forceRefresh === 'true'; + const ck = C.fingerprint(body, runCfg); + const cached = force ? null : C.get(ck); + if (cached) { + progress('done', 100, '命中缓存'); + const result = { + ...cached.result, + cache: { hit: true, ageMs: cached.ageMs, cachedAt: cached.cachedAt }, + }; + if (!closed) sse(res, { type: 'result', data: result }); + if (!closed) sse(res, { type: 'done' }); + res.end(); + return; + } + + progress('start', 2, '开始分析'); + const result = await analyze(body, runCfg, progress); + C.set(ck, result); + if (!closed) sse(res, { type: 'result', data: { ...result, cache: { hit: false } } }); + } catch (err) { + console.error('[analyze]', err); + if (!closed) sse(res, { type: 'error', message: err.message }); + } finally { + if (!closed) sse(res, { type: 'done' }); + res.end(); + } + return; + } + + if (pathname === '/api/ai-test' && req.method === 'POST') { + let body = {}; + try { body = await readBody(req); } catch (err) { return json(res, 400, { error: err.message }); } + const ai = resolveAI(req.headers, cfg); + if (body.baseUrl) ai.baseUrl = String(body.baseUrl).replace(/\/+$/, ''); + if (body.model) ai.model = String(body.model); + if (body.apiKey) ai.apiKey = String(body.apiKey); + try { + return json(res, 200, await testAI(ai)); + } catch (err) { + return json(res, 500, { ok: false, stage: 'server', message: err.message }); + } + } + + if (pathname === '/api/plan' && req.method === 'POST') { + let body; + try { body = await readBody(req); } catch (err) { return json(res, 400, { error: err.message }); } + startSSE(res); + let closed = false; + res.on('close', () => { closed = true; }); + const ai = resolveAI(req.headers, cfg); + try { + for await (const ev of planEvents(body, ai, cfg)) { + if (closed) break; + sse(res, ev); + } + } catch (err) { + console.error('[plan]', err); + if (!closed) sse(res, { type: 'error', message: err.message }); + } finally { + res.end(); + } + return; + } + + if (pathname.startsWith('/api/')) return json(res, 404, { error: 'Unknown API' }); + return serveStatic(req, res, pathname); +} + +const server = http.createServer((req, res) => { + handle(req, res).catch((err) => { + console.error('[server]', err); + if (!res.headersSent) json(res, 500, { error: err.message }); + else res.end(); + }); +}); + +/** 列出本机所有可用的 IPv4 地址,用于打印局域网访问入口 */ +function lanAddresses() { + const out = []; + const ifaces = os.networkInterfaces(); + for (const [name, list] of Object.entries(ifaces)) { + for (const ni of list || []) { + if (ni.family === 'IPv4' && !ni.internal) out.push({ name, address: ni.address }); + } + } + return out; +} + +function printBanner(port) { + const openToLan = cfg.HOST === '0.0.0.0' || cfg.HOST === '::'; + console.log(''); + console.log(' ┌──────────────────────────────────────────────────┐'); + console.log(' │ 行知 · 周边出行顾问 TripSense │'); + console.log(' └──────────────────────────────────────────────────┘'); + console.log(` ➜ 本机访问: http://127.0.0.1:${port}`); + if (openToLan) { + const addrs = lanAddresses(); + if (addrs.length) { + for (const a of addrs) { + console.log(` ➜ 局域网访问:http://${a.address}:${port} (${a.name})`); + } + } else { + console.log(' ➜ 局域网访问:未检测到可用网卡地址'); + } + } + console.log(` ➜ AI 模式: ${cfg.AI.API_KEY ? `已配置 (${cfg.AI.BASE_URL} / ${cfg.AI.MODEL})` : '演示模式(未配置 AI_API_KEY,可在网页设置里临时填)'}`); + console.log(` ➜ 公路数据: ${cfg.AMAP_KEY ? '高德 Web 服务' : 'OSRM(失败自动降级估算)'}`); + console.log(` ➜ 铁路数据: ${cfg.RAIL_API_URL ? '自定义铁路接口' : '里程-速度估算'}`); + if (openToLan) { + console.log(''); + console.log(' ⚠ 已绑定 0.0.0.0:同网段的设备都能打开这个页面,且本项目没有登录鉴权。'); + if (cfg.AI.API_KEY) { + console.log(' 服务端已配置 AI_API_KEY——同一网络内的其他人可以通过本服务消耗你的额度。'); + } + console.log(' 仅在可信网络使用;要暴露到公网,请先加认证、限流与 HTTPS。'); + } + console.log(''); +} + +/** + * 端口被占用时自动往后找(最多 10 个),避免因为桌面工具占了 8787 就起不来。 + * 做法是先用独立 socket 探测,确认空闲后再 listen—— + * 同一个 http.Server 反复 listen 在 Windows 上会 error 与 callback 同时触发, + * 表现为 banner 打印两次、日志里的端口和实际监听不一致。 + */ +function probePort(port) { + return new Promise((resolve) => { + const s = require('net').createServer(); + s.once('error', () => resolve(false)); + s.once('listening', () => s.close(() => resolve(true))); + s.listen(port, cfg.HOST); + }); +} + +(async () => { + let port = cfg.PORT; + for (let i = 0; i < 10; i += 1) { + if (await probePort(port)) break; + console.warn(` ⚠ 端口 ${port} 已被占用,改用 ${port + 1}`); + port += 1; + } + server.listen(port, cfg.HOST, () => printBanner(port)); + server.on('error', (e) => { + console.error(`[server] 启动失败:${e.message}`); + process.exit(1); + }); +})(); + +module.exports = server; diff --git a/tools/cache-ui.cjs b/tools/cache-ui.cjs new file mode 100644 index 0000000..c6adfbd --- /dev/null +++ b/tools/cache-ui.cjs @@ -0,0 +1,108 @@ +'use strict'; +/* 缓存命中验证:连续两次「分析周边目的地」,第二次应秒回并出现缓存提示条 */ +const fs = require('fs'); +const path = require('path'); + +const OUT = path.join(__dirname, '..', 'preview'); +const BASE = process.env.BASE || 'http://127.0.0.1:8788'; +const AMAP = process.env.AMAP_KEY || ''; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new CDP(ws); + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res) => { this.waits.set(id, res); this.ws.send(JSON.stringify({ id, method, params, sessionId })); }); + } +} + +(async () => { + const { webSocketDebuggerUrl } = await (await fetch('http://127.0.0.1:9333/json/version')).json(); + const browser = await CDP.connect(webSocketDebuggerUrl); + const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' }); + const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true }); + const S = (m, p) => browser.send(m, p, sessionId); + await S('Page.enable'); await S('Runtime.enable'); + await S('Emulation.setDeviceMetricsOverride', { width: 1360, height: 1400, deviceScaleFactor: 1, mobile: false }); + + const evalJs = async (expr) => { + const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400)); + return r.result?.result?.value; + }; + + await S('Page.navigate', { url: BASE }); + await sleep(2500); + if (AMAP) { + await evalJs(`(() => { + const raw = JSON.parse(localStorage.getItem('tripsense.ai') || '{}'); + raw.amapKey = ${JSON.stringify(AMAP)}; + localStorage.setItem('tripsense.ai', JSON.stringify(raw)); + return true; + })()`); + await S('Page.reload'); + await sleep(2500); + } + + await evalJs(`(() => { const el = document.querySelector('#originInput'); el.value = '柳州'; el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`); + for (let i = 0; i < 20; i++) { + await sleep(1000); + const ok = await evalJs(`(() => { const it = document.querySelector('#suggestBox .suggest-item'); if (it && !document.querySelector('#suggestBox').classList.contains('hidden')) { it.click(); return true; } return false; })()`); + if (ok) break; + } + console.log('origin =', await evalJs(`document.querySelector('#originInput')?.value`)); + + const runAnalyze = async (label) => { + const t0 = Date.now(); + await evalJs(`(() => { const cards = document.querySelectorAll('.dest-card'); cards.forEach(c => c.remove()); const b = document.querySelector('#analyzeBtn'); if (b) b.click(); return true; })()`); + let n = 0; + for (let i = 0; i < 40; i++) { + await sleep(500); + n = await evalJs(`document.querySelectorAll('.dest-card').length`); + if (n) break; + } + const ms = Date.now() - t0; + console.log(`\n[${label}] candidates=${n} elapsed=${ms}ms`); + console.log(' progress =', JSON.stringify(await evalJs(`(document.querySelector('#progress')?.innerText||'').slice(0,200)`))); + console.log(' cacheBar hidden =', await evalJs(`document.querySelector('#cacheBar')?.classList.contains('hidden')`)); + console.log(' cacheBar text =', JSON.stringify(await evalJs(`(document.querySelector('#cacheBar')?.innerText||'').slice(0,300)`))); + return ms; + }; + + const cold = await runAnalyze('第一次(冷)'); + await sleep(1500); + const warm = await runAnalyze('第二次(应命中缓存)'); + + // 点「重新获取最新数据」 + await evalJs(`document.querySelector('#cacheBar button, #cacheBar .link-btn')?.click()`); + for (let i = 0; i < 60; i++) { + await sleep(500); + const hit = await evalJs(`document.querySelector('#cacheBar')?.classList.contains('hidden')`); + if (hit) break; + } + console.log('\n=== 强制刷新后 ==='); + console.log(' cacheBar hidden =', await evalJs(`document.querySelector('#cacheBar')?.classList.contains('hidden')`)); + console.log(' cacheBar text =', JSON.stringify(await evalJs(`(document.querySelector('#cacheBar')?.innerText||'').slice(0,200)`))); + console.log(' progress =', JSON.stringify(await evalJs(`(document.querySelector('#progress')?.innerText||'').slice(0,120)`))); + console.log(' candidates =', await evalJs(`document.querySelectorAll('.dest-card').length`)); + + // 服务端缓存统计 + const health = await (await fetch(BASE + '/api/health')).json(); + console.log('\nserver cache =', JSON.stringify(health.cache)); + + const shot = await S('Page.captureScreenshot', { format: 'png' }); + if (shot.result?.data) fs.writeFileSync(path.join(OUT, 'preview-cache.png'), Buffer.from(shot.result.data, 'base64')); + console.log('screenshot -> preview/preview-cache.png'); + browser.ws.close(); + process.exit(0); +})().catch((e) => { console.error('FAIL', e.message); process.exit(1); }); diff --git a/tools/diag-ui.cjs b/tools/diag-ui.cjs new file mode 100644 index 0000000..0b29988 --- /dev/null +++ b/tools/diag-ui.cjs @@ -0,0 +1,130 @@ +'use strict'; +/* 真实浏览器诊断:跑完整三步,dump 路线图 DOM 与住宿区 DOM */ +const fs = require('fs'); +const path = require('path'); + +const OUT = path.join(__dirname, '..', 'preview'); +const BASE = process.env.BASE || 'http://127.0.0.1:8788'; +const AMAP = process.env.AMAP_KEY || ''; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new CDP(ws); + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res) => { this.waits.set(id, res); this.ws.send(JSON.stringify({ id, method, params, sessionId })); }); + } +} + +(async () => { + const { webSocketDebuggerUrl } = await (await fetch('http://127.0.0.1:9333/json/version')).json(); + const browser = await CDP.connect(webSocketDebuggerUrl); + const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' }); + const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true }); + const S = (m, p) => browser.send(m, p, sessionId); + await S('Page.enable'); await S('Runtime.enable'); + await S('Emulation.setDeviceMetricsOverride', { width: 1360, height: 1800, deviceScaleFactor: 1, mobile: false }); + + const evalJs = async (expr) => { + const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400)); + return r.result?.result?.value; + }; + + // 预置设置(含高德 Key),这样能跑通真实底图与周边 POI + await S('Page.navigate', { url: BASE }); + await sleep(2500); + if (AMAP) { + await evalJs(`(() => { + const raw = JSON.parse(localStorage.getItem('tripsense.ai') || '{}'); + raw.amapKey = ${JSON.stringify(AMAP)}; + localStorage.setItem('tripsense.ai', JSON.stringify(raw)); + return true; + })()`); + await S('Page.reload'); + await sleep(2500); + console.log('amap key injected:', AMAP ? AMAP.slice(0, 6) + '…' : '(none)'); + } + + // 出发地 + await evalJs(`(() => { const el = document.querySelector('#originInput'); el.value = '柳州'; el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`); + for (let i = 0; i < 20; i++) { + await sleep(1000); + const ok = await evalJs(`(() => { const it = document.querySelector('#suggestBox .suggest-item'); if (it && !document.querySelector('#suggestBox').classList.contains('hidden')) { it.click(); return true; } return false; })()`); + if (ok) break; + } + console.log('origin =', await evalJs(`document.querySelector('#originInput')?.value`)); + + // 分析 + await evalJs(`document.querySelector('#analyzeBtn')?.click()`); + for (let i = 0; i < 40; i++) { + await sleep(1500); + const n = await evalJs(`document.querySelectorAll('.dest-card').length`); + if (n) { console.log('candidates =', n); break; } + if (i === 39) console.log('candidates = 0 (超时)'); + } + console.log('alerts =', await evalJs(`(document.querySelector('#alerts')?.innerText || '').slice(0, 300)`)); + + // 选 3 个 + await evalJs(`(() => { const b = [...document.querySelectorAll('.dest-card .pick-btn')].slice(0,3); b.forEach(x => x.click()); return b.length; })()`); + await sleep(2000); + + // 进入第三步(否则面板 display:none,getBoundingClientRect 全是 0) + await evalJs(`document.querySelector('#toStep3')?.click()`); + await sleep(1200); + console.log('step3 active =', await evalJs(`document.querySelector('#panel3')?.classList.contains('active')`)); + + // 生成方案 + await evalJs(`document.querySelector('#planBtn')?.click()`); + for (let i = 0; i < 30; i++) { + await sleep(1500); + const t = await evalJs(`document.querySelector('#outputMeta')?.textContent || ''`); + if (t.includes('生成完成') || t.includes('中断')) { console.log('outputMeta =', t); break; } + if (i === 29) console.log('outputMeta =', t); + } + await sleep(4000); + + // ===== 关键诊断:路线图 ===== + console.log('\n=== 路线图诊断 ==='); + console.log('planExtra hidden =', await evalJs(`document.querySelector('#planExtra')?.classList.contains('hidden')`)); + console.log('routeNote =', JSON.stringify(await evalJs(`document.querySelector('#routeNote')?.textContent`))); + console.log('routeMap children =', await evalJs(`document.querySelector('#routeMap')?.children.length`)); + console.log('routeMap html (600) =', JSON.stringify(await evalJs(`(document.querySelector('#routeMap')?.innerHTML||'').slice(0,600)`))); + console.log('routeMap box =', JSON.stringify(await evalJs(`(() => { const e = document.querySelector('#routeMap'); if(!e) return null; const r = e.getBoundingClientRect(); return {w: Math.round(r.width), h: Math.round(r.height)}; })()`))); + console.log('svg count =', await evalJs(`document.querySelectorAll('#routeMap svg').length`)); + console.log('img count =', await evalJs(`document.querySelectorAll('#routeMap img').length`)); + + // ===== 住宿诊断 ===== + console.log('\n=== 住宿诊断 ==='); + console.log('stayNote =', JSON.stringify(await evalJs(`document.querySelector('#stayNote')?.textContent`))); + console.log('hotelBox (900) =', JSON.stringify(await evalJs(`(document.querySelector('#hotelBox')?.innerText||'').slice(0,900)`))); + + // 截图:先整页,再分别截路线图与住宿卡 + await evalJs(`document.querySelector('#planExtra')?.scrollIntoView({block:'start'})`); + await sleep(1200); + const shot = await S('Page.captureScreenshot', { format: 'png' }); + if (shot.result?.data) fs.writeFileSync(path.join(OUT, 'preview-diag.png'), Buffer.from(shot.result.data, 'base64')); + + const clipOf = async (sel, file) => { + const box = await evalJs(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); if(!e) return null; const r = e.getBoundingClientRect(); return {x:r.x+window.scrollX,y:r.y+window.scrollY,width:r.width,height:r.height}; })()`); + if (!box || box.width < 2) { console.log('no box for', sel); return; } + const s = await S('Page.captureScreenshot', { format: 'png', clip: { ...box, scale: 1 } }); + if (s.result?.data) fs.writeFileSync(path.join(OUT, file), Buffer.from(s.result.data, 'base64')); + }; + await clipOf('#routeMap', 'preview-diag-route.png'); + await clipOf('#hotelBox', 'preview-diag-hotel.png'); + + console.log('\nscreenshots saved to preview/'); + browser.ws.close(); + process.exit(0); +})().catch((e) => { console.error('FAIL', e.message); process.exit(1); }); diff --git a/tools/dump-body.cjs b/tools/dump-body.cjs new file mode 100644 index 0000000..ddef1f3 --- /dev/null +++ b/tools/dump-body.cjs @@ -0,0 +1,82 @@ +'use strict'; +/* 对比两次 analyze 请求体,定位缓存指纹不稳定的原因 */ +const BASE = process.env.BASE || 'http://127.0.0.1:8788'; +const AMAP = process.env.AMAP_KEY || ''; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new CDP(ws); + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res) => { this.waits.set(id, res); this.ws.send(JSON.stringify({ id, method, params, sessionId })); }); + } +} + +(async () => { + const { webSocketDebuggerUrl } = await (await fetch('http://127.0.0.1:9333/json/version')).json(); + const browser = await CDP.connect(webSocketDebuggerUrl); + const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' }); + const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true }); + const S = (m, p) => browser.send(m, p, sessionId); + await S('Page.enable'); await S('Runtime.enable'); + const evalJs = async (expr) => { + const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400)); + return r.result?.result?.value; + }; + + await S('Page.navigate', { url: BASE }); + await sleep(2500); + if (AMAP) { + await evalJs(`(() => { const raw = JSON.parse(localStorage.getItem('tripsense.ai') || '{}'); raw.amapKey = ${JSON.stringify(AMAP)}; localStorage.setItem('tripsense.ai', JSON.stringify(raw)); return true; })()`); + await S('Page.reload'); + await sleep(2500); + } + // 拦截 fetch,记录 /api/analyze 的请求体 + await evalJs(`(() => { + window.__bodies = []; + const of = window.fetch; + window.fetch = async function (u, o) { + if (String(u).includes('/api/analyze') && o && o.body) window.__bodies.push(o.body); + return of.apply(this, arguments); + }; + return true; + })()`); + + await evalJs(`(() => { const el = document.querySelector('#originInput'); el.value = '柳州'; el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`); + for (let i = 0; i < 20; i++) { + await sleep(1000); + const ok = await evalJs(`(() => { const it = document.querySelector('#suggestBox .suggest-item'); if (it && !document.querySelector('#suggestBox').classList.contains('hidden')) { it.click(); return true; } return false; })()`); + if (ok) break; + } + + for (let i = 1; i <= 2; i++) { + await evalJs(`(() => { const b = document.querySelector('#analyzeBtn'); if (b) b.click(); return true; })()`); + for (let k = 0; k < 40; k++) { + await sleep(500); + const n = await evalJs(`document.querySelectorAll('.dest-card').length`); + if (n) break; + } + await sleep(1500); + console.log(`run ${i} done, cards =`, await evalJs(`document.querySelectorAll('.dest-card').length`)); + } + + const bodies = await evalJs(`window.__bodies`); + console.log('\n=== captured analyze bodies ==='); + (bodies || []).forEach((b, i) => console.log(`[${i}]`, b)); + if ((bodies || []).length >= 2) { + console.log('\nidentical =', bodies[0] === bodies[1]); + } + browser.ws.close(); + process.exit(0); +})().catch((e) => { console.error('FAIL', e.message); process.exit(1); }); diff --git a/tools/verify-route.cjs b/tools/verify-route.cjs new file mode 100644 index 0000000..b69a3ea --- /dev/null +++ b/tools/verify-route.cjs @@ -0,0 +1,133 @@ +'use strict'; +/* 验收脚本:真实场景(南宁青秀区出发 → 柳州为目的地)→ 选柳州 → 生成方案 → 检查路线图 */ +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const OUT = path.join(__dirname, '..', 'preview'); +const BASE = process.env.BASE || 'http://127.0.0.1:8791'; +const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; + +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +async function getWsUrl() { + const res = await fetch('http://127.0.0.1:9333/json/version'); + return (await res.json()).webSocketDebuggerUrl; +} + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new CDP(ws); + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res) => { this.waits.set(id, res); this.ws.send(JSON.stringify({ id, method, params, sessionId })); }); + } +} + +(async () => { + const browser = await CDP.connect(await getWsUrl()); + const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' }); + const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true }); + const S = (m, p) => browser.send(m, p, sessionId); + await S('Page.enable'); + await S('Runtime.enable'); + await S('Emulation.setDeviceMetricsOverride', { width: 1400, height: 1700, deviceScaleFactor: 1, mobile: false }); + await S('Page.navigate', { url: BASE }); + await sleep(2500); + + const evalJs = async (expr) => { + const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 500)); + return r.result?.result?.value; + }; + + // 1) 直接注入出发地坐标(河池市金城江区),绕过联想 + await evalJs(`(() => { + state.origin = { name:'河池市金城江区', label:'广西壮族自治区 · 河池市 · 金城江区', lat:24.69562, lon:108.08091, admin1:'广西壮族自治区', country:'中国' }; + document.querySelector('#originInput').value = '河池市金城江区'; + const r = document.querySelector('#radius'); + if (r) { r.value = '250'; r.dispatchEvent(new Event('change',{bubbles:true})); } + return true; + })()`); + await evalJs(`document.querySelector('#analyzeBtn')?.click()`); + // 分析要跑 20~60s(高德检索 + 天气) + for (let i = 0; i < 60; i += 1) { + await sleep(2000); + const n = await evalJs(`document.querySelectorAll('.dest-card').length`); + if (n > 0) { console.log('candidates =', n, 'after', (i + 1) * 2, 's'); break; } + } + + // 2) 找到「柳州市」并勾选它(真实场景:目的地是柳州) + const picked = await evalJs(`(() => { + const cards = [...document.querySelectorAll('.dest-card')]; + const hit = cards.find(c => (c.innerText||'').includes('柳州市')); + if (!hit) return 'NO-LIUZHOU'; + const btn = hit.querySelector('.pick-btn'); + btn.click(); + return 'clicked'; + })()`); + console.log('pick 柳州市 =', picked); + await sleep(1500); + console.log('selected =', await evalJs(`document.querySelector('#selectedCount')?.textContent`)); + + // 3) 生成方案 + await evalJs(`document.querySelector('#planBtn')?.click()`); + for (let i = 0; i < 60; i += 1) { + await sleep(2000); + const t = await evalJs(`(document.querySelector('#planOutput')?.innerText||'').length`); + if (t > 200) { console.log('plan chars =', t, 'after', (i + 1) * 2, 's'); break; } + } + await sleep(4000); + + // 4) 检查路线图状态 + const route = await evalJs(`(() => ({ + note: document.querySelector('#routeNote')?.textContent || '', + legend: document.querySelector('#routeMap .map-legend')?.textContent || '', + hasImg: !!document.querySelector('#routeMap img'), + circles: document.querySelectorAll('#routeMap .map-overlay circle').length, + polyline: !!document.querySelector('#routeMap .map-overlay polyline'), + names: [...document.querySelectorAll('#routeMap .map-overlay .map-name')].map(t=>t.textContent), + hints: [...document.querySelectorAll('#routeMap .map-hint')].map(t=>t.textContent.slice(0,90)), + routePoints: (state.routePoints||[]).map(p=>p.name+'@'+p.lat+','+p.lon), + missing: state.routeMissing || [], + }))()`); + console.log('ROUTE =', JSON.stringify(route, null, 1)); + + await evalJs(`gotoStep(3)`); + await sleep(1200); + await evalJs(`document.querySelector('#routeMap')?.scrollIntoView({block:'center'})`); + await sleep(1200); + const shot = await S('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(path.join(OUT, 'route-plan.png'), Buffer.from(shot.result.data, 'base64')); + console.log('screenshot -> preview/route-plan.png'); + + // 单独截路线图区域(clip 按元素文档坐标截,不依赖滚动位置) + const box = await evalJs(`(() => { + const el = document.querySelector('#routeMap'); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.left + window.scrollX, y: r.top + window.scrollY, width: Math.round(r.width), height: Math.round(r.height) }; + })()`); + console.log('clip box =', JSON.stringify(box)); + if (box && box.height > 20) { + const shot2 = await S('Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: true, + clip: { x: Math.max(0, box.x - 8), y: Math.max(0, box.y - 8), width: box.width + 16, height: box.height + 16, scale: 1 }, + }); + fs.writeFileSync(path.join(OUT, 'route-map.png'), Buffer.from(shot2.result.data, 'base64')); + console.log('screenshot -> preview/route-map.png'); + } + + browser.ws.close(); + process.exit(0); +})().catch((e) => { console.error('FAIL', e.message); process.exit(1); }); diff --git a/tools/verify-ui.cjs b/tools/verify-ui.cjs new file mode 100644 index 0000000..4cb1766 --- /dev/null +++ b/tools/verify-ui.cjs @@ -0,0 +1,117 @@ +'use strict'; +/* 临时验收脚本:走 CDP 打开首页 → 填表 → 分析 → 选目的地 → 生成方案 → 截图 */ +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const OUT = path.join(__dirname, '..', 'preview'); +const BASE = process.env.BASE || 'http://127.0.0.1:8788'; +const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; + +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +async function getWsUrl() { + const port = 9333; + const res = await fetch(`http://127.0.0.1:${port}/json/version`); + const j = await res.json(); + return j.webSocketDebuggerUrl; +} + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.events = []; } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new CDP(ws); + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && c.waits.has(m.id)) { c.waits.get(m.id)(m); c.waits.delete(m.id); } + else if (m.method) c.events.push(m); + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res) => { + this.waits.set(id, res); + this.ws.send(JSON.stringify({ id, method, params, sessionId })); + }); + } +} + +(async () => { + const wsUrl = await getWsUrl(); + const browser = await CDP.connect(wsUrl); + const { result: { targetId } } = await browser.send('Target.createTarget', { url: 'about:blank' }); + const { result: { sessionId } } = await browser.send('Target.attachToTarget', { targetId, flatten: true }); + const S = (m, p) => browser.send(m, p, sessionId); + + await S('Page.enable'); + await S('Runtime.enable'); + await S('Emulation.setDeviceMetricsOverride', { width: 1280, height: 1600, deviceScaleFactor: 1, mobile: false }); + + await S('Page.navigate', { url: BASE }); + await sleep(2500); + + const evalJs = async (expr) => { + const r = await S('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails)); + return r.result?.result?.value; + }; + + // 填出发地 + await evalJs(`(() => { + const el = document.querySelector('#originInput'); + el.value = '柳州'; + el.dispatchEvent(new Event('input', { bubbles: true })); + return true; + })()`); + // 等联想(网络有波动,最多等 20s) + let picked = false; + for (let i = 0; i < 20; i += 1) { + await sleep(1000); + picked = await evalJs(`(() => { + const it = document.querySelector('#suggestBox .suggest-item'); + if (it && !document.querySelector('#suggestBox').classList.contains('hidden')) { it.click(); return true; } + return false; + })()`); + if (picked) break; + } + console.log('suggest picked =', picked, '| box =', await evalJs(`document.querySelector('#suggestBox')?.textContent?.slice(0,60)`)); + await sleep(1500); + const originName = await evalJs(`document.querySelector('#originInput')?.value`); + console.log('origin =', originName); + + // 点击分析 + await evalJs(`document.querySelector('#analyzeBtn')?.click()`); + await sleep(12000); + const candCount = await evalJs(`document.querySelectorAll('.dest-card').length`); + console.log('candidates =', candCount); + + // 选前 3 个 + await evalJs(`(() => { + const btns = [...document.querySelectorAll('.dest-card .pick-btn')].slice(0,3); + btns.forEach(b => b.click()); + return btns.length; + })()`); + await sleep(2000); + console.log('selected =', await evalJs(`document.querySelector('#selectedCount')?.textContent`)); + + // 生成方案 + await evalJs(`document.querySelector('#planBtn')?.click()`); + await sleep(12000); + const meta = await evalJs(`document.querySelector('#outputMeta')?.textContent`); + console.log('outputMeta =', meta); + console.log('plan head =', await evalJs(`(document.querySelector('#planOutput')?.innerText||'').slice(0,80)`)); + + await evalJs(`document.querySelector('#panel3')?.scrollIntoView()`); + await sleep(800); + const shot = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); + if (!shot.result || !shot.result.data) throw new Error('no screenshot data: ' + JSON.stringify(shot).slice(0, 300)); + fs.writeFileSync(path.join(OUT, 'preview-ctx-token.png'), Buffer.from(shot.result.data, 'base64')); + console.log('screenshot saved'); + + browser.ws.close(); + process.exit(0); +})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });