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