24 Commits
Author SHA1 Message Date
zhouruizhe ef8a2881b3 Merge branch 'main' into zhouruizhe
AI Code Review / review (pull_request) Successful in 1s
2026-08-04 17:28:51 +08:00
周瑞哲andClaude Fable 5 0a00cd5b3a feat(travel): 接入远端 AI 推荐服务,支持 GLM real 模式与本地静默回退
AI Code Review / review (pull_request) Successful in 1s
- createPlan 在配置 VITE_TRAVEL_ASSISTANT_API_BASE_URL 时调用 FastAPI 推荐服务
  做无坐标选点,路线/交通/时长仍由本机核算;远端不可用即静默回退本地规划
- 放开 loadPlan 对远端行程的回读限制(原 AI 冻结期禁用),行程页跳转不再丢行程
- planner/assistant 文案按是否启用远端如实切换,不再谎称「本期不调用 AI」
- 新增远端接通/回退/候选载荷单测;server/README 更新联调与 real 模式说明
- .env.development 默认指向已部署推荐服务;server/.env.example 补 GLM 示例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:22:38 +08:00
zhouruizhe fed2a8bf7b Merge pull request 'feat(map): 初始视野对到点位聚类中心,并在已授权时开屏静默定位' (#16) from zhouruizhe into main
Reviewed-on: #16
2026-08-04 08:42:55 +08:00
周瑞哲 377efbffa8 feat(map): 初始视野对到点位聚类中心,并在已授权时开屏静默定位
AI Code Review / review (pull_request) Successful in 6m43s
初始视野
- 新增 computeClusterViewport:把全部点位当一个聚类,中心取算术中心
  (跟点位密度走,不像外接矩形中心那样被个别远点拽偏),scale 由聚类跨度
  反推出「一眼看全」的档位。当前 30 个点位算出 (113.92754, 22.76105)
  scale 12 —— 原来的 scale 11 会把光明区缩成一小块,留一圈空白。
- 视野改成运行时实时算,不再读数据集里手写的 defaultViewport:增删点位之后
  手写值会过期,算出来的不会。手写值降级为点位为空时的兜底。
- 「回到全域」用同一个聚类视野。
- loadDataset 里判断「视野还没被用户动过」原来是硬编码 113.935/22.748/11
  三个字面量,跟 DEFAULT_GUANGMING_VIEWPORT 重复;改成直接跟常量比。

开屏定位
- 新增 restoreLocationOnLaunch:仅当 scope.userLocation 已授权时静默定位并
  居中,蓝点直接出现在用户位置上。未决定/已拒绝一律不碰,避免小程序第一帧
  就弹微信授权框、拒绝后再连弹一个引导框;那两种状态留给定位按钮。
- 静默定位只在用户确实在光明区包络内才居中,否则把地图甩到没有任何点位的
  地方比停在聚类视野更糟。
- 静默失败不弹提示,但仍写入终态,否则 status 卡在 locating、定位按钮
  永远显示「定位中…」。
- 新增 isWithinGuangmingArea,与数据校验共用同一个包络定义。

需要说明:地图漂到几内亚湾不是「开屏没请求定位」造成的,而是没有 fix 时
(0, 0) 被写进 viewport。堵住它的是 isTrustworthyCoordinate(7ef4094),
开屏定位是叠在守卫之上的体验改进,不是替代 —— 用户拒权、模拟器没设位置、
室内超时都还是拿不到 fix。
2026-08-03 12:14:46 +08:00
zhouruizhe 0a2af5ef3d Merge pull request 'Zhouruizhe: 修复了首页空白 以及定位漂移到大西洋的bug' (#14) from zhouruizhe into main
Reviewed-on: #14
2026-08-03 11:25:02 +08:00
周瑞哲 57ba418630 chore: 删除 files/ 下的三份方案文档
AI Code Review / review (pull_request) Successful in 4m45s
本机没装 git-lfs,仓库里留下的是 130 字节的 LFS 指针而不是文档本体,
对代码和构建都没有作用。按需要直接从工作树移除。

.gitattributes 里的 LFS 规则保留,它约束的是以后新增的二进制文件。
2026-08-03 11:12:25 +08:00
周瑞哲 7ef4094788 fix(map): 收敛 regionchange 回写回路,修复选中标点后地图抽动
AI Code Review / review (pull_request) Successful in 2m11s
选中标点后地图在左右/上下反复抽动,根因是视口回写形成了自激回路:
map 的 longitude/latitude/scale 绑定到 store,而 regionchange 又无条件把
地图当前中心点写回 store —— 写 store 触发地图移动,移动结束回传一个
「差一点点」的中心点,再写回,再移动。上一个提交把中心点改成从
detail.centerLocation 同步读取后,回路里原本靠 getCenterLocation 异步跳变
掩盖住的这一点噪声就直接闭合了。

两道闸:
- 只接受手势造成的变化。causedBy 为 update(我们自己改绑定值或调
  includePoints 触发)时 store 已是权威值,不回写。老基础库拿不到
  causedBy 时退化到下一道闸。
- 所有回写走 commitViewport,经 isSignificantViewportChange 过滤掉小于
  1e-4 度 / 0.01 级的变化。远小于任何一次真实拖动,足以吸收量化噪声。

顺带修一个 H5 构建回归(1bcdd40 引入):uni 的 H5 路由生成器按路径推导
组件标识,pages/poi/detail 与 pages-poi/detail 都归一成 PagesPoiDetail,
重复声明导致 build:h5 失败。兼容跳板必须留在旧路径上,所以改名分包路由
pages-poi/detail -> pages-poi/poi-detail。
2026-08-03 11:08:39 +08:00
周瑞哲 1bcdd40729 fix(map): 修正定位落到零坐标、旧详情页路由白屏与跳转期间地图可拖动
三个问题,前两个各有独立根因。

1) 首次打开定位到几内亚湾、底图变成 HERE

   regionchange 处理里读的是 `event.detail.longitude`,但微信的 regionchange
   载荷没有这个字段,`Number(undefined)` 恒为 NaN,于是每次都落到异步的
   getCenterLocation。冷启动时地图 SDK 还没拿到有效中心点,它会回传 (0, 0),
   而两个 store 的坐标校验只查 Number.isFinite,(0, 0) 直接通过。

   (0, 0) 落在几内亚湾(Null Island),微信 <map> 一到境外坐标就切 HERE 底图。
   全程不需要用户点过定位按钮。

   数据集校验器本来就在拒绝零坐标,只是运行时 store 没套用同一条规则。现把它
   提成共享守卫 isTrustworthyCoordinate,validateCoordinates 改为复用,并铺到
   map store、location store 和打卡定位服务。regionchange 一侧改为优先读微信
   真正给的 detail.centerLocation,回落路径校验后才写入,且 mapReady 为 false
   时不接受任何回写。

2) 冷启动白屏、左上角只剩回首页的小房子

   f8ad5e1 把详情页从 pages/poi/detail 移进 pages-poi 分包,源码调用点都改了,
   但旧路由本身从 app.json 消失。任何还攥着旧路径的入口 —— 开发者工具模拟器上次
   停留的路由、预览启动页、已发出的分享卡片或二维码 —— 冷启动会落到微信解析不出
   的路由,渲染成白屏;因为不是 tabBar 页且栈深为 1,左上角只剩小房子。

   旧路径补一个只做重定向的跳板页,带 poiId 就 redirectTo 新路径,不带就 reLaunch
   回地图。用 redirectTo 是为了让跳板不留在页面栈里。主包 +1 KB。等所有入口都刷新
   过可以删掉。

3) 跳转详情页时地图仍可拖动几毫秒

   <map> 是渲染在 webview 之上的原生组件,销毁是异步的,页面转场开始了它还在。
   navigating 之前只用于防重复点击,没接到地图上。现在接上 enable-scroll /
   enable-zoom,并给 selectMarker、openCheckInRecords 补同一个守卫(后者原先完全
   没有防护),regionchange 在跳转期间也不再回写 viewport。onShow 里直接复位
   navigating,返回时立刻恢复交互,不等 500ms 兜底定时器。

   原生组件的异步销毁消不掉,但那几毫秒里地图不再响应拖动和缩放。

验证:测试 100 passed(map store 新增零坐标、越界、resetViewport 兜底用例),
type-check 干净。build 主包 1187 KB / 分包 1841 KB,dev 1527 KB / 1847 KB,
均过 verify:mp-weixin。
2026-08-03 10:45:28 +08:00
周瑞哲 f8ad5e1faf perf(mp-weixin): 照片分两层,详情页移入 pages-poi 分包
微信主包上限 2 MB,30 张 800×600 照片单独就 2.6 MB,
整包 3.2 MB,上传被拒(错误码 80051)。

按使用场景把照片拆成两层:
- 主包缩略图 400×300 q75(678 KB),供地图、行程、打卡卡片使用,
  这些场景最大只显示 192rpx(≈288 px @DPR3),800×600 是过剩
- 分包大图 800×600 q66(1832 KB),供详情页 hero 使用,
  随 pages-poi 分包按需下载

两层都保持 4:3,PoiImage 的 aspectFill 行为不变,视觉表现与之前一致。

详情页从 pages/poi/detail 移到 pages-poi/detail:主包页面无法引用
分包资源,hero 要用大图,页面就必须在分包内。新增
coverPhotoFullUrlFor() 供分包内页面取大图,占位图仍指向主包。

verify-weixin-output.mjs 增加防回归检查:分包注册、两层照片数量
一致、主包与各分包实际体积(留 64 KB 余量)。包体再超限会在
verify 阶段失败,不必等上传才发现。

process-poi-photos.mjs 重写为双层产出,幂等,末尾报告包体占用并
在超限时退出非零。原图改从 src/photo-intake/ 读取(不提交仓库)。

改造后:主包 1522 KB、分包 pages-poi 1847 KB,均在 2048 KB 内。
mp-weixin 与 H5 均编译通过,97 个测试通过,vue-tsc 与 ESLint 无错。
2026-08-03 09:21:23 +08:00
周瑞哲andClaude Opus 5 9e8c7bf908 chore: 清理归档文件和打卡相关旧代码
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:41:11 +08:00
admin b1e0cae07a Merge pull request 'Update planner, check-in records, location service and tests' (#10) from zhouruizhe into main
Reviewed-on: #10
2026-08-01 22:01:35 +08:00
zuozhou 352a94b148 Merge pull request 'feat: 30个景点替换为真实照片,添加图片批处理脚本' (#12) from zuozhou into main
Reviewed-on: #12
2026-07-31 18:17:26 +08:00
zuozhou d01525fd51 feat: 30个景点替换为真实照片,添加图片批处理脚本
AI Code Review / review (pull_request) Successful in 2m58s
2026-07-31 18:11:38 +08:00
zuozhou 33b9c53be6 Merge pull request 'feat: 统一推荐指数显示格式,移除地图底部免责声明' (#11) from zuozhou into main
Reviewed-on: #11
2026-07-31 15:29:11 +08:00
周瑞哲 c0ed0048aa Update planner, check-in records, location service and tests
AI Code Review / review (pull_request) Successful in 6m54s
2026-07-31 15:12:21 +08:00
zuozhou 09f8ec7ecf feat: 统一推荐指数显示格式,移除地图底部免责声明
AI Code Review / review (pull_request) Successful in 2m31s
2026-07-31 15:01:44 +08:00
zhouruizhe 12380cd8cf Merge pull request 'Done: Merge zhouruizhe into 'main'' (#9) from zhouruizhe into main
Reviewed-on: #9
2026-07-31 13:10:17 +08:00
周瑞哲 73664ca974 Merge branch 'main' into zhouruizhe
AI Code Review / review (pull_request) Successful in 5m25s
2026-07-31 12:57:22 +08:00
周瑞哲 5511c85f58 Merge branch 'main' of http://192.168.8.242:3000/zhouruizhe/gmTouringMiniApp 2026-07-31 12:53:14 +08:00
周瑞哲 3ef7266591 Add check-in feature and update travel planner UI 2026-07-31 12:50:14 +08:00
zhouruizhe 14ca296d7d Merge pull request 'zhouruizhe' (#8) from zhouruizhe into main 2026-07-31 11:19:53 +08:00
zhouruizhe 941fd13859 Merge branch 'main' into zhouruizhe
AI Code Review / review (pull_request) Successful in 2m38s
2026-07-31 11:04:46 +08:00
admin 2844ecd5bb Merge pull request 'Admin patch 1' (#1) from team/gmTouringMiniApp:admin-patch-1 into main
Reviewed-on: zhouruizhe/gmTouringMiniApp#1
2026-07-31 08:38:19 +08:00
admin 7deba0dd37 更新 README.md 2026-07-31 08:37:16 +08:00
180 changed files with 4838 additions and 17668 deletions
+7
View File
@@ -1 +1,8 @@
# 在开发时进行加载
# Deployed travel recommender (FastAPI, real GLM) on the gml box, exposed via
# Tailscale Funnel. The /travel/ prefix is stripped by Tailscale before reaching
# the app, so the client calls .../travel/api/v1/plans. Set to empty (or stop the
# service) to use the deterministic local planner; on any remote error the client
# silently falls back to local so the demo never breaks.
VITE_TRAVEL_ASSISTANT_API_BASE_URL=https://desktop-gml.tailc2083d.ts.net/travel
+4
View File
@@ -43,3 +43,7 @@ server/**/*.pyc
# cache
.eslintcache
.stylelintcache
# POI 照片原图投放目录:只作为 scripts/process-poi-photos.mjs 的输入,
# 产出的两层图片(分包大图 + 主包缩略图)才提交仓库。
src/photo-intake/
+19 -10
View File
@@ -1,10 +1,8 @@
# 光明文旅地图 POC
基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅问答式偏好收集行程规划。
基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅偏好收集行程规划和真实定位到点打卡
当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。路线规划支持基于当前位置的快速规划,以及用户自选 2–8 个 POI、2–10 小时时长的自定义规划;本地规划器按时间预算动态排程,配置服务端后可由受控 AI 在审核白名单内推荐点位与顺序。当前不是道路级实时导航,也不包含打卡、登录或个人中心。
## Todo:还需要再次合并打卡功能至main branch.
当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、文旅偏好收集、推荐行程展示和到点打卡。路线规划支持基于当前位置的快速规划,以及用户自选 2–8 个 POI、2–10 小时时长的自定义规划;当前交付统一使用本地规划器按时间预算动态排程。打卡使用用户每次主动触发的位置请求结果核验到点范围,并在本机记录积分、徽章和足迹;当前不是道路级实时导航,也不包含登录或云端个人中心。
## 微信开发者工具
@@ -33,9 +31,9 @@ macOS 已安装微信开发者工具时,也可在另一个终端执行 `corepa
corepack pnpm build:mp-weixin
```
开发编译后可运行 `corepack pnpm verify:mp-weixin`,检查微信工程配置、启动页、个业务页面、三个 tabBar 入口、六个图标和页面四件套是否完整。
开发编译后可运行 `corepack pnpm verify:mp-weixin`,检查微信工程配置、启动页、个业务页面、三个 tabBar 入口、六个图标和页面四件套是否完整。
生产产物位于 `dist/build/mp-weixin`。微信 AppID 通过未提交的 `.env.local``UNI_MP_WEIXIN_APPID` 配置;根目录 IDE 配置暂用 `touristappid`,避免误用模板中的其他项目身份。游客身份只能本地编译,真机预览或上传前必须同时把 `project.config.json` `appid` 换成项目方确认的真实 AppID。详细说明见 [微信小程序构建与验收说明](./docs/微信小程序构建与验收说明.md)。
生产产物位于 `dist/build/mp-weixin`。微信 AppID 通过未提交的 `.env.local``UNI_MP_WEIXIN_APPID` 配置;根目录 `project.config.json` 也必须使用同一个项目 AppID。详细说明见 [微信小程序构建与验收说明](./docs/微信小程序构建与验收说明.md)。
生产验收时可直接导入 `dist/build/mp-weixin`macOS 也可运行 `corepack pnpm ide:mp-weixin:build`。根目录始终用于开发模式,不会因执行生产构建而自动切换到 `dist/build/mp-weixin`
@@ -44,10 +42,11 @@ corepack pnpm build:mp-weixin
```text
src/
├── pages/map/index.vue # 小程序首页:全域地图
├── pages/assistant/index.vue # AI 文旅助手:偏好快捷入口
├── pages/assistant/index.vue # 文旅助手:偏好快捷入口
├── pages/planner/index.vue # 行程偏好表单
├── pages/itinerary/index.vue # 推荐路线与地图展示
├── pages/poi/detail.vue # POI 详情
├── pages/check-in/ # 真实定位打卡、积分、徽章与足迹
├── components/map/ # 地图筛选和摘要组件
├── components/poi/ # POI 图片、标签、状态和图集组件
├── components/travel/ # 行程点位展示组件
@@ -60,11 +59,21 @@ src/
└── static/ # 随包静态资源
```
不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序使用本地确定性 POC 规划器。配置受控后端地址后,服务端 AI 只接收偏好和不含坐标的 POI 白名单,返回推荐 ID、理由和顺序;当前位置、交通估算、时长适配及最终路线仍由小程序本机计算。服务端默认 `LLM_MODE=mock`,只有部署时设置 `LLM_MODE=real` 及模型凭据才会标记为真实 AI。微信公众平台还需配置 HTTPS 合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。
## AI 后续接入说明
实时位置仅由用户主动触发,并只在地图页处于前台时持续更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存也不会发送给远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》
本期 AI 能力留空,不纳入交付和验收。当前 `createPlan` 业务入口固定走本地规划器,即使误配 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 也不会发起模型请求;页面展示的“本地 POC 规划”是确定性排程,不代表大模型生成,旧版本遗留的远程路线缓存也不会在本期恢复
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`IDE 只读取 `dist/dev/mp-weixin`
仓库保留了可选的 `server/` 和客户端远程适配层,但本期不与 `createPlan` 业务入口连接。后续接入时需要在代码中显式启用远程分支,部署 HTTPS 服务,设置 `LLM_MODE=real``OPENAI_API_KEY``OPENAI_MODEL``VITE_TRAVEL_ASSISTANT_API_BASE_URL`,并在微信公众平台加入对应合法 request 域名。模型只负责从审核 POI 白名单中返回推荐 ID、理由和建议顺序;当前位置、坐标、交通估算、时长适配和最终路线继续在本机处理。API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物
实时位置仅由用户主动触发,并只在地图页处于前台时持续更新。“当前位置快速规划”要求使用 5 分钟内的位置快照;用户不授权时仍可使用不依赖定位的自选点位规划。每次打卡会重新请求一次 GCJ-02 位置,不复用地图或规划历史位置;只有在 200 米范围内且定位精度满足要求时才记录成功。精确坐标只用于本次内存计算,不写入匿名偏好、路线或打卡缓存,也不会发送给远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口、完善《用户隐私保护指引》,并现场校验 POC 点位坐标和打卡半径。
## 打卡能力说明
`files/打卡` 仅作为功能参考。本项目复用了“积分摘要、打卡状态、徽章墙和本机记录”的交互概念,没有迁移其杭州模拟点位、CommonJS 页面代码、全量清缓存逻辑或橙黄色视觉。实际实现使用现有光明区已发布 POI,并统一采用项目的绿色文旅视觉体系。
打卡为匿名本机 POC`src/data/check-in/tasks.ts` 会为当前 POI 仓库中的全部已发布点位生成打卡任务;点位下线后会自动停止新打卡,重新发布后仍保留本机历史记录以防重复领奖。所有点位的游客入口坐标与安全到达范围仍需 iOS 现场复核后才能用于正式验收。每个开放点位首次成功打卡增加 10 积分,累计完成 1、2、3 个开放点位时依次解锁徽章。积分、徽章与足迹使用独立版本化存储键;v1 旧格式首次升级时只迁移当前开放点位记录,进入 v2 后则保留合法历史足迹。清除操作只删除打卡数据,不影响路线和偏好。打卡记录不保存经纬度、定位精度或轨迹。微信客户端无法可靠识别系统级缓存位置或虚拟定位,因此本期只承诺每次点击重新调用定位接口并校验微信返回的精度,不提供虚拟定位测试入口或生产级反作弊承诺。
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`IDE 只读取 `dist/dev/mp-weixin`。仓库中的 `src/manifest.json` 以开发模式快照为准,`【dev】` / `【prod】` 项目名前缀由构建模式自动生成;提交时不应单独保留由最后一次构建造成的前缀漂移。
## POC 数据声明
+14 -9
View File
@@ -2,11 +2,11 @@
## 1. 当前交付边界
- 本项目是光明区文旅全域地图、POI 数字化展示、AI 文旅助手与行程规划 POC
- 本项目是光明区文旅全域地图、POI 数字化展示、本地行程规划与真实定位打卡 POC;AI 模型接入留待后续版本
- 目标端为微信小程序,当前只要求在一台实际 iOS 设备验证。
- 用户主动授权后,地图可显示前台实时位置;行程会用本次位置快照优化首站和游览顺序。
- 用户主动授权后,地图可显示前台实时位置;当前位置快速规划要求使用 5 分钟内的位置快照优化首站和游览顺序。
- 行程由随包 POI 和确定性规则生成,用于验证推荐链路,不提供道路级实时路况、路径导航或到达承诺。
- 不包含打卡、登录或个人中心;匿名偏好最近行程保存在本机,精确位置只保留在内存会话中
- 不包含登录或云端个人中心;匿名偏好最近行程、打卡积分、徽章与足迹保存在本机,精确位置只用于内存中的本次判定
- 当前随包数据明确标注为 `POC 审核样本`,不能宣称已覆盖光明区全部文旅资源。
## 2. 地图和高德能力说明
@@ -26,8 +26,8 @@
1. 复制 `.env.example``.env.local`
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`
3. 真机预览或上传前,根目录 `project.config.json``appid` `touristappid` 同步为相同的真实 AppID。
4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。服务端必须设置 `LLM_MODE=real` 才会作为真实 AI 展示;mock 仅用于联调演示
3. 真机预览或上传前,确认根目录 `project.config.json``appid` 相同的真实 AppID 一致
4. 本期 `createPlan` 已固定使用本地 POC 规划器,`server/` 及远程适配层仅作为后续 AI 接入预留,不纳入本期验收
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
@@ -48,7 +48,9 @@ corepack pnpm build:mp-weixin
项目的微信开发与生产脚本默认对文件监听使用轮询,用于规避部分 macOS 会话中 `EMFILE: too many open files, watch` 问题。这不改变构建产物,但开发模式的本机资源占用会略有增加。
开发模式启动后可运行 `corepack pnpm verify:mp-weixin`,自动检查根目录 IDE 配置、启动页、五个业务页面、三个 tabBar 入口、图标和微信页面四件套是否完整
微信产物启用了 `lazyCodeLoading: "requiredComponents"`,页面只注入实际使用的自定义组件,以减少小程序启动开销。该能力需要微信基础库 2.11.1 及以上;目标基础库尚未冻结时,应在 iOS 真机验收所用微信版本中确认兼容性
开发模式启动后可运行 `corepack pnpm verify:mp-weixin`,自动检查根目录 IDE 配置、启动页、七个业务页面、三个 tabBar 入口、图标和微信页面四件套是否完整。
开发模式产物位于 `dist/dev/mp-weixin`;根目录 `project.config.json` 已将微信开发者工具的 `miniprogramRoot``srcMiniprogramRoot` 指向该目录。先运行开发编译,再用微信开发者工具直接导入项目根目录。
@@ -78,9 +80,12 @@ macOS 可在另一个终端执行 `corepack pnpm ide:mp-weixin` 调起已安装
5. 返回地图后恢复分类、中心、缩放和选中点位。
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
8. 从“AI 助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。
9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。
8. 从“文旅助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。
9. 关闭网络后验证本地规划仍可生成;确认当前业务入口不调用远程 AI客户端产物不含模型凭证。
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储不包含精确坐标,且调试面板没有远程规划请求
12. 确认全部已发布 POI 的详情页均展示打卡入口,未发布或已下线 POI 不开放新打卡;从详情进入打卡页后,仅在点击按钮时重新请求一次位置,分别验证隐私授权、200 米范围内成功、范围外、精度不足、边界不确定和重复打卡。
13. 检查首次打卡增加 10 积分并解锁首枚徽章,重启后记录仍存在;“清除本机打卡数据”不得删除路线或偏好。
14. 扫描本机打卡存储,确认不包含经纬度、定位精度或轨迹;开发者工具模拟位置仅用于流程测试,正式成功验收必须使用 iOS 真机现场定位。
在项目方尚未提供正式 AppID、真实清权图片和冻结基准清单时,只能判定“样本链路通过”,不能判定正式内容或全域覆盖通过。
-21
View File
@@ -1,21 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-20
View File
@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-71
View File
@@ -1,71 +0,0 @@
{
"name": "uni-preset-vue",
"version": "0.0.0",
"scripts": {
"dev:custom": "uni -p",
"dev:h5": "uni",
"dev:h5:ssr": "uni --ssr",
"dev:mp-alipay": "uni -p mp-alipay",
"dev:mp-baidu": "uni -p mp-baidu",
"dev:mp-jd": "uni -p mp-jd",
"dev:mp-kuaishou": "uni -p mp-kuaishou",
"dev:mp-lark": "uni -p mp-lark",
"dev:mp-qq": "uni -p mp-qq",
"dev:mp-toutiao": "uni -p mp-toutiao",
"dev:mp-harmony": "uni -p mp-harmony",
"dev:mp-weixin": "uni -p mp-weixin",
"dev:mp-xhs": "uni -p mp-xhs",
"dev:quickapp-webview": "uni -p quickapp-webview",
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:custom": "uni build -p",
"build:h5": "uni build",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"build:mp-baidu": "uni build -p mp-baidu",
"build:mp-jd": "uni build -p mp-jd",
"build:mp-kuaishou": "uni build -p mp-kuaishou",
"build:mp-lark": "uni build -p mp-lark",
"build:mp-qq": "uni build -p mp-qq",
"build:mp-toutiao": "uni build -p mp-toutiao",
"build:mp-harmony": "uni build -p mp-harmony",
"build:mp-weixin": "uni build -p mp-weixin",
"build:mp-xhs": "uni build -p mp-xhs",
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-5010520260709002",
"@dcloudio/uni-app-harmony": "3.0.0-5010520260709002",
"@dcloudio/uni-app-plus": "3.0.0-5010520260709002",
"@dcloudio/uni-components": "3.0.0-5010520260709002",
"@dcloudio/uni-h5": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-alipay": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-baidu": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-harmony": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-jd": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-kuaishou": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-lark": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-qq": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-toutiao": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-weixin": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-xhs": "3.0.0-5010520260709002",
"@dcloudio/uni-quickapp-webview": "3.0.0-5010520260709002",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-5010520260709002",
"@dcloudio/uni-cli-shared": "3.0.0-5010520260709002",
"@dcloudio/uni-stacktracey": "3.0.0-5010520260709002",
"@dcloudio/vite-plugin-uni": "3.0.0-5010520260709002",
"@vue/tsconfig": "^0.1.3",
"@vue/runtime-core": "^3.4.21",
"typescript": "^4.9.4",
"vite": "5.2.8",
"vue-tsc": "^1.0.24"
}
}
@@ -1,25 +0,0 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wx2a49d023ec320d0b",
"editorSetting": {}
}
-10
View File
@@ -1,10 +0,0 @@
/// <reference types='@dcloudio/types' />
import 'vue'
declare module '@vue/runtime-core' {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {
}
}
-13
View File
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
onLaunch(() => {
console.log("App Launch");
});
onShow(() => {
console.log("App Show");
});
onHide(() => {
console.log("App Hide");
});
</script>
<style></style>
@@ -1,61 +0,0 @@
import type { APIError, PlanResponse, TravelPreferences } from '@/types/travel'
// H5 dev uses the Vite /api proxy; mini-program builds should inject an HTTPS base URL.
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
const request = <T>(options: UniApp.RequestOptions): Promise<T> =>
new Promise((resolve, reject) => {
uni.request({
...options,
success: (response) => {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data as T)
return
}
const error = response.data as APIError
reject(new Error(error?.detail || `请求失败(${response.statusCode}`))
},
fail: () => {
reject(new Error('无法连接行程服务,请检查后端是否已启动'))
},
})
})
export const createPlan = (preferences: TravelPreferences) =>
request<PlanResponse>({
url: `${API_BASE_URL}/api/v1/plans`,
method: 'POST',
data: preferences,
header: { 'content-type': 'application/json' },
timeout: 50000,
})
export const adjustPlan = (conversationId: string, message: string) =>
request<PlanResponse>({
url: `${API_BASE_URL}/api/v1/conversations/${conversationId}/messages`,
method: 'POST',
data: { message },
header: { 'content-type': 'application/json' },
timeout: 50000,
})
export const PLAN_STORAGE_KEY = 'guangming:last-plan'
export const PREFERENCES_STORAGE_KEY = 'guangming:last-preferences'
export const savePlan = (plan: PlanResponse) => {
uni.setStorageSync(PLAN_STORAGE_KEY, plan)
}
export const loadPlan = (): PlanResponse | null => {
const value = uni.getStorageSync(PLAN_STORAGE_KEY)
return value && typeof value === 'object' ? (value as PlanResponse) : null
}
export const savePreferences = (preferences: TravelPreferences) => {
uni.setStorageSync(PREFERENCES_STORAGE_KEY, preferences)
}
export const loadPreferences = (): TravelPreferences | null => {
const value = uni.getStorageSync(PREFERENCES_STORAGE_KEY)
return value && typeof value === 'object' ? (value as TravelPreferences) : null
}
-8
View File
@@ -1,8 +0,0 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}
-8
View File
@@ -1,8 +0,0 @@
import { createSSRApp } from "vue";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
return {
app,
};
}
-72
View File
@@ -1,72 +0,0 @@
{
"name" : "",
"appid" : "",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx2a49d023ec320d0b",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics": {
"enable": false
},
"vueVersion" : "3"
}
-31
View File
@@ -1,31 +0,0 @@
{
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "逐光 · 光明区"
}
},
{
"path": "pages/planner/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "定制光明行程"
}
},
{
"path": "pages/itinerary/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的光明行程"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "逐光文旅",
"navigationBarBackgroundColor": "#1D392C",
"backgroundColor": "#F1EEE5"
}
}
@@ -1,199 +0,0 @@
<template>
<view class="page">
<view class="hero">
<view class="orb orb-one" />
<view class="orb orb-two" />
<view class="grid" />
<view class="topbar">
<view class="brand">
<text class="brand-cn"> </text>
<text class="brand-en">GUANGMING FIELD GUIDE</text>
</view>
<view class="district-pill">深圳 · 光明区</view>
</view>
<view class="hero-copy">
<text class="eyebrow">SCIENCE × NATURE × CITY</text>
<text class="hero-title">在光明<br />遇见城市的<br /><text class="accent">另一种呼吸</text></text>
<text class="hero-body">从未来科技馆到都市田园一天之内切换科学自然与生活</text>
<view class="hero-action" @click="makePlan">
<text>定制我的光明行程</text>
<text class="arrow"></text>
</view>
</view>
<view class="coordinate">
<text>22.748° N</text>
<view class="coordinate-line" />
<text>113.935° E</text>
</view>
</view>
<view class="content">
<view class="intro">
<text class="section-kicker">WHY GUANGMING</text>
<text class="intro-title">一座把未来种进田野的城区</text>
<text class="intro-body">这里有科学城的银色飞船也有四季变化的田园花景沿着绿道慢慢走城市的锋芒与自然的松弛恰好相遇</text>
<view class="fact-row">
<view class="fact">
<text class="fact-value">½1</text>
<text class="fact-label">推荐游玩天数</text>
</view>
<view class="fact">
<text class="fact-value">15</text>
<text class="fact-label">POC精选地点</text>
</view>
<view class="fact">
<text class="fact-value">AI</text>
<text class="fact-label">按偏好编排行程</text>
</view>
</view>
</view>
<view class="section-head">
<view>
<text class="section-kicker">THREE WAYS TO SEE</text>
<text class="section-title">三种光明</text>
</view>
<text class="index-mark">03</text>
</view>
<view class="story-list">
<view v-for="(story, index) in stories" :key="story.title" class="story" :class="`story-${index + 1}`">
<view class="story-art">
<view class="story-shape" />
<text class="story-number">0{{ index + 1 }}</text>
</view>
<view class="story-copy">
<text class="story-tag">{{ story.tag }}</text>
<text class="story-title">{{ story.title }}</text>
<text class="story-desc">{{ story.desc }}</text>
</view>
</view>
</view>
<view class="planner-card" @click="makePlan">
<view class="planner-top">
<text class="planner-label">AI TRIP MAKER</text>
<text class="spark"></text>
</view>
<text class="planner-title">不照搬攻略<br />只生成适合你的光明</text>
<view class="choice-preview">
<text>亲子</text><text>半日</text><text>轻松</text><text>自然</text>
</view>
<view class="planner-button">
<text>开始选择偏好</text>
<text></text>
</view>
</view>
<text class="source-note">地点资料基于光明区政府及场馆公开信息整理动态开放与预约信息请出行前确认</text>
</view>
</view>
</template>
<script setup lang="ts">
const stories = [
{ tag: 'FUTURE / 未来', title: '科学城的银色飞船', desc: '走进深圳科学技术馆,在互动展项里触摸数字文明。' },
{ tag: 'FIELD / 田野', title: '都市里的四季田园', desc: '在欢乐田园等风吹过稻浪,把时间交还给自然。' },
{ tag: 'GREEN / 漫游', title: '桥与绿道之间', desc: '沿虹桥与山林步道散步,看城市向绿色深处延伸。' },
]
const makePlan = () => {
uni.navigateTo({ url: '/pages/planner/index' })
}
</script>
<style>
page { background: #efeede; }
.page { min-height: 100vh; color: #17382f; background: #efeede; }
.hero { position: relative; height: 1180rpx; overflow: hidden; color: #f5f1da; background: linear-gradient(145deg, #123d35 0%, #0b2825 62%, #071d1c 100%); }
.grid { position: absolute; inset: 0; opacity: .15; background-image: linear-gradient(rgba(255,255,255,.22) 1rpx, transparent 1rpx), linear-gradient(90deg, rgba(255,255,255,.22) 1rpx, transparent 1rpx); background-size: 86rpx 86rpx; transform: perspective(500rpx) rotateX(62deg) scale(1.5); transform-origin: bottom; }
.orb { position: absolute; border-radius: 50%; filter: blur(2rpx); }
.orb-one { width: 620rpx; height: 620rpx; right: -240rpx; top: 130rpx; border: 1rpx solid rgba(208,255,101,.5); box-shadow: 0 0 120rpx rgba(174,235,55,.13) inset; }
.orb-two { width: 190rpx; height: 190rpx; right: 58rpx; top: 330rpx; background: #c5ef55; box-shadow: 0 0 100rpx rgba(197,239,85,.35); }
.topbar { position: relative; z-index: 2; display: flex; justify-content: space-between; align-items: flex-start; padding: calc(34rpx + env(safe-area-inset-top)) 38rpx 0; }
.brand { display: flex; flex-direction: column; gap: 4rpx; }
.brand-cn { font-family: 'STSong','Songti SC',serif; font-size: 30rpx; font-weight: 700; letter-spacing: 14rpx; }
.brand-en { font-size: 13rpx; letter-spacing: 3rpx; opacity: .6; }
.district-pill { flex-shrink: 0; padding: 11rpx 15rpx; border: 1rpx solid rgba(255,255,255,.3); border-radius: 999rpx; font-size: 18rpx; letter-spacing: 1rpx; }
.hero-copy { position: absolute; z-index: 2; left: 42rpx; right: 42rpx; bottom: 130rpx; }
.eyebrow,.section-kicker { display: block; color: #a5c83d; font-size: 18rpx; letter-spacing: 5rpx; }
.hero-title { display: block; margin-top: 32rpx; font-family: 'STSong','Songti SC',serif; font-size: 78rpx; line-height: 1.2; font-weight: 700; letter-spacing: 2rpx; }
.accent { color: #c8ed5d; font-style: italic; }
.hero-body { display: block; width: 560rpx; margin-top: 28rpx; color: rgba(245,241,218,.7); font-size: 27rpx; line-height: 1.8; }
.hero-action { display: flex; justify-content: space-between; align-items: center; width: 570rpx; margin-top: 40rpx; padding: 27rpx 30rpx; box-sizing: border-box; color: #102f29; background: #c8ed5d; font-size: 27rpx; font-weight: 700; }
.arrow { font-size: 38rpx; }
.coordinate { position: absolute; z-index: 2; right: -84rpx; bottom: 325rpx; display: flex; align-items: center; gap: 14rpx; color: rgba(255,255,255,.42); font-size: 15rpx; letter-spacing: 2rpx; transform: rotate(90deg); }
.coordinate-line { width: 80rpx; height: 1rpx; background: rgba(255,255,255,.3); }
.content { padding: 0 36rpx 100rpx; background: radial-gradient(circle at 90% 10%, rgba(200,237,93,.16), transparent 340rpx), #efeede; }
.intro { padding: 84rpx 6rpx 78rpx; border-bottom: 1rpx solid rgba(23,56,47,.16); }
.intro-title,.section-title { display: block; font-family: 'STSong','Songti SC',serif; font-weight: 700; }
.intro-title { margin-top: 22rpx; font-size: 51rpx; line-height: 1.35; }
.intro-body { display: block; margin-top: 25rpx; color: #4f635b; font-family: 'STSong','Songti SC',serif; font-size: 28rpx; line-height: 1.95; }
.fact-row { display: flex; margin-top: 48rpx; }
.fact { flex: 1; display: flex; flex-direction: column; gap: 9rpx; }
.fact-value { font-family: Georgia,serif; font-size: 37rpx; color: #17382f; }
.fact-label { font-size: 19rpx; color: #718078; }
.section-head { display: flex; justify-content: space-between; align-items: flex-end; padding: 78rpx 6rpx 38rpx; }
.section-title { margin-top: 12rpx; font-size: 54rpx; }
.index-mark { font-family: Georgia,serif; font-style: italic; font-size: 68rpx; color: rgba(23,56,47,.17); }
.story-list { display: flex; flex-direction: column; gap: 24rpx; }
.story { display: flex; min-height: 260rpx; background: #f7f4e8; box-shadow: 0 18rpx 50rpx rgba(19,52,44,.07); }
.story-art { position: relative; width: 250rpx; overflow: hidden; background: #173d35; }
.story-shape { position: absolute; width: 200rpx; height: 200rpx; border-radius: 50%; right: -70rpx; bottom: -54rpx; background: #c7eb5d; }
.story-2 .story-art { background: #ca6b3d; }
.story-2 .story-shape { width: 250rpx; height: 110rpx; border-radius: 50% 50% 0 0; right: -35rpx; bottom: 0; background: #f2c85d; }
.story-3 .story-art { background: #244f48; }
.story-3 .story-shape { width: 280rpx; height: 50rpx; right: -20rpx; bottom: 76rpx; border-radius: 99rpx; background: #df593f; transform: rotate(-26deg); }
.story-number { position: absolute; left: 22rpx; top: 20rpx; color: rgba(255,255,255,.75); font-family: Georgia,serif; font-size: 23rpx; }
.story-copy { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 28rpx; }
.story-tag { color: #b05e39; font-size: 17rpx; letter-spacing: 3rpx; }
.story-title { margin-top: 13rpx; font-family: 'STSong','Songti SC',serif; font-size: 33rpx; font-weight: 700; }
.story-desc { margin-top: 13rpx; color: #68776f; font-size: 22rpx; line-height: 1.65; }
.planner-card { margin-top: 90rpx; padding: 40rpx; color: #f3f0dc; background: #15372f; box-shadow: 18rpx 18rpx 0 #c8ed5d; }
.planner-top { display: flex; align-items: center; justify-content: space-between; }
.planner-label { color: #c8ed5d; font-size: 18rpx; letter-spacing: 4rpx; }
.spark { color: #c8ed5d; font-size: 45rpx; }
.planner-title { display: block; margin-top: 30rpx; font-family: 'STSong','Songti SC',serif; font-size: 47rpx; line-height: 1.45; font-weight: 700; }
.choice-preview { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 34rpx; }
.choice-preview text { padding: 12rpx 18rpx; border: 1rpx solid rgba(255,255,255,.28); border-radius: 999rpx; font-size: 20rpx; }
.planner-button { display: flex; justify-content: space-between; margin-top: 36rpx; padding-top: 28rpx; border-top: 1rpx solid rgba(255,255,255,.2); color: #c8ed5d; font-size: 25rpx; }
.source-note { display: block; margin: 72rpx 16rpx 0; color: #87928b; font-size: 19rpx; line-height: 1.7; text-align: center; }
/* AI travel assistant visual system */
page,.page { background: #f4f8f6; }
.page { color: #17221d; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
.hero { height: 1020rpx; color: #17221d; background: radial-gradient(circle at 90% 28%, rgba(8,156,92,.14), transparent 360rpx), linear-gradient(180deg,#fff 0%,#f3faf6 100%); }
.grid { opacity: .28; background-image: linear-gradient(rgba(7,142,85,.1) 1rpx, transparent 1rpx),linear-gradient(90deg,rgba(7,142,85,.1) 1rpx,transparent 1rpx); }
.orb-one { border-color: rgba(7,156,92,.22); box-shadow: 0 0 120rpx rgba(7,156,92,.08) inset; }
.orb-two { background: #0a9b5d; box-shadow: 0 0 80rpx rgba(7,156,92,.22); }
.topbar { padding-top: calc(28rpx + env(safe-area-inset-top)); }
.brand-cn { color: #087b4a; }
.brand-en { color: #73827a; opacity: 1; }
.district-pill { color: #087b4a; border-color: #b9d9c9; background: rgba(255,255,255,.75); }
.hero-copy { bottom: 100rpx; }
.eyebrow,.section-kicker { color: #078e55; }
.hero-title { font-size: 72rpx; }
.accent { color: #078e55; font-style: normal; }
.hero-body { color: #68766f; }
.hero-action { width: 590rpx; border-radius: 20rpx; color: #fff; background: linear-gradient(100deg,#079c5c,#067d4a); box-shadow: 0 14rpx 32rpx rgba(3,129,75,.24); }
.coordinate { color: rgba(7,96,57,.4); }
.coordinate-line { background: rgba(7,96,57,.24); }
.content { background: #f4f8f6; }
.intro { margin-top: 28rpx; padding: 58rpx 34rpx; border: 2rpx solid #dce9e2; border-radius: 30rpx; background: #fff; box-shadow: 0 18rpx 48rpx rgba(19,75,50,.07); }
.intro-title,.section-title,.story-title,.planner-title { font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
.intro-body { color: #617068; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; font-size: 25rpx; }
.story { border: 2rpx solid #e0e9e4; border-radius: 24rpx; overflow: hidden; background: #fff; box-shadow: 0 12rpx 34rpx rgba(19,75,50,.06); }
.story-art { background: #087c4b; }
.story-shape { background: #8fe0b8; }
.story-2 .story-art { background: #0b9560; }
.story-2 .story-shape { background: #b6ead0; }
.story-3 .story-art { background: #076b48; }
.story-3 .story-shape { background: #62cea0; }
.story-tag { color: #078e55; }
.planner-card { border-radius: 30rpx; background: linear-gradient(145deg,#089b5d,#067645); box-shadow: 0 20rpx 50rpx rgba(3,114,66,.22); }
.planner-label,.spark,.planner-button { color: #d9ffeb; }
.choice-preview text { background: rgba(255,255,255,.08); }
</style>
@@ -1,321 +0,0 @@
<template>
<view v-if="plan" class="page">
<view class="nav">
<view class="back" @click="goBack"></view>
<text class="nav-title">推荐路线</text>
<text class="replan" @click="goPlanner">重新规划</text>
</view>
<view class="content">
<view class="route-card">
<view class="recommend"><text class="recommend-icon"></text>为你推荐以下路线</view>
<text class="route-title">{{ plan.itinerary.title }}</text>
<view class="tags">
<text v-for="tag in routeTags" :key="tag">{{ tag }}</text>
</view>
<view class="route-meta">
<view><text class="meta-label">总时长</text><text class="meta-value">{{ formatMinutes(plan.itinerary.totalMinutes) }}</text></view>
<view><text class="meta-label">行程</text><text class="meta-value">{{ plan.itinerary.items.length }} 个地点</text></view>
</view>
<text class="route-summary">{{ plan.itinerary.summary }}</text>
</view>
<view v-if="plan.changeSummary" class="change-note"><text> 已更新</text>{{ plan.changeSummary }}</view>
<view class="map-card">
<view class="map-grid" />
<view class="park park-one" />
<view class="park park-two" />
<view class="water" />
<view class="road road-one" />
<view class="road road-two" />
<view class="road road-three" />
<view class="route-line route-one" />
<view class="route-line route-two" />
<view class="route-line route-three" />
<view
v-for="(item, index) in mapItems"
:key="item.placeId"
class="map-stop"
:class="`map-stop-${index + 1}`"
>
<view class="map-pin">{{ index + 1 }}</view>
<text class="map-label">{{ shortName(item.placeName) }}</text>
</view>
<view class="map-badge"><text>路线示意</text><text>非实时地图</text></view>
</view>
<view class="list-card">
<view
v-for="(item, index) in plan.itinerary.items"
:key="`${item.placeId}-${index}`"
class="place-row"
>
<view class="place-art" :class="`art-${index % 4}`">
<view class="sun" />
<view class="hill hill-back" />
<view class="hill hill-front" />
<view class="building"><view /><view /><view /></view>
<text>{{ String(index + 1).padStart(2, '0') }}</text>
</view>
<view class="place-copy">
<view class="place-head">
<view class="place-number">{{ index + 1 }}</view>
<text class="place-name">{{ item.placeName }}</text>
</view>
<view class="place-tags">
<text>{{ index === 0 ? '首站' : '推荐' }}</text>
<text>{{ preferenceTag(index) }}</text>
</view>
<text class="activity">{{ item.activity }}</text>
<view class="place-foot">
<text>{{ item.startTime }}{{ item.endTime }}</text>
<text v-if="index">{{ transferText(item.transferFromPreviousMinutes) }}</text>
</view>
<view class="reason"><text></text>{{ item.reason }}</view>
</view>
</view>
</view>
<view class="cost-card">
<view class="cost-icon">¥</view>
<view><text>费用提示</text><text>{{ plan.itinerary.estimatedCostText }}</text></view>
</view>
<view class="adjust-card">
<text class="section-kicker">AI 路线微调</text>
<text class="section-title">还想怎么调整</text>
<view class="quick-list">
<view v-for="prompt in quickPrompts" :key="prompt" class="quick" @click="sendAdjustment(prompt)">
<text>{{ prompt }}</text><text></text>
</view>
</view>
<view class="composer">
<input
v-model="message"
:disabled="adjusting"
maxlength="500"
placeholder="例如:第二个地点换成室内项目"
placeholder-class="placeholder"
confirm-type="send"
@confirm="sendAdjustment()"
/>
<button :disabled="adjusting || !message.trim()" @click="sendAdjustment()">{{ adjusting ? '' : '发送' }}</button>
</view>
</view>
<view class="detail-card">
<view class="detail-section">
<text class="section-kicker">出行提醒</text>
<text v-for="note in plan.itinerary.notes" :key="note" class="note">· {{ note }}</text>
</view>
<view class="detail-section sources">
<text class="section-kicker">资料来源</text>
<view v-for="source in plan.itinerary.sources" :key="source.placeId" class="source" @click="copySource(source.sourceUrl)">
<view><text>{{ source.sourceName }}</text><text>{{ source.updatedAt }} 更新</text></view><text>复制链接</text>
</view>
</view>
</view>
</view>
<view class="bottom-bar">
<button class="save-button" @click="saveRoute"><text></text>{{ saved ? '已保存' : '保存路线' }}</button>
<button class="accept-button" @click="acceptRoute"><text></text>采纳这条路线</button>
</view>
</view>
<view v-else class="empty">
<view class="empty-icon"></view>
<text>还没有生成行程</text>
<button @click="goPlanner">返回选择偏好</button>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { adjustPlan, loadPlan, loadPreferences, savePlan } from '@/api/travelAssistant'
import type { PlanResponse, TravelPreferences } from '@/types/travel'
const plan = ref<PlanResponse | null>(null)
const preferences = ref<TravelPreferences | null>(null)
const message = ref('')
const adjusting = ref(false)
const saved = ref(false)
const quickPrompts = ['节奏慢一点', '换一个室内地点', '增加亲子体验']
const paceLabels = { relaxed: '轻松休闲', moderate: '节奏适中', compact: '紧凑高效' }
const durationLabels = { half_day: '半日游', full_day: '一日游' }
const routeTags = computed(() => {
const value = preferences.value
if (!value) return ['AI 优选', '光明文旅']
return [
durationLabels[value.duration],
value.themes[0],
paceLabels[value.pace],
value.interests[0],
].filter(Boolean)
})
const mapItems = computed(() => plan.value?.itinerary.items.slice(0, 4) || [])
onLoad(() => {
plan.value = loadPlan()
preferences.value = loadPreferences()
})
const formatMinutes = (minutes: number) => {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest ? `${hours}小时${rest}` : `${hours}小时`
}
const transferText = (minutes: number | null) => minutes === null ? '交通待确认' : `${minutes} 分钟`
const shortName = (name: string) => name.length > 8 ? `${name.slice(0, 8)}` : name
const preferenceTag = (index: number) => {
const interests = preferences.value?.interests || []
return interests[index % Math.max(interests.length, 1)] || '光明精选'
}
const goBack = () => uni.navigateBack()
const goPlanner = () => uni.redirectTo({ url: '/pages/planner/index' })
const copySource = (url: string) => {
uni.setClipboardData({ data: url, success: () => uni.showToast({ title: '来源链接已复制', icon: 'none' }) })
}
const saveRoute = () => {
if (!plan.value) return
savePlan(plan.value)
saved.value = true
uni.showToast({ title: '路线已保存', icon: 'success' })
}
const acceptRoute = () => {
saveRoute()
uni.showModal({
title: '路线已采纳',
content: '行程已保存到本机。真实地图导航将在下一阶段接入。',
showCancel: false,
})
}
const sendAdjustment = async (quick?: string) => {
if (!plan.value || adjusting.value) return
const content = (quick || message.value).trim()
if (!content) return
adjusting.value = true
try {
const response = await adjustPlan(plan.value.conversationId, content)
plan.value = response
savePlan(response)
saved.value = false
message.value = ''
uni.pageScrollTo({ scrollTop: 0, duration: 300 })
} catch (error) {
uni.showModal({ title: '调整未完成', content: error instanceof Error ? error.message : '上一次行程已为你保留', showCancel: false })
} finally {
adjusting.value = false
}
}
</script>
<style>
page { background: #f4f8f6; }
.page { min-height: 100vh; padding-bottom: calc(150rpx + env(safe-area-inset-bottom)); color: #17221d; background: radial-gradient(circle at 8% 8%,rgba(9,151,89,.06),transparent 340rpx),#f4f8f6; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
.nav { position: sticky; z-index: 30; top: 0; height: 100rpx; display: flex; align-items: center; justify-content: space-between; padding: calc(24rpx + env(safe-area-inset-top)) 28rpx 0; border-bottom: 1rpx solid #e7eeea; background: rgba(255,255,255,.96); }
.back { width: 76rpx; color: #14201a; font-family: Georgia,serif; font-size: 59rpx; font-weight: 300; }
.nav-title { position: absolute; left: 180rpx; right: 180rpx; text-align: center; font-size: 32rpx; font-weight: 800; }
.replan { color: #087d4d; font-size: 23rpx; font-weight: 700; }
.content { padding: 24rpx 20rpx 60rpx; }
.route-card,.list-card,.adjust-card,.detail-card { border: 2rpx solid #d9e7e0; border-radius: 26rpx; background: #fff; box-shadow: 0 14rpx 40rpx rgba(20,78,53,.07); }
.route-card { padding: 28rpx; border-radius: 26rpx 26rpx 0 0; }
.recommend { display: flex; align-items: center; gap: 10rpx; color: #0a9258; font-size: 21rpx; font-weight: 700; }
.recommend-icon { width: 34rpx; height: 34rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #087b4b; background: #c9f0dc; }
.route-title { display: block; margin-top: 19rpx; font-size: 38rpx; line-height: 1.35; font-weight: 800; letter-spacing: 1rpx; }
.tags,.place-tags { display: flex; flex-wrap: wrap; gap: 10rpx; }
.tags { margin-top: 17rpx; }
.tags text,.place-tags text { padding: 8rpx 14rpx; border-radius: 8rpx; color: #27825a; background: #eaf7f0; font-size: 18rpx; }
.route-meta { display: flex; gap: 48rpx; margin-top: 24rpx; }
.route-meta > view { display: flex; align-items: baseline; gap: 10rpx; }
.meta-label { color: #829089; font-size: 19rpx; }
.meta-value { color: #18231e; font-size: 29rpx; font-weight: 800; }
.route-summary { display: block; margin-top: 15rpx; color: #718078; font-size: 20rpx; line-height: 1.7; }
.change-note { margin-top: 18rpx; padding: 20rpx 22rpx; border: 1rpx solid #cce6d8; border-radius: 15rpx; color: #527061; background: #eaf7f0; font-size: 20rpx; }
.change-note text { margin-right: 12rpx; color: #087d4d; font-weight: 800; }
.map-card { position: relative; height: 430rpx; overflow: hidden; border-right: 2rpx solid #d9e7e0; border-bottom: 2rpx solid #d9e7e0; border-left: 2rpx solid #d9e7e0; background: #edf4ed; }
.map-grid { position: absolute; inset: 0; opacity: .8; background-image: linear-gradient(23deg,transparent 46%,#d7ded7 47%,#fff 49%,#d7ded7 51%,transparent 53%),linear-gradient(115deg,transparent 45%,#dae1da 46%,#fff 49%,#dae1da 52%,transparent 53%); background-size: 180rpx 150rpx,230rpx 190rpx; }
.park { position: absolute; border-radius: 46% 54% 50% 50%; background: rgba(151,206,146,.33); }
.park-one { left: 38rpx; top: 48rpx; width: 190rpx; height: 110rpx; transform: rotate(-14deg); }
.park-two { right: 44rpx; bottom: 34rpx; width: 230rpx; height: 140rpx; transform: rotate(17deg); }
.water { position: absolute; left: -50rpx; top: 232rpx; width: 820rpx; height: 22rpx; border-radius: 50%; background: #b9dfec; transform: rotate(-10deg); }
.road { position: absolute; height: 15rpx; border: 3rpx solid #fff; border-radius: 999rpx; background: #d4d9d5; }
.road-one { left: -30rpx; top: 92rpx; width: 520rpx; transform: rotate(12deg); }
.road-two { right: -40rpx; top: 200rpx; width: 500rpx; transform: rotate(-18deg); }
.road-three { left: 110rpx; bottom: 70rpx; width: 530rpx; transform: rotate(8deg); }
.route-line { position: absolute; z-index: 3; height: 10rpx; border-radius: 99rpx; background: #1686df; box-shadow: 0 0 0 4rpx rgba(255,255,255,.8); transform-origin: left center; }
.route-one { left: 148rpx; top: 112rpx; width: 210rpx; transform: rotate(22deg); }
.route-two { left: 339rpx; top: 188rpx; width: 190rpx; transform: rotate(35deg); }
.route-three { left: 493rpx; top: 294rpx; width: 150rpx; transform: rotate(-30deg); }
.map-stop { position: absolute; z-index: 5; display: flex; align-items: center; gap: 8rpx; }
.map-stop-1 { left: 98rpx; top: 73rpx; }
.map-stop-2 { left: 315rpx; top: 156rpx; }
.map-stop-3 { left: 492rpx; top: 272rpx; }
.map-stop-4 { right: 38rpx; bottom: 64rpx; flex-direction: row-reverse; }
.map-pin { width: 50rpx; height: 50rpx; display: flex; align-items: center; justify-content: center; border: 5rpx solid #fff; border-radius: 50% 50% 50% 8rpx; color: #fff; background: #078b52; box-shadow: 0 5rpx 14rpx rgba(6,93,56,.28); font-size: 21rpx; font-weight: 800; transform: rotate(-45deg); }
.map-pin::first-letter { transform: rotate(45deg); }
.map-label { max-width: 190rpx; padding: 8rpx 12rpx; border-radius: 8rpx; color: #28342e; background: rgba(255,255,255,.92); box-shadow: 0 4rpx 12rpx rgba(40,75,57,.12); font-size: 17rpx; font-weight: 700; }
.map-badge { position: absolute; left: 18rpx; bottom: 16rpx; z-index: 7; display: flex; flex-direction: column; gap: 3rpx; padding: 10rpx 14rpx; border-radius: 10rpx; color: #315543; background: rgba(255,255,255,.9); font-size: 16rpx; }
.map-badge text:last-child { color: #8a9690; font-size: 13rpx; }
.list-card { margin-top: 18rpx; overflow: hidden; }
.place-row { display: flex; gap: 20rpx; padding: 24rpx; border-bottom: 1rpx solid #e8eeeb; }
.place-row:last-child { border-bottom: 0; }
.place-art { position: relative; flex-shrink: 0; width: 190rpx; height: 150rpx; overflow: hidden; border-radius: 16rpx; background: linear-gradient(#b8e5f2 0 48%,#a8d58c 49%); }
.place-art.art-1 { background: linear-gradient(#cce8f2 0 49%,#99c7a0 50%); }
.place-art.art-2 { background: linear-gradient(#c1e2f4 0 48%,#8ec69a 49%); }
.place-art.art-3 { background: linear-gradient(#f4d8b4 0 48%,#c18a55 49%); }
.place-art > text { position: absolute; right: 9rpx; top: 7rpx; color: rgba(255,255,255,.88); font-family: Georgia,serif; font-size: 19rpx; font-weight: 700; }
.sun { position: absolute; right: 25rpx; top: 21rpx; width: 30rpx; height: 30rpx; border-radius: 50%; background: #fff3a2; }
.hill { position: absolute; border-radius: 50% 50% 0 0; }
.hill-back { left: -28rpx; bottom: 38rpx; width: 150rpx; height: 58rpx; background: #74b986; transform: rotate(12deg); }
.hill-front { right: -34rpx; bottom: 22rpx; width: 160rpx; height: 70rpx; background: #4b9b68; transform: rotate(-7deg); }
.building { position: absolute; z-index: 2; left: 65rpx; bottom: 22rpx; width: 65rpx; height: 58rpx; display: flex; justify-content: space-around; padding: 13rpx 7rpx 0; box-sizing: border-box; background: #f0eee4; box-shadow: 0 4rpx 8rpx rgba(0,0,0,.18); }
.building::before { content: ''; position: absolute; left: -8rpx; top: -20rpx; border-right: 40rpx solid transparent; border-bottom: 22rpx solid #557d67; border-left: 40rpx solid transparent; }
.building view { width: 8rpx; height: 34rpx; background: #759682; }
.place-copy { min-width: 0; flex: 1; }
.place-head { display: flex; align-items: flex-start; gap: 10rpx; }
.place-number { flex-shrink: 0; width: 37rpx; height: 37rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #fff; background: #177fce; font-size: 18rpx; font-weight: 800; }
.place-name { flex: 1; padding-top: 2rpx; font-size: 27rpx; line-height: 1.35; font-weight: 800; }
.place-tags { margin: 12rpx 0 10rpx 47rpx; }
.place-tags text { padding: 5rpx 10rpx; font-size: 15rpx; }
.activity { display: block; margin-left: 47rpx; color: #68766f; font-size: 20rpx; line-height: 1.55; }
.place-foot { display: flex; justify-content: space-between; margin: 12rpx 0 0 47rpx; color: #858f8a; font-size: 17rpx; }
.reason { display: flex; gap: 8rpx; margin: 14rpx 0 0 47rpx; padding: 12rpx; border-radius: 9rpx; color: #426351; background: #f0f8f4; font-size: 17rpx; line-height: 1.45; }
.reason text { color: #07935a; }
.cost-card { display: flex; gap: 18rpx; margin-top: 18rpx; padding: 24rpx; border-radius: 20rpx; color: #fff; background: linear-gradient(135deg,#0a965b,#077647); box-shadow: 0 12rpx 28rpx rgba(5,118,69,.17); }
.cost-icon { flex-shrink: 0; width: 52rpx; height: 52rpx; display: flex; align-items: center; justify-content: center; border: 1rpx solid rgba(255,255,255,.5); border-radius: 50%; font-size: 23rpx; }
.cost-card > view:last-child { display: flex; flex-direction: column; gap: 7rpx; font-size: 18rpx; line-height: 1.5; }
.cost-card > view:last-child text:first-child { font-size: 22rpx; font-weight: 800; }
.adjust-card,.detail-card { margin-top: 18rpx; padding: 28rpx; }
.section-kicker { display: block; color: #078e55; font-size: 18rpx; font-weight: 800; letter-spacing: 2rpx; }
.section-title { display: block; margin-top: 8rpx; font-size: 31rpx; font-weight: 800; }
.quick-list { display: flex; flex-direction: column; margin-top: 20rpx; border: 1rpx solid #e1eae5; border-radius: 14rpx; }
.quick { display: flex; justify-content: space-between; padding: 21rpx 20rpx; border-bottom: 1rpx solid #e8eeeb; font-size: 21rpx; }
.quick:last-child { border-bottom: 0; }
.quick text:last-child { color: #078e55; font-size: 30rpx; line-height: 20rpx; }
.composer { display: flex; margin-top: 18rpx; overflow: hidden; border: 2rpx solid #dce7e1; border-radius: 14rpx; }
.composer input { flex: 1; height: 82rpx; padding: 0 20rpx; font-size: 20rpx; }
.placeholder { color: #a5afaa; }
.composer button { width: 112rpx; height: 82rpx; margin: 0; border-radius: 0; color: #fff; background: #078e55; font-size: 20rpx; line-height: 82rpx; }
.composer button::after { border: 0; }
.composer button[disabled] { opacity: .55; }
.detail-section + .detail-section { margin-top: 30rpx; padding-top: 26rpx; border-top: 1rpx solid #e5ece8; }
.note { display: block; margin-top: 13rpx; color: #68766f; font-size: 19rpx; line-height: 1.6; }
.source { display: flex; align-items: center; justify-content: space-between; gap: 18rpx; padding: 18rpx 0; border-bottom: 1rpx solid #e8eeeb; color: #078e55; font-size: 17rpx; }
.source view { flex: 1; display: flex; flex-direction: column; gap: 5rpx; color: #34473d; }
.source view text:last-child { color: #919b96; font-size: 15rpx; }
.bottom-bar { position: fixed; z-index: 40; right: 0; bottom: 0; left: 0; display: flex; gap: 18rpx; padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #dfe8e3; background: rgba(255,255,255,.96); box-shadow: 0 -10rpx 30rpx rgba(26,70,49,.07); }
.bottom-bar button { height: 88rpx; display: flex; align-items: center; justify-content: center; gap: 10rpx; margin: 0; border-radius: 18rpx; font-size: 23rpx; font-weight: 800; }
.bottom-bar button::after { border: 0; }
.save-button { width: 230rpx; color: #087d4d; border: 2rpx solid #11915b; background: #fff; }
.save-button text { font-size: 33rpx; font-weight: 400; }
.accept-button { flex: 1; color: #fff; background: linear-gradient(100deg,#079c5c,#067d4a); box-shadow: 0 10rpx 22rpx rgba(3,129,75,.2); }
.accept-button text { font-size: 26rpx; transform: rotate(-26deg); }
.empty { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 28rpx; color: #26372e; background: #f4f8f6; font-size: 31rpx; font-weight: 700; }
.empty-icon { width: 100rpx; height: 100rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #078e55; background: #e4f5ec; font-size: 56rpx; }
.empty button { padding: 0 34rpx; border-radius: 16rpx; color: #fff; background: #078e55; font-size: 21rpx; }
</style>
@@ -1,302 +0,0 @@
<template>
<view class="page">
<view class="nav">
<view class="back" @click="goBack"></view>
<text class="nav-title">AI 智能规划</text>
<view class="nav-space" />
</view>
<view class="intro">
<view class="bot" aria-label="光明文旅 AI 助手">
<view class="antenna"><view class="antenna-dot" /></view>
<view class="bot-ear left" />
<view class="bot-ear right" />
<view class="bot-head">
<view class="bot-face">
<view class="bot-eye" />
<view class="bot-mouth" />
<view class="bot-eye" />
</view>
</view>
<view class="bot-body"><text>AI</text></view>
</view>
<view class="speech">
<view class="speech-tail" />
<text class="hello">Hi我是光明文旅 AI 助手</text>
<text class="intro-copy">告诉我你的偏好和需求<br />我将为你生成专属路线</text>
<view class="online"><view class="online-dot" /> REAL AI · 在线规划</view>
</view>
</view>
<view class="form-card">
<view class="card-title">
<view>
<text class="title">告诉我你的偏好</text>
<text class="title-sub">已选内容会成为 AI 规划依据</text>
</view>
<view class="progress">1/1</view>
</view>
<view class="section first">
<view class="section-head">
<text class="label">出行主题</text><text class="hint">多选</text>
</view>
<view class="option-grid four">
<view v-for="item in themeOptions" :key="item" class="option" :class="{ active: form.themes.includes(item) }" @click="toggleTheme(item)">
<text v-if="form.themes.includes(item)" class="check"></text>{{ item }}
</view>
</view>
</view>
<view class="section">
<view class="section-head"><text class="label">游玩时长</text></view>
<view class="option-grid three">
<view class="option" :class="{ active: form.duration === 'half_day' }" @click="form.duration = 'half_day'">
<text class="option-main">半日游</text><text class="option-sub"> 4 小时</text>
</view>
<view class="option" :class="{ active: form.duration === 'full_day' }" @click="form.duration = 'full_day'">
<text class="option-main">一日游</text><text class="option-sub"> 8 小时</text>
</view>
<view class="option disabled"><text class="option-main">两日游</text><text class="option-sub">敬请期待</text></view>
</view>
</view>
<view class="section">
<view class="section-head"><text class="label">节奏偏好</text></view>
<view class="option-grid three">
<view v-for="item in paceOptions" :key="item.value" class="option" :class="{ active: form.pace === item.value }" @click="form.pace = item.value">
<text class="option-main">{{ item.label }}</text><text class="option-sub">{{ item.desc }}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-head">
<text class="label">特色偏好</text><text class="hint">多选</text>
</view>
<view class="option-grid three interest-grid">
<view v-for="item in interestOptions" :key="item" class="option" :class="{ active: form.interests.includes(item) }" @click="toggleInterest(item)">
<text v-if="form.interests.includes(item)" class="check"></text>{{ item }}
</view>
</view>
</view>
<view class="section">
<view class="section-head"><text class="label">出行方式</text></view>
<view class="option-grid three">
<view v-for="item in transportOptions" :key="item.value" class="option" :class="{ active: form.transport === item.value }" @click="form.transport = item.value">{{ item.label }}</view>
</view>
</view>
<view class="section">
<view class="section-head"><text class="label">预算偏好</text></view>
<view class="option-grid three">
<view v-for="item in budgetOptions" :key="item.value" class="option" :class="{ active: form.budgetLevel === item.value }" @click="form.budgetLevel = item.value">{{ item.label }}</view>
</view>
</view>
<view class="section people-section">
<view class="section-head">
<text class="label">同行人数</text><text class="hint">儿童年龄会影响推荐</text>
</view>
<view class="people-grid">
<view class="counter-row">
<view><text class="person-label">成人</text><text class="person-note">18 岁以上</text></view>
<view class="counter"><button @click="changeAdults(-1)"></button><text>{{ form.adults }}</text><button @click="changeAdults(1)"></button></view>
</view>
<view class="counter-row">
<view><text class="person-label">儿童</text><text class="person-note">017 </text></view>
<view class="counter"><button @click="changeChildren(-1)"></button><text>{{ form.children }}</text><button @click="changeChildren(1)"></button></view>
</view>
</view>
<view v-if="form.children" class="ages">
<view v-for="(_, index) in form.childAges" :key="index" class="age-row">
<text>儿童 {{ index + 1 }} 年龄</text>
<slider :value="form.childAges[index]" min="0" max="17" activeColor="#07945b" backgroundColor="#dce8e2" block-color="#07945b" block-size="18" @change="setAge(index, $event)" />
<text class="age-value">{{ form.childAges[index] }} </text>
</view>
</view>
</view>
<view class="section last">
<view class="section-head">
<text class="label">补充说明</text><text class="hint">选填</text>
</view>
<view class="textarea-wrap">
<textarea v-model="form.extraRequirements" class="textarea" maxlength="500" placeholder="如:想带小朋友,喜欢公园和美食,不想爬山…" placeholder-class="placeholder" />
<text class="count">{{ form.extraRequirements.length }}/500</text>
</view>
</view>
<button class="submit" :disabled="submitting" @click="submit">
<text class="submit-spark"></text>
<text>{{ submitting ? 'AI 正在编排行程…' : '开始智能规划' }}</text>
</button>
</view>
<view class="footer-note">
<view class="line" /><view class="footer-dot" />
<text>AI 懂你所想 · 规划你所需</text>
<view class="footer-dot" /><view class="line" />
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { createPlan, savePlan, savePreferences } from '@/api/travelAssistant'
import type { BudgetLevel, Interest, Pace, Theme, Transport, TravelPreferences } from '@/types/travel'
const themeOptions: Theme[] = ['亲子', '情侣', '朋友', '银发', '研学']
const interestOptions: Interest[] = ['自然风光', '文化场馆', '生态科普', '美食', '摄影']
const paceOptions: { label: string; desc: string; value: Pace }[] = [
{ label: '轻松', desc: '慢慢走', value: 'relaxed' },
{ label: '适中', desc: '刚刚好', value: 'moderate' },
{ label: '紧凑', desc: '多看看', value: 'compact' },
]
const transportOptions: { label: string; value: Transport }[] = [
{ label: '公共交通', value: 'public_transport' },
{ label: '自驾', value: 'driving' },
{ label: '步行', value: 'walking' },
]
const budgetOptions: { label: string; value: BudgetLevel }[] = [
{ label: '经济', value: 'economy' },
{ label: '适中', value: 'standard' },
{ label: '品质', value: 'quality' },
]
const form = reactive<TravelPreferences>({
destination: '深圳市光明区',
themes: ['亲子'],
duration: 'half_day',
pace: 'relaxed',
interests: ['自然风光'],
adults: 2,
children: 0,
childAges: [],
transport: 'public_transport',
budgetLevel: 'standard',
extraRequirements: '',
})
const submitting = ref(false)
const toggleTheme = (item: Theme) => {
const index = form.themes.indexOf(item)
index >= 0 ? form.themes.splice(index, 1) : form.themes.push(item)
}
const toggleInterest = (item: Interest) => {
const index = form.interests.indexOf(item)
index >= 0 ? form.interests.splice(index, 1) : form.interests.push(item)
}
const changeAdults = (delta: number) => {
form.adults = Math.min(20, Math.max(0, form.adults + delta))
}
const changeChildren = (delta: number) => {
const next = Math.min(20, Math.max(0, form.children + delta))
while (form.childAges.length < next) form.childAges.push(6)
form.childAges.splice(next)
form.children = next
}
const setAge = (index: number, event: { detail: { value: number } }) => {
form.childAges[index] = event.detail.value
}
const goBack = () => uni.navigateBack()
const submit = async () => {
if (!form.themes.length && !form.interests.length) {
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
return
}
if (form.adults + form.children < 1) {
uni.showToast({ title: '出行总人数至少为 1', icon: 'none' })
return
}
submitting.value = true
try {
const preferences = { ...form, themes: [...form.themes], interests: [...form.interests], childAges: [...form.childAges] }
const response = await createPlan(preferences)
savePreferences(preferences)
savePlan(response)
uni.navigateTo({ url: '/pages/itinerary/index' })
} catch (error) {
uni.showModal({ title: '暂时无法生成', content: error instanceof Error ? error.message : '请稍后再试', showCancel: false })
} finally {
submitting.value = false
}
}
</script>
<style>
page { background: #f4f8f6; }
.page { min-height: 100vh; padding-bottom: calc(60rpx + env(safe-area-inset-bottom)); color: #17221d; background: radial-gradient(circle at 12% 12%, rgba(20,166,105,.08), transparent 330rpx), #f4f8f6; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
.nav { position: relative; display: flex; align-items: center; justify-content: space-between; height: 100rpx; padding: calc(24rpx + env(safe-area-inset-top)) 28rpx 0; background: rgba(255,255,255,.92); border-bottom: 1rpx solid #e9efec; }
.back,.nav-space { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; }
.back { color: #101a15; font-family: Georgia,serif; font-size: 58rpx; font-weight: 300; }
.nav-title { font-size: 32rpx; font-weight: 700; letter-spacing: 1rpx; }
.intro { display: flex; align-items: flex-end; gap: 20rpx; padding: 42rpx 28rpx 34rpx; }
.bot { position: relative; flex-shrink: 0; width: 150rpx; height: 170rpx; }
.antenna { position: absolute; z-index: 1; left: 73rpx; top: 0; width: 4rpx; height: 30rpx; background: #81d2ad; }
.antenna-dot { position: absolute; left: -8rpx; top: -4rpx; width: 20rpx; height: 20rpx; border: 3rpx solid #b5e4ce; border-radius: 50%; background: #fff; box-shadow: 0 3rpx 10rpx rgba(0,123,76,.18); }
.bot-head { position: absolute; z-index: 3; left: 16rpx; top: 28rpx; width: 118rpx; height: 92rpx; border: 4rpx solid #93d8b8; border-radius: 46rpx 46rpx 38rpx 38rpx; background: linear-gradient(145deg,#fff,#edf8f3); box-shadow: 0 12rpx 24rpx rgba(5,112,68,.15); }
.bot-face { position: absolute; inset: 17rpx 14rpx; display: flex; align-items: center; justify-content: space-around; border-radius: 28rpx; background: #092f2b; box-shadow: inset 0 0 16rpx rgba(0,246,190,.18); }
.bot-eye { width: 13rpx; height: 29rpx; border-radius: 999rpx; background: #18e4bd; box-shadow: 0 0 10rpx rgba(24,228,189,.7); }
.bot-mouth { width: 20rpx; height: 5rpx; margin-top: 18rpx; border-radius: 99rpx; background: #18e4bd; }
.bot-ear { position: absolute; z-index: 2; top: 58rpx; width: 20rpx; height: 46rpx; border: 3rpx solid #8ad5b1; background: #e7f7ef; }
.bot-ear.left { left: 3rpx; border-radius: 18rpx 4rpx 4rpx 18rpx; }
.bot-ear.right { right: 3rpx; border-radius: 4rpx 18rpx 18rpx 4rpx; }
.bot-body { position: absolute; left: 37rpx; top: 117rpx; width: 76rpx; height: 48rpx; display: flex; justify-content: center; padding-top: 14rpx; box-sizing: border-box; border: 4rpx solid #9bdcbe; border-radius: 30rpx 30rpx 12rpx 12rpx; color: #06905a; background: #f2fbf7; font-size: 14rpx; font-weight: 800; letter-spacing: 2rpx; }
.speech { position: relative; flex: 1; min-height: 150rpx; display: flex; flex-direction: column; justify-content: center; padding: 26rpx 28rpx; box-sizing: border-box; border: 2rpx solid #d4e8df; border-radius: 30rpx; background: rgba(255,255,255,.92); box-shadow: 0 12rpx 32rpx rgba(20,80,54,.07); }
.speech-tail { position: absolute; left: -16rpx; bottom: 34rpx; width: 28rpx; height: 28rpx; border-left: 2rpx solid #d4e8df; border-bottom: 2rpx solid #d4e8df; background: #fff; transform: rotate(45deg); }
.hello { color: #087846; font-size: 29rpx; font-weight: 800; }
.intro-copy { margin-top: 10rpx; color: #64716b; font-size: 21rpx; line-height: 1.65; }
.online { display: flex; align-items: center; margin-top: 12rpx; color: #7a8b83; font-size: 15rpx; letter-spacing: 1rpx; }
.online-dot { width: 11rpx; height: 11rpx; margin-right: 8rpx; border-radius: 50%; background: #08a865; box-shadow: 0 0 0 5rpx rgba(8,168,101,.12); }
.form-card { margin: 0 20rpx; padding: 0 28rpx 30rpx; border: 2rpx solid #d9e6e0; border-radius: 32rpx; background: rgba(255,255,255,.96); box-shadow: 0 20rpx 60rpx rgba(19,75,50,.08); }
.card-title { display: flex; align-items: center; justify-content: space-between; margin: 0 -28rpx; padding: 29rpx 28rpx; border-bottom: 1rpx solid #e9efec; }
.title { display: block; font-size: 31rpx; font-weight: 800; }
.title-sub { display: block; margin-top: 6rpx; color: #8b9892; font-size: 18rpx; }
.progress { padding: 8rpx 16rpx; border-radius: 999rpx; color: #07814d; background: #e9f7f0; font-size: 17rpx; font-weight: 700; }
.section { padding: 34rpx 0; border-bottom: 1rpx solid #edf1ef; }
.section.first { padding-top: 30rpx; }
.section.last { border-bottom: 0; }
.section-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 20rpx; }
.label { font-size: 25rpx; font-weight: 700; }
.hint { color: #9aa49f; font-size: 19rpx; }
.hint::before { content: '· '; }
.option-grid { display: grid; gap: 16rpx; }
.option-grid.four { grid-template-columns: repeat(4,1fr); }
.option-grid.three { grid-template-columns: repeat(3,1fr); }
.option { min-height: 70rpx; display: flex; align-items: center; justify-content: center; gap: 7rpx; padding: 12rpx 10rpx; box-sizing: border-box; border: 2rpx solid #e0e6e3; border-radius: 14rpx; color: #454f4a; background: #fff; font-size: 23rpx; transition: transform .18s ease, background .18s ease; }
.option:active { transform: scale(.97); }
.option.active { color: #fff; border-color: #078e55; background: linear-gradient(145deg,#079c5c,#087d4d); box-shadow: 0 8rpx 18rpx rgba(5,142,84,.2); font-weight: 700; }
.option.disabled { color: #b7bfbb; background: #f6f8f7; }
.check { font-size: 18rpx; }
.option-main,.option-sub { display: block; text-align: center; }
.option-main { font-size: 23rpx; }
.option-sub { margin-left: 6rpx; color: #9aa49f; font-size: 16rpx; }
.option.active .option-sub { color: rgba(255,255,255,.75); }
.interest-grid .option { font-size: 21rpx; }
.people-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
.counter-row { min-height: 112rpx; display: flex; flex-direction: column; gap: 18rpx; padding: 20rpx; box-sizing: border-box; border: 2rpx solid #e4eae7; border-radius: 16rpx; }
.person-label,.person-note { display: block; }
.person-label { font-size: 23rpx; font-weight: 700; }
.person-note { margin-top: 3rpx; color: #9aa49f; font-size: 16rpx; }
.counter { display: flex; align-items: center; justify-content: space-between; }
.counter button { width: 52rpx; height: 52rpx; margin: 0; padding: 0; line-height: 50rpx; border: 0; border-radius: 12rpx; color: #087d4d; background: #eaf7f0; font-size: 28rpx; }
.counter button::after { border: 0; }
.counter text { min-width: 36rpx; text-align: center; color: #16231d; font-size: 27rpx; font-weight: 800; }
.ages { margin-top: 18rpx; padding: 18rpx; border-radius: 14rpx; background: #f4f9f6; }
.age-row { display: grid; grid-template-columns: 150rpx 1fr 68rpx; align-items: center; font-size: 18rpx; }
.age-value { text-align: right; color: #07814d; font-weight: 700; }
.textarea-wrap { position: relative; }
.textarea { width: 100%; height: 180rpx; padding: 22rpx 24rpx 50rpx; box-sizing: border-box; border: 2rpx solid #e0e6e3; border-radius: 16rpx; color: #29342f; background: #fbfcfc; font-size: 22rpx; line-height: 1.7; }
.placeholder { color: #aeb7b2; }
.count { position: absolute; right: 18rpx; bottom: 16rpx; color: #a1aaa5; font-size: 17rpx; }
.submit { width: 100%; height: 94rpx; display: flex; align-items: center; justify-content: center; gap: 17rpx; margin-top: 30rpx; border: 0; border-radius: 20rpx; color: #fff; background: linear-gradient(100deg,#079c5c,#067d4a); box-shadow: 0 13rpx 28rpx rgba(3,129,75,.25); font-size: 28rpx; font-weight: 800; letter-spacing: 1rpx; }
.submit::after { border: 0; }
.submit[disabled] { opacity: .7; }
.submit-spark { font-size: 31rpx; }
.footer-note { display: flex; align-items: center; justify-content: center; gap: 13rpx; padding: 46rpx 28rpx calc(10rpx + env(safe-area-inset-bottom)); color: #176744; font-family: 'STSong','Songti SC',serif; font-size: 22rpx; letter-spacing: 3rpx; }
.line { width: 50rpx; height: 1rpx; background: #9cc8b3; }
.footer-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #8cbda6; }
</style>
-6
View File
@@ -1,6 +0,0 @@
export {}
declare module "vue" {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

@@ -1,59 +0,0 @@
export type Theme = '亲子' | '情侣' | '朋友' | '银发' | '研学'
export type Interest = '自然风光' | '文化场馆' | '生态科普' | '美食' | '摄影'
export type Duration = 'half_day' | 'full_day'
export type Pace = 'relaxed' | 'moderate' | 'compact'
export type Transport = 'walking' | 'driving' | 'public_transport'
export type BudgetLevel = 'economy' | 'standard' | 'quality'
export interface TravelPreferences {
destination: '深圳市光明区'
themes: Theme[]
duration: Duration
pace: Pace
interests: Interest[]
adults: number
children: number
childAges: number[]
transport: Transport
budgetLevel: BudgetLevel
extraRequirements: string
}
export interface ItineraryItem {
startTime: string
endTime: string
placeId: string
placeName: string
activity: string
reason: string
transferFromPreviousMinutes: number | null
tips: string[]
}
export interface ItinerarySource {
placeId: string
sourceName: string
sourceUrl: string
updatedAt: string
}
export interface Itinerary {
title: string
summary: string
totalMinutes: number
estimatedCostText: string
items: ItineraryItem[]
notes: string[]
sources: ItinerarySource[]
}
export interface PlanResponse {
conversationId: string
assistantMessage: string
itinerary: Itinerary
changeSummary?: string | null
}
export interface APIError {
detail?: string
}
-76
View File
@@ -1,76 +0,0 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;
-13
View File
@@ -1,13 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"lib": ["esnext", "dom"],
"types": ["@dcloudio/types"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from "vite";
import uni from "@dcloudio/vite-plugin-uni";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [uni()],
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
},
},
});
-6
View File
@@ -1,6 +0,0 @@
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=
LLM_TIMEOUT_SECONDS=45
LLM_MODE=mock
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
-7
View File
@@ -1,7 +0,0 @@
.env
.venv/
__pycache__/
.pytest_cache/
*.pyc
.DS_Store
-2
View File
@@ -1,2 +0,0 @@
"""Guangming travel assistant POC."""
-105
View File
@@ -1,105 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Optional
from .schemas import TravelPreferences
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
class KnowledgeBase:
def __init__(self) -> None:
self.places: list[dict[str, Any]] = json.loads(
(DATA_DIR / "places.json").read_text(encoding="utf-8")
)
travel_rows = json.loads(
(DATA_DIR / "travel_times.json").read_text(encoding="utf-8")
)
self.travel_times = {
(row["from"], row["to"]): row for row in travel_rows
}
@property
def count(self) -> int:
return len(self.places)
def by_id(self, place_id: str) -> Optional[dict[str, Any]]:
return next((place for place in self.places if place["id"] == place_id), None)
def retrieve(self, preferences: TravelPreferences, limit: int = 8) -> list[dict[str, Any]]:
requirements = preferences.extra_requirements.lower()
indoor_required = any(
token in requirements for token in ("室内", "下雨", "雨天", "避雨")
)
avoid_climbing = any(
token in requirements
for token in ("不爬山", "不要爬山", "不登山", "体力不好", "婴儿车")
)
youngest = min(preferences.child_ages) if preferences.child_ages else None
scored: list[tuple[int, str, dict[str, Any]]] = []
for place in self.places:
if place["city"] != "深圳市" or place["district"] != "光明区":
continue
if youngest is not None and youngest < place.get("minAge", 0):
continue
if avoid_climbing and place.get("intensity") == "high":
continue
if indoor_required and not (
place.get("indoor") or place.get("rainyDaySuitable")
):
continue
score = 0
score += 3 * len(set(preferences.themes) & set(place.get("themes", [])))
score += 3 * len(
set(preferences.interests) & set(place.get("interests", []))
)
if preferences.children and "儿童" in place.get("suitableFor", []):
score += 2
elif not preferences.children and "成人" in place.get("suitableFor", []):
score += 2
if preferences.pace.value in place.get("pace", []):
score += 1
if preferences.budget_level.value == place.get("budgetLevel"):
score += 1
if indoor_required and (
place.get("indoor") or place.get("rainyDaySuitable")
):
score += 2
scored.append((score, place["name"], place))
scored.sort(key=lambda item: (-item[0], item[1]))
return [place for _, _, place in scored[:limit]]
def travel_minutes(
self, from_id: str, to_id: str, transport: str
) -> Optional[int]:
row = self.travel_times.get((from_id, to_id)) or self.travel_times.get(
(to_id, from_id)
)
if not row:
return None
value = row.get(transport)
return int(value) if value is not None else None
def travel_context(
self, candidates: list[dict[str, Any]], transport: str
) -> list[dict[str, Any]]:
ids = {place["id"] for place in candidates}
result = []
for row in self.travel_times.values():
if row["from"] in ids and row["to"] in ids:
result.append(
{
"from": row["from"],
"to": row["to"],
"minutes": row.get(transport),
"note": row.get("note", "POC估算,请以实际导航为准"),
}
)
return result
-203
View File
@@ -1,203 +0,0 @@
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from typing import Any, Optional
from pydantic import ValidationError
from .knowledge import KnowledgeBase
from .prompts import SYSTEM_PROMPT
from .schemas import GeneratedItinerary, Pace, TravelPreferences
class LLMError(RuntimeError):
pass
class LLMTimeout(LLMError):
pass
class LLMService:
def __init__(self, knowledge: KnowledgeBase) -> None:
self.knowledge = knowledge
self.mode = os.getenv("LLM_MODE", "mock").lower()
self.timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "45"))
@property
def configured(self) -> bool:
if self.mode == "mock":
return True
return bool(os.getenv("OPENAI_API_KEY") and os.getenv("OPENAI_MODEL"))
async def generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary] = None,
user_message: Optional[str] = None,
) -> GeneratedItinerary:
if self.mode == "mock":
return self._mock_generate(preferences, candidates, previous, user_message)
return await self._real_generate(preferences, candidates, previous, user_message)
async def _real_generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary],
user_message: Optional[str],
) -> GeneratedItinerary:
if not self.configured:
raise LLMError("真实模型尚未配置")
try:
from openai import APITimeoutError, AsyncOpenAI
except ImportError as exc:
raise LLMError("缺少 openai 依赖") from exc
client = AsyncOpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.getenv("OPENAI_BASE_URL") or None,
timeout=self.timeout,
)
payload = {
"preferences": preferences.model_dump(by_alias=True),
"candidates": candidates,
"travelTimes": self.knowledge.travel_context(
candidates, preferences.transport.value
),
"previousItinerary": (
previous.model_dump(by_alias=True) if previous else None
),
"adjustmentRequest": user_message,
}
feedback = ""
allowed_ids = {place["id"] for place in candidates}
for attempt in range(2):
try:
response = await client.chat.completions.create(
model=os.environ["OPENAI_MODEL"],
temperature=0.2,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": json.dumps(payload, ensure_ascii=False)
+ feedback,
},
],
)
except APITimeoutError as exc:
raise LLMTimeout("模型调用超时") from exc
except Exception as exc:
raise LLMError(f"模型调用失败:{exc}") from exc
try:
content = response.choices[0].message.content or "{}"
itinerary = GeneratedItinerary.model_validate_json(content)
unknown = {
item.place_id for item in itinerary.items
} - allowed_ids
if unknown:
raise ValueError(f"包含未知地点ID{sorted(unknown)}")
return itinerary
except (ValidationError, ValueError, json.JSONDecodeError) as exc:
if attempt == 1:
raise LLMError(f"模型输出无法通过结构校验:{exc}") from exc
feedback = f"\n上次输出校验失败:{exc}。请重新输出完整合法 JSON。"
raise LLMError("模型未返回有效结果")
def _mock_generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary],
user_message: Optional[str],
) -> GeneratedItinerary:
if not candidates:
raise LLMError("没有符合条件的候选地点")
max_items = {
Pace.RELAXED: 3,
Pace.MODERATE: 4,
Pace.COMPACT: 5,
}[preferences.pace]
target = 240 if preferences.duration.value == "half_day" else 480
start = datetime(2026, 1, 1, 9, 0)
elapsed = 0
items = []
previous_place: Optional[dict[str, Any]] = None
for place in candidates:
if len(items) >= max_items:
break
transfer_value: Optional[int] = 0
if previous_place:
transfer_value = self.knowledge.travel_minutes(
previous_place["id"],
place["id"],
preferences.transport.value,
)
transfer_for_math = transfer_value or 0
duration = int(place.get("recommendedMinutes", 75))
if elapsed + transfer_for_math + duration > target + 30 and items:
continue
item_start = start + timedelta(minutes=transfer_for_math)
end = item_start + timedelta(minutes=duration)
items.append(
{
"startTime": item_start.strftime("%H:%M"),
"endTime": end.strftime("%H:%M"),
"placeId": place["id"],
"placeName": place["name"],
"activity": place["summary"],
"reason": self._reason(preferences, place),
"transferFromPreviousMinutes": (
transfer_value if previous_place else 0
),
"tips": place.get("tips", [])[:2],
}
)
elapsed += transfer_for_math + duration
start = end
previous_place = place
themes = "".join(preferences.themes or preferences.interests[:1])
adjustment = f";已响应“{user_message}" if user_message else ""
pace_label = {
Pace.RELAXED: "轻松",
Pace.MODERATE: "适中",
Pace.COMPACT: "紧凑",
}[preferences.pace]
return GeneratedItinerary.model_validate(
{
"title": f"光明区{themes or '精选'}{'半日' if preferences.duration.value == 'half_day' else '一日'}",
"summary": f"以科学、人文与都市自然为线索,按{pace_label}节奏安排{adjustment}",
"totalMinutes": max(elapsed, 1),
"estimatedCostText": "费用以场馆、景区及实际交通信息为准",
"items": items,
"notes": [
"开放时间、预约和票价请在出行前通过官方渠道再次确认。",
"交通耗时为POC估算,请以出发时的实际导航为准。",
],
}
)
@staticmethod
def _reason(
preferences: TravelPreferences, place: dict[str, Any]
) -> str:
matches = list(
(set(preferences.themes) & set(place.get("themes", [])))
| (set(preferences.interests) & set(place.get("interests", [])))
)
return (
f"符合你的{''.join(sorted(matches))}偏好"
if matches
else "作为光明区同路线备选,便于控制整体节奏"
)
-83
View File
@@ -1,83 +0,0 @@
from __future__ import annotations
import os
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from .knowledge import KnowledgeBase
from .llm import LLMError, LLMService, LLMTimeout
from .planner import PlannerService, SessionStore
from .schemas import HealthResponse, MessageRequest, PlanResponse, TravelPreferences
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# Mock mode can run without python-dotenv; production installs project deps.
pass
knowledge = KnowledgeBase()
llm = LLMService(knowledge)
planner = PlannerService(knowledge, llm, SessionStore())
app = FastAPI(
title="光明区文旅行程助手 API",
version="0.1.0",
description="POC:本地知识检索 + OpenAI 兼容模型 + 内存会话",
)
origins = [
origin.strip()
for origin in os.getenv(
"CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
).split(",")
if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
@app.get("/api/v1/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(
status="ok",
llm_mode=llm.mode,
model_configured=llm.configured,
knowledge_count=knowledge.count,
)
@app.post("/api/v1/plans", response_model=PlanResponse)
async def create_plan(preferences: TravelPreferences) -> PlanResponse:
try:
return await planner.create(preferences)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LLMTimeout as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except (LLMError, ValueError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.post(
"/api/v1/conversations/{conversation_id}/messages",
response_model=PlanResponse,
)
async def adjust_plan(
conversation_id: str, request: MessageRequest
) -> PlanResponse:
try:
return await planner.adjust(conversation_id, request.message)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LLMTimeout as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except (LLMError, ValueError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
-149
View File
@@ -1,149 +0,0 @@
from __future__ import annotations
import time
import uuid
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Optional
from .knowledge import KnowledgeBase
from .llm import LLMService
from .schemas import (
GeneratedItinerary,
Itinerary,
PlanResponse,
Source,
TravelPreferences,
)
@dataclass
class Session:
preferences: TravelPreferences
itinerary: GeneratedItinerary
created_at: float
history: list[str] = field(default_factory=list)
class SessionStore:
def __init__(self, max_size: int = 100, ttl_seconds: int = 7200) -> None:
self.max_size = max_size
self.ttl_seconds = ttl_seconds
self.sessions: OrderedDict[str, Session] = OrderedDict()
def put(self, session: Session) -> str:
self.cleanup()
while len(self.sessions) >= self.max_size:
self.sessions.popitem(last=False)
session_id = str(uuid.uuid4())
self.sessions[session_id] = session
return session_id
def get(self, session_id: str) -> Optional[Session]:
self.cleanup()
session = self.sessions.get(session_id)
if session:
self.sessions.move_to_end(session_id)
return session
def cleanup(self) -> None:
deadline = time.time() - self.ttl_seconds
expired = [
key for key, session in self.sessions.items()
if session.created_at < deadline
]
for key in expired:
self.sessions.pop(key, None)
class PlannerService:
def __init__(
self,
knowledge: KnowledgeBase,
llm: LLMService,
store: SessionStore,
) -> None:
self.knowledge = knowledge
self.llm = llm
self.store = store
async def create(self, preferences: TravelPreferences) -> PlanResponse:
candidates = self.knowledge.retrieve(preferences)
if not candidates:
raise LookupError("没有找到符合当前条件的光明区地点")
generated = await self.llm.generate(preferences, candidates)
generated = self._canonicalize(generated, candidates)
session_id = self.store.put(
Session(preferences=preferences, itinerary=generated, created_at=time.time())
)
return PlanResponse(
conversation_id=session_id,
assistant_message="已根据你的偏好生成光明区行程。",
itinerary=self._with_sources(generated),
)
async def adjust(self, session_id: str, message: str) -> PlanResponse:
session = self.store.get(session_id)
if not session:
raise LookupError("会话不存在或已过期,请重新生成行程")
updated_preferences = session.preferences.model_copy(
update={
"extra_requirements": (
session.preferences.extra_requirements + "" + message
).strip("")
}
)
candidates = self.knowledge.retrieve(updated_preferences)
if not candidates:
raise LookupError("没有找到符合调整条件的光明区地点")
generated = await self.llm.generate(
updated_preferences, candidates, session.itinerary, message
)
generated = self._canonicalize(generated, candidates)
session.preferences = updated_preferences
session.itinerary = generated
session.history.append(message)
return PlanResponse(
conversation_id=session_id,
assistant_message="已按你的补充要求调整行程。",
change_summary=f"已根据“{message}”更新相关安排。",
itinerary=self._with_sources(generated),
)
def _canonicalize(
self,
itinerary: GeneratedItinerary,
candidates: list[dict],
) -> GeneratedItinerary:
candidate_map = {place["id"]: place for place in candidates}
for item in itinerary.items:
place = candidate_map.get(item.place_id)
if not place:
raise ValueError(f"行程包含知识库外地点:{item.place_id}")
item.place_name = place["name"]
item.tips = [
tip for tip in item.tips if tip in place.get("tips", [])
] or place.get("tips", [])[:2]
return itinerary
def _with_sources(self, generated: GeneratedItinerary) -> Itinerary:
seen: set[str] = set()
sources: list[Source] = []
for item in generated.items:
if item.place_id in seen:
continue
seen.add(item.place_id)
place = self.knowledge.by_id(item.place_id)
if place:
sources.append(
Source(
place_id=place["id"],
source_name=place["sourceName"],
source_url=place["sourceUrl"],
updated_at=place["updatedAt"],
)
)
return Itinerary(
**generated.model_dump(),
sources=sources,
)
-26
View File
@@ -1,26 +0,0 @@
SYSTEM_PROMPT = """你是深圳市光明区文旅行程规划助手。
你只能使用“候选地点”中出现的 placeId,不得创造地点。
不得虚构开放时间、票价、预约、天气或交通耗时;缺少交通数据时必须输出 null。
优先满足年龄、安全、室内外、体力和用户明确排除条件。
半日游控制在约4小时,一日游控制在约8小时;轻松、适中、紧凑最多安排3、4、5个地点。
修改已有行程时,只修改用户要求影响的部分,尽量保留其余安排。
只输出一个 JSON 对象,不要输出 Markdown、代码围栏或解释文字。
JSON 结构:
{
"title": "字符串",
"summary": "字符串",
"totalMinutes": 240,
"estimatedCostText": "费用说明,不编造精确价格",
"items": [{
"startTime": "09:00",
"endTime": "10:30",
"placeId": "候选地点ID",
"placeName": "候选地点名称",
"activity": "活动安排",
"reason": "匹配理由",
"transferFromPreviousMinutes": null,
"tips": ["知识库内的提示"]
}],
"notes": ["动态信息需出行前确认"]
}"""
-115
View File
@@ -1,115 +0,0 @@
from enum import Enum
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
def to_camel(value: str) -> str:
parts = value.split("_")
return parts[0] + "".join(part.capitalize() for part in parts[1:])
class APIModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class Duration(str, Enum):
HALF_DAY = "half_day"
FULL_DAY = "full_day"
class Pace(str, Enum):
RELAXED = "relaxed"
MODERATE = "moderate"
COMPACT = "compact"
class Transport(str, Enum):
WALKING = "walking"
DRIVING = "driving"
PUBLIC_TRANSPORT = "public_transport"
class BudgetLevel(str, Enum):
ECONOMY = "economy"
STANDARD = "standard"
QUALITY = "quality"
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
class TravelPreferences(APIModel):
destination: Literal["深圳市光明区"] = "深圳市光明区"
themes: list[Theme] = Field(default_factory=list)
duration: Duration
pace: Pace
interests: list[Interest] = Field(default_factory=list)
adults: int = Field(default=2, ge=0, le=20)
children: int = Field(default=0, ge=0, le=20)
child_ages: list[int] = Field(default_factory=list)
transport: Transport = Transport.PUBLIC_TRANSPORT
budget_level: BudgetLevel = BudgetLevel.STANDARD
extra_requirements: str = Field(default="", max_length=500)
@model_validator(mode="after")
def validate_group_and_preferences(self) -> "TravelPreferences":
if self.adults + self.children < 1:
raise ValueError("出行总人数至少为1")
if self.child_ages and len(self.child_ages) != self.children:
raise ValueError("儿童年龄数量必须与儿童人数一致")
if any(age < 0 or age > 17 for age in self.child_ages):
raise ValueError("儿童年龄须在0至17岁之间")
if not self.themes and not self.interests:
raise ValueError("主题或特色偏好至少选择一项")
return self
class ItineraryItem(APIModel):
start_time: str
end_time: str
place_id: str
place_name: str
activity: str
reason: str
transfer_from_previous_minutes: Optional[int] = Field(default=None, ge=0)
tips: list[str] = Field(default_factory=list)
class GeneratedItinerary(APIModel):
title: str
summary: str
total_minutes: int = Field(ge=1, le=720)
estimated_cost_text: str
items: list[ItineraryItem] = Field(min_length=1, max_length=5)
notes: list[str] = Field(default_factory=list)
class Source(APIModel):
place_id: str
source_name: str
source_url: str
updated_at: str
class Itinerary(GeneratedItinerary):
sources: list[Source]
class PlanResponse(APIModel):
conversation_id: str
assistant_message: str
itinerary: Itinerary
change_summary: Optional[str] = None
class MessageRequest(APIModel):
message: str = Field(min_length=1, max_length=500)
class HealthResponse(APIModel):
status: str
llm_mode: str
model_configured: bool
knowledge_count: int
-423
View File
@@ -1,423 +0,0 @@
[
{
"id": "guangming-science-museum",
"city": "深圳市",
"district": "光明区",
"name": "深圳科学技术馆",
"type": "museum",
"area": "光明中心区",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 3,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 150,
"budgetLevel": "standard",
"address": "深圳市光明区光辉大道8号",
"latitude": null,
"longitude": null,
"openTimeText": "开放与预约信息请以场馆官方最新公告为准",
"ticketText": "票务信息请以场馆官方最新公告为准",
"summary": "围绕数字文明与通信科技设置互动展项,适合科普参观和亲子研学。",
"tips": ["热门时段建议提前查询预约信息", "场馆较大,亲子家庭可预留充足时间"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12151090.html",
"updatedAt": "2025-04-29"
},
{
"id": "guangming-science-park",
"city": "深圳市",
"district": "光明区",
"name": "科学公园",
"type": "park",
"area": "光明中心区",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 90,
"budgetLevel": "economy",
"address": "深圳市光明区光明中心区",
"latitude": null,
"longitude": null,
"openTimeText": "开放区域请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "以山林、步道和生态体验连接科学城公共空间,适合散步和轻量户外活动。",
"tips": ["户外活动注意防晒和补水", "雨天请留意步道路面状况"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12235727.html",
"updatedAt": "2025-06-19"
},
{
"id": "guangming-happy-farm",
"city": "深圳市",
"district": "光明区",
"name": "光明欢乐田园",
"type": "park",
"area": "新湖街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 120,
"budgetLevel": "economy",
"address": "深圳市光明区新湖街道楼村一号路",
"latitude": null,
"longitude": null,
"openTimeText": "花期、开放区域及活动请以园区最新公告为准",
"ticketText": "费用请以园区最新公告为准",
"summary": "都市田园景观随季节变化,可体验农田、花景与开阔田园空间。",
"tips": ["景观受季节影响,出行前建议查询当期花田信息", "户外区域注意防晒"],
"sourceName": "光明区文化广电旅游体育局",
"sourceUrl": "https://www.szgm.gov.cn/zjgm/lsgm_118488/tsgm_118491/content/post_8758374.html",
"updatedAt": "2025-03-24"
},
{
"id": "guangming-farm-grand-view",
"city": "深圳市",
"district": "光明区",
"name": "光明农场大观园",
"type": "attraction",
"area": "光明街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 2,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 150,
"budgetLevel": "standard",
"address": "深圳市光明区光明街道",
"latitude": null,
"longitude": null,
"openTimeText": "开放时间和活动安排请以景区公告为准",
"ticketText": "购票信息请以景区官方渠道为准",
"summary": "以农场、动物互动和自然科普为特色,适合亲子家庭和研学体验。",
"tips": ["动物互动和主题活动以当日安排为准", "建议为儿童准备替换衣物"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12164420.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-hongqiao-park",
"city": "深圳市",
"district": "光明区",
"name": "虹桥公园",
"type": "park",
"area": "光明街道",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "medium",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 100,
"budgetLevel": "economy",
"address": "深圳市光明区光侨路以东、观光路以北",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "以醒目的红色桥体和山林绿意形成城市生态地标,适合步行和摄影。",
"tips": ["路线较长,可按体力选择折返", "户外步行注意防晒和补水"],
"sourceName": "光明区城市管理和综合执法局",
"sourceUrl": "https://www.szgm.gov.cn/gmcsglj/gkmlpt/content/10/10284/post_10284402.html",
"updatedAt": "2022-12-13"
},
{
"id": "guangming-dadingling-greenway",
"city": "深圳市",
"district": "光明区",
"name": "大顶岭绿道",
"type": "greenway",
"area": "光明街道",
"themes": ["情侣", "朋友", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["成人"],
"minAge": 8,
"pace": ["moderate", "compact"],
"intensity": "high",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 150,
"budgetLevel": "economy",
"address": "深圳市光明区大顶岭片区",
"latitude": null,
"longitude": null,
"openTimeText": "绿道开放情况请以管理方公告为准",
"ticketText": "费用信息请以现场公告为准",
"summary": "以悬桥、探桥和浮桥等节点串联山林生态,是具有运动强度的绿道体验。",
"tips": ["需要较多步行,不适合婴儿车和体力较弱人群", "雨天不建议前往"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163137.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-culture-art-center",
"city": "深圳市",
"district": "光明区",
"name": "光明文化艺术中心",
"type": "cultural_center",
"area": "凤凰街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["文化场馆", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 120,
"budgetLevel": "economy",
"address": "深圳市光明区观光路东北侧",
"latitude": null,
"longitude": null,
"openTimeText": "图书馆、展览和演出开放安排请分别查询官方公告",
"ticketText": "各空间及活动费用请以官方公告为准",
"summary": "集图书馆、演艺、展览与休闲空间于一体的综合文化地标。",
"tips": ["不同场馆开放安排可能不同", "观演活动通常需要单独预约或购票"],
"sourceName": "光明区文化广电旅游体育局",
"sourceUrl": "https://www.szgm.gov.cn/zjgm/lsgm_118488/tsgm_118491/content/post_8774880.html",
"updatedAt": "2025-03-24"
},
{
"id": "guangming-science-city-exhibition",
"city": "深圳市",
"district": "光明区",
"name": "光明科学城展示中心",
"type": "exhibition",
"area": "新湖街道",
"themes": ["朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区新湖街道圳园路南侧",
"latitude": null,
"longitude": null,
"openTimeText": "接待和开放信息请提前向官方渠道确认",
"ticketText": "参观安排请以官方公告为准",
"summary": "以展览展示功能介绍光明科学城规划与建设,适合城市与科学主题研学。",
"tips": ["并非常规全天候开放景点,务必提前确认", "适合与科学公园或欢乐田园组合"],
"sourceName": "光明区科学城开发建设署",
"sourceUrl": "https://www.szgm.gov.cn/gmcsfzcjzx/gkmlpt/content/10/10366/post_10366147.html",
"updatedAt": "2023-01-03"
},
{
"id": "guangming-ebohr-museum",
"city": "深圳市",
"district": "光明区",
"name": "依波钟表文化博物馆",
"type": "museum",
"area": "马田街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区钟表产业基地依波大厦",
"latitude": null,
"longitude": null,
"openTimeText": "工业旅游参观安排请提前向场馆确认",
"ticketText": "参观费用请以场馆最新信息为准",
"summary": "通过古代计时、钟表历史和制表技术展项展示时间文化与工业设计。",
"tips": ["工业旅游场馆建议提前预约确认", "适合学龄儿童及成人"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12165124.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-fiyta-center",
"city": "深圳市",
"district": "光明区",
"name": "飞亚达计时文化中心",
"type": "museum",
"area": "马田街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区钟表产业基地",
"latitude": null,
"longitude": null,
"openTimeText": "工业旅游参观安排请提前向场馆确认",
"ticketText": "参观费用请以场馆最新信息为准",
"summary": "融合钟表文化、工业旅游和科普教育,包含航天主题展示与制表体验。",
"tips": ["体验项目和开放安排需要提前确认", "适合作为雨天研学备选"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12165107.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-honghua-park",
"city": "深圳市",
"district": "光明区",
"name": "红花山公园",
"type": "park",
"area": "公明街道",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 80,
"budgetLevel": "economy",
"address": "深圳市光明区公明街道",
"latitude": null,
"longitude": null,
"openTimeText": "开放区域请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "结合荔枝林、草坪、儿童活动和休闲设施的社区型生态公园。",
"tips": ["亲子活动请留意儿童设施现场状态", "户外活动注意防晒"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12164759.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-rosewood-town",
"city": "深圳市",
"district": "光明区",
"name": "光明红木文化古镇",
"type": "cultural_town",
"area": "公明街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["文化场馆", "美食", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 100,
"budgetLevel": "standard",
"address": "深圳市光明区公明街道北环大道与民生路交会处",
"latitude": null,
"longitude": null,
"openTimeText": "各展馆和商户开放安排请以现场公告为准",
"ticketText": "费用请以现场公告为准",
"summary": "以红木工艺和岭南文化为主题,包含文化展陈、传统街区和特色商业。",
"tips": ["各主题空间开放时间可能不同", "适合安排为文化参观与用餐衔接点"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12164714.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-nanguang-green-zone",
"city": "深圳市",
"district": "光明区",
"name": "南光绿境",
"type": "park",
"area": "茅洲河沿线",
"themes": ["亲子", "情侣", "朋友"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区茅洲河沿线",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园现场公告为准",
"ticketText": "活动项目费用请以运营方公告为准",
"summary": "由旧净水设施及桥下空间更新而成的滨水休闲区,包含运动和儿童活动空间。",
"tips": ["水上活动需另行确认开放与安全要求", "儿童在滨水区域需由成人看护"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163075.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-zuoan-tech-park",
"city": "深圳市",
"district": "光明区",
"name": "左岸科技公园",
"type": "park",
"area": "茅洲河沿线",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区茅洲河沿线",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "沿茅洲河建设的生态修复型公园,适合滨水散步、观察城市生态更新。",
"tips": ["滨水步行注意儿童安全", "雨天请留意步道路面"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163102.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-minghu-park",
"city": "深圳市",
"district": "光明区",
"name": "明湖城市公园",
"type": "park",
"area": "马田街道",
"themes": ["亲子", "情侣", "朋友"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 90,
"budgetLevel": "economy",
"address": "深圳市光明区光明大道、光明国际汽车城旁",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围及活动请以公园公告为准",
"ticketText": "活动项目费用请以现场公告为准",
"summary": "融合湖畔休闲、亲子市集和汽车主题活动的城市公园。",
"tips": ["露营和市集活动并非常设内容", "湖边活动请照看儿童"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163086.html",
"updatedAt": "2025-05-08"
}
]
@@ -1,18 +0,0 @@
[
{"from":"guangming-science-museum","to":"guangming-science-park","walking":12,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-park","to":"guangming-happy-farm","walking":35,"driving":10,"public_transport":24,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-museum","to":"guangming-happy-farm","walking":42,"driving":12,"public_transport":28,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-happy-farm","to":"guangming-farm-grand-view","walking":30,"driving":9,"public_transport":22,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-farm-grand-view","to":"guangming-hongqiao-park","walking":28,"driving":9,"public_transport":20,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-hongqiao-park","to":"guangming-dadingling-greenway","walking":15,"driving":6,"public_transport":18,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-museum","to":"guangming-culture-art-center","walking":55,"driving":14,"public_transport":28,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-park","to":"guangming-science-city-exhibition","walking":22,"driving":7,"public_transport":17,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-city-exhibition","to":"guangming-happy-farm","walking":18,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-ebohr-museum","to":"guangming-fiyta-center","walking":18,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-ebohr-museum","to":"guangming-minghu-park","walking":35,"driving":10,"public_transport":22,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-fiyta-center","to":"guangming-minghu-park","walking":30,"driving":9,"public_transport":20,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-honghua-park","to":"guangming-rosewood-town","walking":25,"driving":8,"public_transport":18,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-rosewood-town","to":"guangming-nanguang-green-zone","walking":45,"driving":12,"public_transport":25,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-nanguang-green-zone","to":"guangming-zuoan-tech-park","walking":22,"driving":7,"public_transport":16,"note":"POC估算,请以实际导航为准"}
]
@@ -1,13 +0,0 @@
Metadata-Version: 2.4
Name: guangming-travel-assistant
Version: 0.1.0
Summary: Shenzhen Guangming travel planning assistant POC
Requires-Python: >=3.9
Requires-Dist: fastapi<1.0.0,>=0.115.0
Requires-Dist: openai<2.0.0,>=1.50.0
Requires-Dist: pydantic<2.13.0,>=2.8.0
Requires-Dist: python-dotenv<2.0.0,>=1.0.0
Requires-Dist: uvicorn[standard]<1.0.0,>=0.30.0
Provides-Extra: test
Requires-Dist: httpx<1.0.0,>=0.27.0; extra == "test"
Requires-Dist: pytest<9.0.0,>=8.0.0; extra == "test"
@@ -1,15 +0,0 @@
pyproject.toml
app/__init__.py
app/knowledge.py
app/llm.py
app/main.py
app/planner.py
app/prompts.py
app/schemas.py
guangming_travel_assistant.egg-info/PKG-INFO
guangming_travel_assistant.egg-info/SOURCES.txt
guangming_travel_assistant.egg-info/dependency_links.txt
guangming_travel_assistant.egg-info/requires.txt
guangming_travel_assistant.egg-info/top_level.txt
tests/test_api.py
tests/test_knowledge.py
@@ -1,9 +0,0 @@
fastapi<1.0.0,>=0.115.0
openai<2.0.0,>=1.50.0
pydantic<2.13.0,>=2.8.0
python-dotenv<2.0.0,>=1.0.0
uvicorn[standard]<1.0.0,>=0.30.0
[test]
httpx<1.0.0,>=0.27.0
pytest<9.0.0,>=8.0.0
-29
View File
@@ -1,29 +0,0 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "guangming-travel-assistant"
version = "0.1.0"
description = "Shenzhen Guangming travel planning assistant POC"
requires-python = ">=3.9"
dependencies = [
"fastapi>=0.115.0,<1.0.0",
"openai>=1.50.0,<2.0.0",
"pydantic>=2.8.0,<2.13.0",
"python-dotenv>=1.0.0,<2.0.0",
"uvicorn[standard]>=0.30.0,<1.0.0",
]
[project.optional-dependencies]
test = [
"httpx>=0.27.0,<1.0.0",
"pytest>=8.0.0,<9.0.0",
]
[tool.setuptools]
packages = ["app"]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
-99
View File
@@ -1,99 +0,0 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def preferences(**overrides):
payload = {
"destination": "深圳市光明区",
"themes": ["亲子"],
"duration": "half_day",
"pace": "relaxed",
"interests": ["自然风光", "生态科普"],
"adults": 2,
"children": 1,
"childAges": [6],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": "",
}
payload.update(overrides)
return payload
def test_health_reports_knowledge_and_mock_model():
response = client.get("/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["knowledgeCount"] >= 15
assert data["modelConfigured"] is True
def test_create_plan_only_returns_known_guangming_places():
response = client.post("/api/v1/plans", json=preferences())
assert response.status_code == 200
data = response.json()
assert data["conversationId"]
assert data["itinerary"]["items"]
assert len(data["itinerary"]["items"]) >= 2
assert len(data["itinerary"]["items"]) <= 3
assert all(
item["placeId"].startswith("guangming-")
for item in data["itinerary"]["items"]
)
assert len(data["itinerary"]["sources"]) == len(
{item["placeId"] for item in data["itinerary"]["items"]}
)
def test_indoor_requirement_filters_out_outdoor_places():
response = client.post(
"/api/v1/plans",
json=preferences(
themes=["研学"],
interests=["文化场馆"],
extraRequirements="下午下雨,只安排室内项目",
),
)
assert response.status_code == 200
outdoor_ids = {
"guangming-science-park",
"guangming-happy-farm",
"guangming-hongqiao-park",
"guangming-dadingling-greenway",
}
ids = {item["placeId"] for item in response.json()["itinerary"]["items"]}
assert ids.isdisjoint(outdoor_ids)
def test_adjust_existing_plan():
created = client.post("/api/v1/plans", json=preferences()).json()
response = client.post(
f"/api/v1/conversations/{created['conversationId']}/messages",
json={"message": "改成室内研学路线"},
)
assert response.status_code == 200
data = response.json()
assert data["conversationId"] == created["conversationId"]
assert data["changeSummary"]
assert data["itinerary"]["items"]
def test_missing_preferences_are_rejected():
response = client.post(
"/api/v1/plans",
json=preferences(themes=[], interests=[]),
)
assert response.status_code == 422
def test_expired_or_unknown_session_is_404():
response = client.post(
"/api/v1/conversations/not-found/messages",
json={"message": "慢一点"},
)
assert response.status_code == 404
@@ -1,31 +0,0 @@
from app.knowledge import KnowledgeBase
from app.schemas import TravelPreferences
def make_preferences(extra_requirements: str = "") -> TravelPreferences:
return TravelPreferences(
themes=["亲子"],
duration="half_day",
pace="relaxed",
interests=["自然风光"],
adults=2,
children=1,
child_ages=[3],
transport="public_transport",
budget_level="standard",
extra_requirements=extra_requirements,
)
def test_retrieval_is_deterministic():
knowledge = KnowledgeBase()
first = [place["id"] for place in knowledge.retrieve(make_preferences())]
second = [place["id"] for place in knowledge.retrieve(make_preferences())]
assert first == second
def test_avoid_climbing_excludes_high_intensity_places():
knowledge = KnowledgeBase()
result = knowledge.retrieve(make_preferences("带婴儿车,不爬山"))
assert all(place["intensity"] != "high" for place in result)
-7
View File
@@ -1,7 +0,0 @@
// app.js
App({
onLaunch() {
// 可以在此做全局初始化,如上报启动日志等
},
globalData: {}
});
-14
View File
@@ -1,14 +0,0 @@
{
"pages": [
"pages/poi/poi",
"pages/badges/badges"
],
"window": {
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTitleText": "打卡小程序",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}
-11
View File
@@ -1,11 +0,0 @@
/**app.wxss**/
page {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #333;
background: #fff8ec;
}
/* 通用按钮重置:去掉小程序 button 默认的边框和圆角 */
button::after {
border: none;
}
-52
View File
@@ -1,52 +0,0 @@
// pages/badges/badges.js
const userStore = require('../../userStore.js');
Page({
data: {
points: 0,
badges: []
},
onLoad() {
this.refresh();
},
// 每次显示都刷新一次,保证从其他页面打卡回来后数据是最新的
onShow() {
this.refresh();
},
refresh() {
const data = userStore.getUserData();
// 将徽章数据处理为渲染友好的结构(格式化获得时间、补默认图标)
const badges = (data.badges || []).map(b => ({
name: b.name,
icon: this.getBadgeIcon(b.name),
obtainedAtText: this.formatTime(b.obtainedAt)
}));
this.setData({
points: data.points || 0,
badges
});
},
// 根据徽章名称返回对应的 emoji 图标(也可换成图片路径)
getBadgeIcon(name) {
const map = {
'探索者': '🧭',
'旅行家': '🌍',
'打卡达人': '🏆'
};
return map[name] || '🎖️';
},
// 格式化时间戳为 "YYYY-MM-DD HH:mm"
formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const pad = n => (n < 10 ? '0' + n : '' + n);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
});
-6
View File
@@ -1,6 +0,0 @@
{
"navigationBarTitleText": "我的徽章",
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
}
-37
View File
@@ -1,37 +0,0 @@
<!--pages/badges/badges.wxml-->
<view class="page">
<!-- 顶部积分卡片 -->
<view class="header-card">
<view class="header-label">我的总积分</view>
<view class="header-points">
<text class="points-num">{{points}}</text>
<text class="points-unit"> 分</text>
</view>
</view>
<!-- 徽章墙标题 -->
<view class="section-title">
<text>徽章墙</text>
<text class="section-count">共 {{badges.length}} 枚</text>
</view>
<!-- 徽章网格 -->
<block wx:if="{{badges.length > 0}}">
<view class="badge-grid">
<view class="badge-item" wx:for="{{badges}}" wx:key="name">
<view class="badge-icon">{{item.icon}}</view>
<view class="badge-name">{{item.name}}</view>
<view class="badge-time">{{item.obtainedAtText}}</view>
</view>
</view>
</block>
<!-- 空状态 -->
<view class="empty" wx:else>
<view class="empty-icon">🎖️</view>
<view class="empty-text">还没有获得徽章</view>
<view class="empty-tip">去景点打卡,赢取你的第一枚徽章吧~</view>
</view>
</view>
-127
View File
@@ -1,127 +0,0 @@
/* pages/badges/badges.wxss */
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background: #fff8ec;
}
/* 顶部积分卡片 */
.header-card {
background: linear-gradient(135deg, #ffb347 0%, #ff7e5f 100%);
border-radius: 24rpx;
padding: 40rpx 32rpx;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(255, 126, 95, 0.25);
}
.header-label {
font-size: 28rpx;
opacity: 0.9;
}
.header-points {
margin-top: 12rpx;
display: flex;
align-items: baseline;
}
.points-num {
font-size: 72rpx;
font-weight: 700;
line-height: 1;
}
.points-unit {
font-size: 30rpx;
margin-left: 8rpx;
}
/* 分区标题 */
.section-title {
margin: 40rpx 8rpx 20rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-count {
font-size: 24rpx;
font-weight: 400;
color: #999;
}
/* 徽章网格 */
.badge-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.badge-item {
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
transition: transform 0.2s;
}
.badge-item:active {
transform: scale(0.96);
}
.badge-icon {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fff2d6, #ffe0b3);
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
margin-bottom: 16rpx;
}
.badge-name {
font-size: 28rpx;
color: #333;
font-weight: 600;
margin-bottom: 6rpx;
}
.badge-time {
font-size: 20rpx;
color: #999;
text-align: center;
}
/* 空状态 */
.empty {
margin-top: 120rpx;
text-align: center;
color: #999;
}
.empty-icon {
font-size: 120rpx;
opacity: 0.5;
}
.empty-text {
margin-top: 24rpx;
font-size: 30rpx;
color: #666;
}
.empty-tip {
margin-top: 12rpx;
font-size: 24rpx;
color: #aaa;
}
-80
View File
@@ -1,80 +0,0 @@
// pages/poi/poi.js
const userStore = require('../../userStore.js');
// 模拟的景点数据,实际项目中可以来自云端接口
const POI_LIST = [
{ id: 'poi_001', name: '西湖', icon: '🌊', desc: '杭州最著名的湖泊' },
{ id: 'poi_002', name: '灵隐寺', icon: '🛕', desc: '千年古刹,禅意悠远' },
{ id: 'poi_003', name: '雷峰塔', icon: '🗼', desc: '白娘子传说所在地' },
{ id: 'poi_004', name: '西溪湿地', icon: '🌾', desc: '城市中的绿色湿地' },
{ id: 'poi_005', name: '千岛湖', icon: '🏝️', desc: '碧水青山,湖光山色' }
];
Page({
data: {
points: 0,
badgeCount: 0,
pois: []
},
onLoad() {
this.refresh();
},
onShow() {
this.refresh();
},
// 每次刷新:把打卡状态合并到景点列表中,同时更新顶部积分/徽章数
refresh() {
const data = userStore.getUserData();
const checkedSet = new Set(data.checkIns.map(c => c.poiId));
const pois = POI_LIST.map(p => ({
...p,
checked: checkedSet.has(p.id)
}));
this.setData({
points: data.points,
badgeCount: data.badges.length,
pois
});
},
// 点击打卡按钮
onCheckIn(e) {
const { id, name } = e.currentTarget.dataset;
const res = userStore.checkIn(id, name);
wx.showToast({
title: res.message,
icon: res.success ? 'success' : 'none',
duration: 1500
});
if (res.success) {
this.refresh();
}
},
// 跳转到徽章页面
goBadges() {
wx.navigateTo({ url: '/pages/badges/badges' });
},
// 调试用:清空本地数据
onResetData() {
wx.showModal({
title: '确认清空',
content: '将清除所有积分、徽章和打卡记录',
success: (r) => {
if (r.confirm) {
wx.clearStorageSync();
this.refresh();
wx.showToast({ title: '已清空', icon: 'success' });
}
}
});
}
});
-6
View File
@@ -1,6 +0,0 @@
{
"navigationBarTitleText": "景点打卡",
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
}
-49
View File
@@ -1,49 +0,0 @@
<!--pages/poi/poi.wxml-->
<view class="page">
<!-- 顶部用户信息卡片 -->
<view class="header-card" bindtap="goBadges">
<view class="header-left">
<view class="header-label">我的积分</view>
<view class="header-points">
<text class="points-num">{{points}}</text>
<text class="points-unit"> 分</text>
</view>
</view>
<view class="header-right">
<view class="badge-count">
<text class="badge-count-num">{{badgeCount}}</text>
<text class="badge-count-label">枚徽章</text>
</view>
<view class="arrow">查看 </view>
</view>
</view>
<!-- 景点列表 -->
<view class="section-title">热门景点</view>
<view class="poi-list">
<view class="poi-item" wx:for="{{pois}}" wx:key="id">
<view class="poi-icon">{{item.icon}}</view>
<view class="poi-info">
<view class="poi-name">{{item.name}}</view>
<view class="poi-desc">{{item.desc}}</view>
</view>
<button
class="poi-btn {{item.checked ? 'poi-btn-done' : ''}}"
disabled="{{item.checked}}"
data-id="{{item.id}}"
data-name="{{item.name}}"
bindtap="onCheckIn"
>
{{item.checked ? '已打卡' : '打卡 +10'}}
</button>
</view>
</view>
<!-- 调试按钮:清空数据 -->
<view class="reset-wrap">
<button class="reset-btn" bindtap="onResetData">清空本地数据(调试)</button>
</view>
</view>
-156
View File
@@ -1,156 +0,0 @@
/* pages/poi/poi.wxss */
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
}
/* 顶部积分卡片 */
.header-card {
background: linear-gradient(135deg, #ffb347 0%, #ff7e5f 100%);
border-radius: 24rpx;
padding: 36rpx 32rpx;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(255, 126, 95, 0.25);
display: flex;
align-items: center;
justify-content: space-between;
}
.header-label {
font-size: 26rpx;
opacity: 0.9;
}
.header-points {
margin-top: 8rpx;
display: flex;
align-items: baseline;
}
.points-num {
font-size: 64rpx;
font-weight: 700;
line-height: 1;
}
.points-unit {
font-size: 28rpx;
margin-left: 6rpx;
}
.header-right {
text-align: right;
}
.badge-count-num {
font-size: 40rpx;
font-weight: 700;
margin-right: 6rpx;
}
.badge-count-label {
font-size: 24rpx;
opacity: 0.9;
}
.arrow {
margin-top: 12rpx;
font-size: 22rpx;
opacity: 0.9;
}
/* 分区标题 */
.section-title {
margin: 40rpx 8rpx 20rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
/* 景点列表 */
.poi-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.poi-item {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.poi-icon {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fff2d6, #ffe0b3);
display: flex;
align-items: center;
justify-content: center;
font-size: 52rpx;
flex-shrink: 0;
}
.poi-info {
flex: 1;
margin: 0 24rpx;
overflow: hidden;
}
.poi-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.poi-desc {
margin-top: 6rpx;
font-size: 24rpx;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 打卡按钮 */
.poi-btn {
min-width: 160rpx;
height: 64rpx;
line-height: 64rpx;
padding: 0 24rpx;
font-size: 26rpx;
color: #fff;
background: linear-gradient(135deg, #ffb347, #ff7e5f);
border-radius: 32rpx;
border: none;
}
.poi-btn-done {
background: #e5e5e5 !important;
color: #999 !important;
}
.poi-btn[disabled] {
background: #e5e5e5 !important;
color: #999 !important;
}
/* 调试按钮 */
.reset-wrap {
margin: 60rpx 0 40rpx;
display: flex;
justify-content: center;
}
.reset-btn {
font-size: 24rpx;
color: #999;
background: transparent;
padding: 12rpx 32rpx;
}
-40
View File
@@ -1,40 +0,0 @@
{
"miniprogramRoot": "./",
"projectname": "打卡",
"appid": "wxa3abd3957b0eb47b",
"description": "景点打卡示例",
"setting": {
"es6": true,
"enhance": true,
"postcss": true,
"minified": true,
"urlCheck": true,
"compileHotReLoad": true,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"libVersion": "3.0.0",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}
-8
View File
@@ -1,8 +0,0 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}
-113
View File
@@ -1,113 +0,0 @@
// userStore.js
// 用户数据管理模块:负责积分、徽章、打卡记录的本地存储与业务逻辑
const STORAGE_KEY = 'USER_DATA';
// 默认的用户数据结构
const defaultUserData = {
points: 0, // 用户积分
badges: [], // 用户徽章列表,元素形如 { name, obtainedAt }
checkIns: [] // 打卡记录,元素形如 { poiId, poiName, time }
};
/**
* 获取完整的用户数据
* 如果本地没有数据,返回默认结构
* @returns {{points:number, badges:Array, checkIns:Array}}
*/
function getUserData() {
try {
const data = wx.getStorageSync(STORAGE_KEY);
if (data && typeof data === 'object') {
// 兼容旧数据:补齐可能缺失的字段
return Object.assign({}, defaultUserData, data);
}
} catch (e) {
console.error('[userStore] 读取用户数据失败:', e);
}
// 深拷贝默认数据,避免外部修改影响默认值
return JSON.parse(JSON.stringify(defaultUserData));
}
/**
* 保存用户数据到本地
* @param {Object} data
*/
function saveUserData(data) {
try {
wx.setStorageSync(STORAGE_KEY, data);
} catch (e) {
console.error('[userStore] 保存用户数据失败:', e);
}
}
/**
* 判断某个景点是否已经打卡过
* @param {string|number} poiId
* @returns {boolean}
*/
function hasCheckedIn(poiId) {
const data = getUserData();
return data.checkIns.some(item => item.poiId === poiId);
}
/**
* 判断是否已经拥有某个徽章
* @param {Object} data 用户数据
* @param {string} badgeName
* @returns {boolean}
*/
function hasBadge(data, badgeName) {
return data.badges.some(b => b.name === badgeName);
}
/**
* 处理打卡逻辑
* 检查是否已打卡;如未打卡则 +10 积分,添加“探索者”徽章(如没有),保存数据
* @param {string|number} poiId 景点 ID
* @param {string} poiName 景点名称
* @returns {{success:boolean, message:string, data?:Object}}
*/
function checkIn(poiId, poiName) {
if (!poiId) {
return { success: false, message: '景点 ID 无效' };
}
const data = getUserData();
// 已打卡则不再重复奖励
if (data.checkIns.some(item => item.poiId === poiId)) {
return { success: false, message: '你已经打卡过该景点', data };
}
// 增加打卡记录
data.checkIns.push({
poiId,
poiName: poiName || '',
time: Date.now()
});
// 增加 10 积分
data.points += 10;
// 添加“探索者”徽章(若尚未拥有)
const BADGE_NAME = '探索者';
if (!hasBadge(data, BADGE_NAME)) {
data.badges.push({
name: BADGE_NAME,
obtainedAt: Date.now()
});
}
// 保存到本地
saveUserData(data);
return { success: true, message: '打卡成功,积分 +10', data };
}
module.exports = {
getUserData,
saveUserData,
hasCheckedIn,
checkIn
};
Binary file not shown.
Binary file not shown.
+5 -4
View File
@@ -10,9 +10,9 @@ const weixinAppId = manifestEnv.UNI_MP_WEIXIN_APPID || 'touristappid'
export default defineManifestConfig({
'name': '光明文旅地图',
'appid': '',
'description': '深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC',
'versionName': '0.2.0',
'versionCode': '20',
'description': '深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC',
'versionName': '0.3.0',
'versionCode': '30',
'transformPx': false,
'uniStatistics': {
enable: false,
@@ -69,9 +69,10 @@ export default defineManifestConfig({
'mp-weixin': {
// Configure locally with UNI_MP_WEIXIN_APPID; do not commit project credentials.
appid: weixinAppId,
lazyCodeLoading: 'requiredComponents',
permission: {
'scope.userLocation': {
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
desc: '用于地图定位、行程优化和到点打卡判断',
},
},
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
+1
View File
@@ -121,6 +121,7 @@
"postcss-scss": "^4.0.9",
"sass": "1.63.2",
"sass-loader": "10.4.1",
"sharp": "^0.35.3",
"simple-git-hooks": "^2.11.1",
"stylelint": "^16.9.0",
"stylelint-config-recess-order": "^5.1.1",
+4 -2
View File
@@ -5,10 +5,12 @@ export default defineUniPages({
// uni-app compiler drops their manifest equivalents.
permission: {
'scope.userLocation': {
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
desc: '用于地图定位、行程优化和到点打卡判断',
},
},
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
// 微信小程序仅在页面实际使用组件时注入对应代码,减少启动开销。
lazyCodeLoading: 'requiredComponents',
easycom: {
// 注意一定要放在custom里,否则无效,https://ask.dcloud.net.cn/question/131175
custom: {
@@ -43,7 +45,7 @@ export default defineUniPages({
},
{
pagePath: 'pages/assistant/index',
text: 'AI 助手',
text: '文旅助手',
iconPath: 'static/tabbar/assistant.png',
selectedIconPath: 'static/tabbar/assistant-selected.png',
},
+318
View File
@@ -183,6 +183,9 @@ importers:
sass-loader:
specifier: 10.4.1
version: 10.4.1(sass@1.63.2)(webpack@5.99.9)
sharp:
specifier: ^0.35.3
version: 0.35.3(@types/node@24.0.3)
simple-git-hooks:
specifier: ^2.11.1
version: 2.13.0
@@ -1072,6 +1075,9 @@ packages:
'@emnapi/core@1.4.3':
resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@emnapi/runtime@1.4.3':
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
@@ -1310,6 +1316,152 @@ packages:
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.3':
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.3':
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.3':
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.2':
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.2':
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.2':
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.3.2':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.3.2':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-riscv64@1.3.2':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.3.2':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.3.2':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.35.3':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.35.3':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.35.3':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-riscv64@0.35.3':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.35.3':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.35.3':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.35.3':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.35.3':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.35.3':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.3':
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.3':
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.3':
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.3':
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
'@intlify/core-base@9.1.9':
resolution: {integrity: sha512-x5T0p/Ja0S8hs5xs+ImKyYckVkL4CzcEXykVYYV6rcbXxJTe2o58IquSqX9bdncVKbRZP7GlBU1EcRaQEEJ+vw==}
engines: {node: '>= 10'}
@@ -3196,6 +3348,10 @@ packages:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
@@ -5503,6 +5659,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
send@0.18.0:
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'}
@@ -5521,6 +5682,15 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.35.3:
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -8115,6 +8285,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.4.3':
dependencies:
tslib: 2.8.1
@@ -8305,6 +8480,112 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.2
optional: true
'@img/sharp-darwin-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.2
optional: true
'@img/sharp-freebsd-wasm32@0.35.3':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.2':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.2':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.2':
optional: true
'@img/sharp-libvips-linux-arm@1.3.2':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.2':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.2':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.2':
optional: true
'@img/sharp-libvips-linux-x64@1.3.2':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
optional: true
'@img/sharp-linux-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.2
optional: true
'@img/sharp-linux-arm@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.2
optional: true
'@img/sharp-linux-ppc64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.2
optional: true
'@img/sharp-linux-riscv64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.2
optional: true
'@img/sharp-linux-s390x@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.2
optional: true
'@img/sharp-linux-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.2
optional: true
'@img/sharp-linuxmusl-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
optional: true
'@img/sharp-linuxmusl-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
optional: true
'@img/sharp-wasm32@0.35.3':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-webcontainers-wasm32@0.35.3':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/sharp-win32-arm64@0.35.3':
optional: true
'@img/sharp-win32-ia32@0.35.3':
optional: true
'@img/sharp-win32-x64@0.35.3':
optional: true
'@intlify/core-base@9.1.9':
dependencies:
'@intlify/devtools-if': 9.1.9
@@ -10615,6 +10896,8 @@ snapshots:
detect-indent@6.1.0: {}
detect-libc@2.1.2: {}
detect-newline@3.1.0: {}
devlop@1.1.0:
@@ -13353,6 +13636,8 @@ snapshots:
semver@7.7.2: {}
semver@7.8.5: {}
send@0.18.0:
dependencies:
debug: 2.6.9
@@ -13404,6 +13689,39 @@ snapshots:
setprototypeof@1.2.0: {}
sharp@0.35.3(@types/node@24.0.3):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.3
'@img/sharp-darwin-x64': 0.35.3
'@img/sharp-freebsd-wasm32': 0.35.3
'@img/sharp-libvips-darwin-arm64': 1.3.2
'@img/sharp-libvips-darwin-x64': 1.3.2
'@img/sharp-libvips-linux-arm': 1.3.2
'@img/sharp-libvips-linux-arm64': 1.3.2
'@img/sharp-libvips-linux-ppc64': 1.3.2
'@img/sharp-libvips-linux-riscv64': 1.3.2
'@img/sharp-libvips-linux-s390x': 1.3.2
'@img/sharp-libvips-linux-x64': 1.3.2
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
'@img/sharp-linux-arm': 0.35.3
'@img/sharp-linux-arm64': 0.35.3
'@img/sharp-linux-ppc64': 0.35.3
'@img/sharp-linux-riscv64': 0.35.3
'@img/sharp-linux-s390x': 0.35.3
'@img/sharp-linux-x64': 0.35.3
'@img/sharp-linuxmusl-arm64': 0.35.3
'@img/sharp-linuxmusl-x64': 0.35.3
'@img/sharp-webcontainers-wasm32': 0.35.3
'@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.3
'@types/node': 24.0.3
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
+2 -2
View File
@@ -1,7 +1,7 @@
{
"miniprogramRoot": "dist/dev/mp-weixin/",
"projectname": "光明文旅地图",
"description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC",
"description": "深圳市光明区全域文旅地图、POI 数字化展示与本地行程规划 POC",
"setting": {
"es6": true,
"postcss": true,
@@ -24,7 +24,7 @@
"ignore": [],
"include": []
},
"appid": "wx946b42048d117dde",
"appid": "wxda4fb5048b5dc9a1",
"editorSetting": {},
"condition": {}
}
+191
View File
@@ -0,0 +1,191 @@
import { existsSync } from 'node:fs'
/**
* POI 照片处理:产出主包缩略图 + 分包大图两层。
*
* 为什么分两层:微信小程序主包上限 2 MB。30 张 800×600 照片约 2.6 MB
* 单独就超限。所以卡片场景(最大 192rpx ≈ 288 px)用小图放主包,
* 详情页 hero750×422rpx ≈ 1125×633 px)用大图放分包,随详情页按需下载。
*
* 两个产出目录都是提交进仓库的构建产物:
* src/pages-poi/static/photos/<poiId>.jpg 800×600 分包大图(详情页 hero)
* src/static/poi/photos/<poiId>.jpg 400×300 主包缩略图(各处卡片)
*
* 新增一张照片:
* 1. 原图放入 src/photo-intake/(该目录不提交),命名为 <poiId>.jpg
* 或用下方 NAME_TO_POI_ID 表里的中文名,例如「虹桥公园.jpg」
* 2. 跑 `node scripts/process-poi-photos.mjs`
* 3. 把 poiId 加入 src/data/poi/photos.ts 的 POI_COVER_PHOTOS_READY
*
* 不带 intake 直接跑:从分包大图重新生成全部主包缩略图(幂等)。
*/
import { mkdir, readdir, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, '..')
/** 原图投放目录,不提交仓库 */
const intakeDir = join(projectRoot, 'src/photo-intake')
/** 分包大图:详情页 hero 使用,随分包按需下载 */
const fullDir = join(projectRoot, 'src/pages-poi/static/photos')
/** 主包缩略图:地图、行程、打卡等卡片使用 */
const thumbDir = join(projectRoot, 'src/static/poi/photos')
/** 分包大图规格。q66 是在 2 MB 分包上限内能给到 hero 的最高质量 */
const FULL = { width: 800, height: 600, quality: 66 }
/** 主包缩略图规格。400×300 覆盖最大卡片 192rpx(≈288 px @DPR3 */
const THUMB = { width: 400, height: 300, quality: 75 }
/** 微信包体上限 */
const MAIN_PACKAGE_LIMIT_KB = 2048
const SUB_PACKAGE_LIMIT_KB = 2048
// 中文名 → POI ID 映射(30 个景点)
const NAME_TO_POI_ID = {
// 文化场馆
文化艺术中心: 'poi_gm_culture_center',
光明区图书馆: 'poi_amap_b0hbtu4bfr',
光明区文化馆: 'poi_amap_b0ffk306gx',
少年儿童图书馆: 'poi_amap_b02f38meio',
深圳国际美术馆: 'poi_amap_b0jun773j3',
城市规划馆: 'poi_amap_b0jbbzx4oo',
茅洲河图书馆: 'poi_amap_b0k025w32e',
// 公园湿地
虹桥公园: 'poi_hongqiao_park',
红花山公园: 'poi_honghuashan_park',
科学公园: 'poi_amap_b0j2jc4n0w',
鹅颈水湿地公园: 'poi_amap_b0ffmdhve1',
明湖公园: 'poi_amap_b0fffwu1l5',
光明新城公园: 'poi_amap_b0ffhfbsje',
开明公园: 'poi_amap_b0ffjf1b20',
左岸科技公园: 'poi_amap_b0g0kzrt5s',
西田公园: 'poi_amap_b02f30abgs',
大雁山森林公园: 'poi_amap_b0jgbp18uf',
楼村湿地公园: 'poi_amap_b0grac6nzz',
大顶岭山林公园: 'poi_amap_b02f309r88',
// 田园休闲
光明农场大观园: 'poi_farm_grand_view',
华侨城光明欢乐田园: 'poi_happy_pastoral',
双晖稻田农场: 'poi_amap_b0ffkkmzsc',
// 人文地标
弘源寺: 'poi_amap_b02f38nj9k',
陈仙姑祠: 'poi_amap_b0ffgthbcf',
黄氏大宗祠: 'poi_amap_b0fffttiov',
玉律醒狮文化馆: 'poi_amap_b0kb4cxaby',
// 绿道户外
大顶岭绿道: 'poi_dadingling_greenway',
光明湖碧道: 'poi_amap_b0ldbsbeea',
茅洲河碧道: 'poi_amap_b0kup558j4',
五指耙森林公园: 'poi_amap_b0jb2aurvw',
}
const IMAGE_RE = /\.(jpe?g|png|webp)$/i
/** 把源文件名解析成 poiId:优先当作已经是 poiId,否则查中文名映射 */
function resolvePoiId(fileName) {
const base = fileName.replace(IMAGE_RE, '')
if (base.startsWith('poi_'))
return base
return NAME_TO_POI_ID[base] ?? null
}
async function render(inputPath, outputPath, spec) {
await sharp(inputPath)
.rotate() // 按 EXIF 摆正
.resize(spec.width, spec.height, {
fit: 'cover',
position: 'attention', // 智能裁剪,尽量保留主体
})
.jpeg({
quality: spec.quality,
mozjpeg: true,
chromaSubsampling: '4:2:0',
})
.toFile(outputPath)
}
async function dirTotalKb(dir) {
if (!existsSync(dir))
return { count: 0, kb: 0 }
const files = (await readdir(dir)).filter(f => f.endsWith('.jpg'))
let bytes = 0
for (const f of files)
bytes += (await stat(join(dir, f))).size
return { count: files.length, kb: Math.round(bytes / 1024) }
}
async function listIntake() {
if (!existsSync(intakeDir))
return []
return (await readdir(intakeDir)).filter(f => IMAGE_RE.test(f))
}
async function main() {
await mkdir(fullDir, { recursive: true })
await mkdir(thumbDir, { recursive: true })
const intake = await listIntake()
const processed = new Set()
// 1) 有 intake 原图的:同时生成大图和缩略图
for (const file of intake) {
const poiId = resolvePoiId(file)
if (!poiId) {
console.log(`SKIP ${file} — 文件名既不是 poiId 也不在中文名映射表里`)
continue
}
const inputPath = join(intakeDir, file)
try {
await render(inputPath, join(fullDir, `${poiId}.jpg`), FULL)
await render(inputPath, join(thumbDir, `${poiId}.jpg`), THUMB)
processed.add(poiId)
console.log(`OK ${file}${poiId}.jpg(大图 + 缩略图)`)
}
catch (error) {
console.error(`FAIL ${file}: ${error.message}`)
process.exitCode = 1
}
}
// 2) 其余已有大图的:从大图重新生成缩略图,保证两层始终同步
const fullFiles = (await readdir(fullDir)).filter(f => f.endsWith('.jpg'))
for (const file of fullFiles) {
const poiId = file.replace(/\.jpg$/, '')
if (processed.has(poiId))
continue
try {
await render(join(fullDir, file), join(thumbDir, file), THUMB)
console.log(`THUMB ${poiId}.jpg ← 由分包大图重新生成`)
}
catch (error) {
console.error(`FAIL ${file}: ${error.message}`)
process.exitCode = 1
}
}
// 3) 报告包体占用,超限直接失败
const full = await dirTotalKb(fullDir)
const thumb = await dirTotalKb(thumbDir)
console.log('\n包体占用:')
console.log(` 分包大图 ${String(full.count).padStart(2)}${String(full.kb).padStart(4)} KB (分包上限 ${SUB_PACKAGE_LIMIT_KB} KB)`)
console.log(` 主包缩略图 ${String(thumb.count).padStart(2)}${String(thumb.kb).padStart(4)} KB (主包上限 ${MAIN_PACKAGE_LIMIT_KB} KB,还需容纳代码)`)
if (full.kb >= SUB_PACKAGE_LIMIT_KB) {
console.error(`\n分包大图已达 ${full.kb} KB,超过分包上限。请下调 FULL.quality 后重跑。`)
process.exitCode = 1
}
if (thumb.kb >= MAIN_PACKAGE_LIMIT_KB) {
console.error(`\n主包缩略图已达 ${thumb.kb} KB,超过主包上限。请下调 THUMB 规格后重跑。`)
process.exitCode = 1
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+119 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
@@ -10,8 +10,19 @@ const expectedPages = [
'pages/assistant/index',
'pages/itinerary/index',
'pages/planner/index',
'pages/check-in/index',
'pages/check-in/records',
// 旧详情页路由的兼容跳板,只做重定向。缺了它,还攥着旧路径的入口
// (模拟器上次停留的路由、预览启动页、已发出的分享卡片)会白屏。
'pages/poi/detail',
]
// 点位详情页放在 pages-poi 分包里,连带 800×600 大图一起按需下载,
// 否则 30 张大图会把主包顶过 2 MB 上限。
const expectedSubPackageRoot = 'pages-poi'
// 分包页文件名不能是 detail:uni 的 H5 路由表按路径生成组件标识,`pages-poi/detail`
// 和上面兼容跳板的 `pages/poi/detail` 会同时归一化成 PagesPoiDetail,重复声明直接编译失败。
const expectedSubPackagePages = ['poi-detail']
const poiDetailPath = `${expectedSubPackageRoot}/poi-detail`
const expectedTabPages = [
'pages/map/index',
'pages/assistant/index',
@@ -31,6 +42,9 @@ const requiredFiles = [
'app.wxss',
'project.config.json',
...expectedPages.flatMap(page => ['json', 'js', 'wxml', 'wxss'].map(extension => `${page}.${extension}`)),
...expectedSubPackagePages.flatMap(page => ['json', 'js', 'wxml', 'wxss'].map(
extension => `${expectedSubPackageRoot}/${page}.${extension}`,
)),
...expectedTabIcons,
]
@@ -42,6 +56,29 @@ if (missingFiles.length > 0) {
}
const appConfig = JSON.parse(readFileSync(resolve(outputRoot, 'app.json'), 'utf8'))
if (appConfig.lazyCodeLoading !== 'requiredComponents') {
console.error(`组件按需注入配置错误:期望 requiredComponents,实际为 ${appConfig.lazyCodeLoading ?? '未配置'}`)
process.exit(1)
}
const checkInPageScript = readFileSync(resolve(outputRoot, 'pages/check-in/index.js'), 'utf8')
const checkInPageMarkup = readFileSync(resolve(outputRoot, 'pages/check-in/index.wxml'), 'utf8')
if (!checkInPageScript.includes('requirePrivacyAuthorize')
|| !checkInPageMarkup.includes('open-type="agreePrivacyAuthorization"')) {
console.error('打卡页缺少微信位置隐私授权兼容链。')
process.exit(1)
}
const poiDetailMarkup = readFileSync(resolve(outputRoot, `${poiDetailPath}.wxml`), 'utf8')
const checkInEntryIndex = poiDetailMarkup.indexOf('check-in-entry')
const checkInEntryStart = poiDetailMarkup.lastIndexOf('<view', checkInEntryIndex)
const checkInEntryEnd = poiDetailMarkup.indexOf('>', checkInEntryIndex)
const checkInEntryTag = checkInEntryIndex >= 0 && checkInEntryStart >= 0 && checkInEntryEnd >= 0
? poiDetailMarkup.slice(checkInEntryStart, checkInEntryEnd + 1)
: ''
const visibilityAttributes = ['wx:if=', 'wx:elif=', 'wx:else', 'hidden=']
if (!checkInEntryTag || visibilityAttributes.some(attribute => checkInEntryTag.includes(attribute))) {
console.error('景点详情页必须无条件渲染打卡入口。')
process.exit(1)
}
const expectedPrivateInfos = ['getLocation', 'startLocationUpdate', 'onLocationChange']
const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? []
if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) {
@@ -55,6 +92,10 @@ if (locationPermissionDescriptionLength < 1 || locationPermissionDescriptionLeng
console.error('app.json 缺少 scope.userLocation 用途说明。')
process.exit(1)
}
if (!locationPermissionDescription.includes('打卡')) {
console.error('scope.userLocation 用途说明未覆盖到点打卡。')
process.exit(1)
}
const missingPages = expectedPages.filter(page => !appConfig.pages?.includes(page))
if (missingPages.length > 0) {
@@ -138,4 +179,81 @@ if (!ideConfig.appid || !outputIdeConfig.appid || ideConfig.appid !== outputIdeC
process.exit(1)
}
const subPackages = appConfig.subPackages ?? appConfig.subpackages ?? []
const poiSubPackage = subPackages.find(item => item.root === expectedSubPackageRoot)
if (!poiSubPackage) {
console.error(`app.json 未注册 ${expectedSubPackageRoot} 分包,点位详情页与大图会落进主包。`)
process.exit(1)
}
const missingSubPages = expectedSubPackagePages.filter(page => !poiSubPackage.pages?.includes(page))
if (missingSubPages.length > 0) {
console.error(`${expectedSubPackageRoot} 分包未注册全部页面:`)
missingSubPages.forEach(page => console.error(`- ${page}`))
process.exit(1)
}
// 分包大图必须落在分包内,否则等于没分包
const fullPhotoDir = resolve(outputRoot, expectedSubPackageRoot, 'static/photos')
if (!existsSync(fullPhotoDir)) {
console.error(`分包缺少大图目录:${expectedSubPackageRoot}/static/photos`)
process.exit(1)
}
const thumbPhotoDir = resolve(outputRoot, 'static/poi/photos')
if (!existsSync(thumbPhotoDir)) {
console.error('主包缺少缩略图目录:static/poi/photos')
process.exit(1)
}
const fullPhotos = readdirSync(fullPhotoDir).filter(file => file.endsWith('.jpg'))
const thumbPhotos = readdirSync(thumbPhotoDir).filter(file => file.endsWith('.jpg'))
if (fullPhotos.length !== thumbPhotos.length) {
console.error(`两层照片数量不一致:分包大图 ${fullPhotos.length} 张,主包缩略图 ${thumbPhotos.length} 张。请重跑 node scripts/process-poi-photos.mjs`)
process.exit(1)
}
function dirBytes(dir, skipDirs = []) {
let total = 0
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (skipDirs.includes(entry.name))
continue
total += dirBytes(resolve(dir, entry.name), skipDirs)
}
else {
total += statSync(resolve(dir, entry.name)).size
}
}
return total
}
// 微信主包与单个分包各自上限 2 MB。留 64 KB 余量,避免刚好卡线时上传被拒。
const PACKAGE_LIMIT_KB = 2048
const PACKAGE_WARN_KB = PACKAGE_LIMIT_KB - 64
const subPackageRoots = subPackages.map(item => item.root)
const mainPackageKb = Math.round(dirBytes(outputRoot, subPackageRoots) / 1024)
const subPackageSizes = subPackages.map(item => ({
root: item.root,
kb: Math.round(dirBytes(resolve(outputRoot, item.root)) / 1024),
}))
console.log(`主包 ${mainPackageKb} KB / ${PACKAGE_LIMIT_KB} KB`)
subPackageSizes.forEach(({ root, kb }) => {
console.log(`分包 ${root} ${kb} KB / ${PACKAGE_LIMIT_KB} KB`)
})
if (mainPackageKb > PACKAGE_WARN_KB) {
console.error(`主包 ${mainPackageKb} KB 已超过 ${PACKAGE_WARN_KB} KB 安全线(微信上限 ${PACKAGE_LIMIT_KB} KB),上传会被拒。`)
process.exit(1)
}
const oversizedSubPackages = subPackageSizes.filter(({ kb }) => kb > PACKAGE_WARN_KB)
if (oversizedSubPackages.length > 0) {
oversizedSubPackages.forEach(({ root, kb }) => {
console.error(`分包 ${root} ${kb} KB 已超过 ${PACKAGE_WARN_KB} KB 安全线(微信上限 ${PACKAGE_LIMIT_KB} KB)。`)
})
process.exit(1)
}
console.log(`微信开发者工具产物检查通过:${outputRoot}`)
+3
View File
@@ -2,6 +2,9 @@
LLM_MODE=mock
# Configure these on the server only when LLM_MODE=real.
# Any OpenAI-compatible endpoint works, e.g. Zhipu GLM:
# OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
# OPENAI_MODEL=glm-4-flash # free tier; use glm-4-plus etc. if you have credit
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=
+16 -7
View File
@@ -2,6 +2,8 @@
This FastAPI service is the server-only AI boundary for the Guangming travel POC. It defaults to deterministic `mock` mode. Model credentials belong only in `server/.env` or the deployment environment and must never be added to the mini-program build.
The mini-program calls this service when `VITE_TRAVEL_ASSISTANT_API_BASE_URL` is set (`.env.development` points it at `http://localhost:8000`). If that variable is empty, or the service is unreachable or returns an error, the client silently falls back to its deterministic local planner — so the demo always works. The model only ever recommends POIs from a coordinate-free client whitelist; route geometry, transfer estimates, and final itinerary time remain client responsibilities. The itinerary page labels the result `真实 AI 推荐` (real), `远端演示规划` (mock), or `本地 POC 规划` (local fallback).
## Contract
`POST /api/v1/plans` accepts a planning request and a coordinate-free POI whitelist:
@@ -12,15 +14,9 @@ This FastAPI service is the server-only AI boundary for the Guangming travel POC
"preferences": {
"destination": "深圳市光明区",
"themes": ["亲子"],
"duration": "half_day",
"pace": "moderate",
"interests": ["自然风光"],
"adults": 2,
"children": 1,
"childAges": [6],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": ""
"transport": "public_transport"
},
"durationMinutes": 240,
"selectedPoiIds": ["poi_a", "poi_b"],
@@ -64,3 +60,16 @@ Run tests from the repository root:
```bash
corepack pnpm server:test
```
## Real model mode
Mock mode is deterministic and needs no credentials. To use a real model, set `LLM_MODE=real` in `server/.env` (gitignored) with any OpenAI-compatible endpoint — for example Zhipu GLM:
```
LLM_MODE=real
OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
OPENAI_MODEL=glm-4-flash
OPENAI_API_KEY=<your key>
```
`GET /api/v1/health` then reports `mode=real, configured=true, ready=true`. With `pnpm server:dev` running, generate a plan from the mini-program (H5 dev server on `:5173` is already in `CORS_ORIGINS`; for `mp-weixin` enable “不校验合法域名” in the devtools). To force the local planner again, unset `VITE_TRAVEL_ASSISTANT_API_BASE_URL` or stop the server.
-2
View File
@@ -28,8 +28,6 @@ def _score(candidate: PoiCandidate, request: PlanRecommendationRequest) -> int:
for interest in request.preferences.interests:
desired_tags.update(INTEREST_TAGS.get(interest, set()))
score += len(desired_tags.intersection(candidate.tag_codes)) * 8
if request.preferences.children and "family_friendly" in candidate.tag_codes:
score += 8
return score
-23
View File
@@ -25,11 +25,6 @@ class PlanningMode(str, Enum):
CUSTOM = "custom"
class Duration(str, Enum):
HALF_DAY = "half_day"
FULL_DAY = "full_day"
class Pace(str, Enum):
RELAXED = "relaxed"
MODERATE = "moderate"
@@ -42,12 +37,6 @@ class Transport(str, Enum):
PUBLIC_TRANSPORT = "public_transport"
class BudgetLevel(str, Enum):
ECONOMY = "economy"
STANDARD = "standard"
QUALITY = "quality"
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
@@ -55,24 +44,12 @@ Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "
class TravelPreferences(APIModel):
destination: Literal["深圳市光明区"] = "深圳市光明区"
themes: list[Theme] = Field(default_factory=list, max_length=5)
duration: Duration
pace: Pace
interests: list[Interest] = Field(default_factory=list, max_length=5)
adults: int = Field(default=2, ge=0, le=20)
children: int = Field(default=0, ge=0, le=20)
child_ages: list[int] = Field(default_factory=list, max_length=20)
transport: Transport = Transport.PUBLIC_TRANSPORT
budget_level: BudgetLevel = BudgetLevel.STANDARD
extra_requirements: str = Field(default="", max_length=500)
@model_validator(mode="after")
def validate_group(self) -> "TravelPreferences":
if self.adults + self.children < 1:
raise ValueError("出行总人数至少为 1")
if self.child_ages and len(self.child_ages) != self.children:
raise ValueError("儿童年龄数量必须与儿童人数一致")
if any(age < 0 or age > 17 for age in self.child_ages):
raise ValueError("儿童年龄须在 0 至 17 岁之间")
if len(set(self.themes)) != len(self.themes):
raise ValueError("出行主题不能重复")
if len(set(self.interests)) != len(self.interests):
-6
View File
@@ -16,15 +16,9 @@ def preferences():
return {
"destination": "深圳市光明区",
"themes": ["亲子"],
"duration": "half_day",
"pace": "moderate",
"interests": ["自然风光"],
"adults": 2,
"children": 1,
"childAges": [6],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": "",
}
-6
View File
@@ -14,15 +14,9 @@ def custom_request() -> PlanRecommendationRequest:
"preferences": {
"destination": "深圳市光明区",
"themes": ["朋友"],
"duration": "half_day",
"pace": "moderate",
"interests": ["自然风光"],
"adults": 2,
"children": 0,
"childAges": [],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": "",
},
"durationMinutes": 120,
"selectedPoiIds": ["poi_a", "poi_b", "poi_c"],
+30 -1
View File
@@ -10,6 +10,7 @@ defineProps<{
}>()
const emit = defineEmits<{
checkIns: []
select: [categoryCode: string | null]
}>()
</script>
@@ -21,7 +22,10 @@ const emit = defineEmits<{
<text class="map-filter-header__title">光明区文旅资源</text>
<text class="map-filter-header__scope">{{ coverageLabel }}</text>
</view>
<view class="map-filter-header__actions">
<text class="map-filter-header__count"> {{ resultCount }} 个点位</text>
<button class="map-filter-header__check-ins" @click="emit('checkIns')">我的打卡</button>
</view>
</view>
<scroll-view class="map-filter-header__scroll" scroll-x :show-scrollbar="false">
@@ -91,11 +95,36 @@ const emit = defineEmits<{
}
.map-filter-header__count {
flex: none;
font-weight: 600;
color: #167a5b;
}
.map-filter-header__actions {
display: flex;
flex: none;
flex-direction: column;
gap: 4rpx;
align-items: flex-end;
}
.map-filter-header__check-ins {
width: auto;
height: 42rpx;
padding: 0 15rpx;
margin: 0;
font-size: 20rpx;
font-weight: 700;
line-height: 42rpx;
color: #0e6247;
background: #e8f4ee;
border: 0;
border-radius: 21rpx;
}
.map-filter-header__check-ins::after {
border: 0;
}
.map-filter-header__scroll {
width: 100%;
height: 80rpx;
+1
View File
@@ -0,0 +1 @@
export * from './tasks'
+31
View File
@@ -0,0 +1,31 @@
import { getPoiRepository } from '@/data/poi'
import { CHECK_IN_RADIUS_METERS } from '@/domain/check-in'
export interface CheckInTask {
poiId: string
radiusMeters: number
status: 'poc_enabled'
verificationNote: string
}
function createCheckInTask(poiId: string): CheckInTask {
return {
poiId,
radiusMeters: CHECK_IN_RADIUS_METERS,
status: 'poc_enabled',
verificationNote: 'POC 开放点位,正式发布前需在 iOS 真机复核游客入口坐标与安全到达区域。',
}
}
export function getCheckInTask(poiId: string): CheckInTask | null {
const normalizedPoiId = poiId.trim()
if (!normalizedPoiId || !getPoiRepository().getPoiById(normalizedPoiId))
return null
return createCheckInTask(normalizedPoiId)
}
export function getCheckInTasks(): CheckInTask[] {
return getPoiRepository()
.getPublishedPois()
.map(poi => createCheckInTask(poi.id))
}
+25 -13
View File
@@ -1,7 +1,9 @@
import type { Poi, PoiDataset, PoiImageAsset } from '@/domain/poi'
import { coverPhotoUrlFor, hasCoverPhoto } from './photos'
const SNAPSHOT_CAPTURED_AT = '2026-07-30T01:00:33Z'
const POC_REVIEWED_AT = '2026-07-30T01:30:00Z'
const PHOTOS_REVIEWED_AT = '2026-07-31T00:00:00Z'
const PLACEHOLDER_URL = '/static/poi/placeholder.png'
const AMAP_DATA_SOURCE = '高德地图 Web Service POI 2.0 离线候选快照(2026-07-30),POC 内容映射待项目方复核'
@@ -416,19 +418,26 @@ const poiSeeds: PocPoiSeed[] = [
},
]
function createPlaceholderImage(seed: PocPoiSeed): PoiImageAsset {
function createCoverImage(seed: PocPoiSeed): PoiImageAsset {
const photoReady = hasCoverPhoto(seed.id)
return {
id: `img_${seed.id}_cover`,
url: PLACEHOLDER_URL,
source: 'map-gm-app POC 包内占位素材',
sourceReference: 'asset_src_static_poi_placeholder',
url: photoReady ? coverPhotoUrlFor(seed.id) : PLACEHOLDER_URL,
source: photoReady
? '项目方提供实景照片(本地静态资源)'
: 'map-gm-app POC 包内占位素材',
sourceReference: photoReady
? `asset_src_static_poi_photos_${seed.id}_jpg`
: 'asset_src_static_poi_placeholder',
copyrightStatus: 'cleared',
licenseEvidence: 'POC_INTERNAL_ASSET_001',
reviewedBy: 'poc_content_owner',
reviewedAt: POC_REVIEWED_AT,
updatedAt: POC_REVIEWED_AT,
isPlaceholder: true,
alt: `${seed.name} POC 占位图`,
licenseEvidence: photoReady
? 'PROJECT_PROVIDED_COVER_PHOTO_2026Q3'
: 'POC_INTERNAL_ASSET_001',
reviewedBy: photoReady ? 'project_content_owner' : 'poc_content_owner',
reviewedAt: photoReady ? PHOTOS_REVIEWED_AT : POC_REVIEWED_AT,
updatedAt: photoReady ? PHOTOS_REVIEWED_AT : POC_REVIEWED_AT,
isPlaceholder: !photoReady,
alt: photoReady ? `${seed.name}实景照片` : `${seed.name} POC 占位图`,
}
}
@@ -484,10 +493,13 @@ export const poiDataset: PoiDataset = {
authoritativeBaselineProvided: false,
canClaimFullCoverage: false,
disclaimer: `本地数据来自 ${SNAPSHOT_CAPTURED_AT.slice(0, 10)} 高德候选快照并经 POC 筛选;简介、开放时间、推荐指数与图片仍需项目方逐点复核,不代表光明区全域权威清单。`,
// 仅在运行时算不出聚类视野(点位为空)时兜底。
// 值取自 30 个点位的算术中心,与 computeClusterViewport 的结果一致;
// 真正生效的初始视野由 computeClusterViewport 按当前点位实时算出。
defaultViewport: {
longitude: 113.9275,
latitude: 22.76,
scale: 11,
latitude: 22.7611,
scale: 12,
},
},
categories: [
@@ -544,6 +556,6 @@ export const poiDataset: PoiDataset = {
{ code: 'science_learning', name: '科普体验', sortOrder: 90, status: 'enabled', synonyms: ['科普'] },
{ code: 'walking', name: '漫步休闲', sortOrder: 100, status: 'enabled', synonyms: ['徒步', '散步'] },
],
images: poiSeeds.map(createPlaceholderImage),
images: poiSeeds.map(createCoverImage),
pois: poiSeeds.map(createPoi),
}
+79
View File
@@ -0,0 +1,79 @@
/**
* POI 封面照片注册表
*
* 照片分两层存放,因为微信小程序主包上限 2 MB,30 张大图单独就超限:
* - 缩略图 400×300 放主包,供地图、行程、打卡等卡片使用(最大 192rpx ≈ 288 px
* - 大图 800×600 放 pages-poi 分包,供详情页 hero 使用,随分包按需下载
*
* 如何添加一张真实照片:
* 1. 原图放入 src/photo-intake/(不提交仓库),命名为 <poiId>.jpg
* 2. 跑 `node scripts/process-poi-photos.mjs` 生成两层图片
* 3. 将 poiId 加入下方 POI_COVER_PHOTOS_READY 集合
*
* 未在此集合中的 POI 会继续使用占位图 /static/poi/placeholder.png
*/
/** 已放入真实照片的 POI ID 集合 */
export const POI_COVER_PHOTOS_READY: ReadonlySet<string> = new Set<string>([
// 文化场馆
'poi_gm_culture_center', // 光明文化艺术中心
'poi_amap_b0hbtu4bfr', // 光明区图书馆
'poi_amap_b0ffk306gx', // 光明区文化馆
'poi_amap_b02f38meio', // 光明区少年儿童图书馆
'poi_amap_b0jun773j3', // 深圳国际美术馆
'poi_amap_b0jbbzx4oo', // 光明区城市规划展览馆
'poi_amap_b0k025w32e', // 茅洲河图书馆
// 公园湿地
'poi_hongqiao_park', // 虹桥公园
'poi_honghuashan_park', // 红花山公园
'poi_amap_b0j2jc4n0w', // 科学公园
'poi_amap_b0ffmdhve1', // 鹅颈水湿地公园
'poi_amap_b0fffwu1l5', // 明湖公园
'poi_amap_b0ffhfbsje', // 光明新城公园(牛山公园)
'poi_amap_b0ffjf1b20', // 光明开明公园
'poi_amap_b0g0kzrt5s', // 左岸科技公园
'poi_amap_b02f30abgs', // 西田公园
'poi_amap_b0jgbp18uf', // 大雁山森林公园
'poi_amap_b0grac6nzz', // 楼村湿地公园
'poi_amap_b02f309r88', // 大顶岭山林公园
// 田园休闲
'poi_farm_grand_view', // 光明农场大观园
'poi_happy_pastoral', // 华侨城光明欢乐田园
'poi_amap_b0ffkkmzsc', // 双晖稻田农场
// 人文地标
'poi_amap_b02f38nj9k', // 弘源寺
'poi_amap_b0ffgthbcf', // 陈仙姑祠
'poi_amap_b0fffttiov', // 黄氏大宗祠
'poi_amap_b0kb4cxaby', // 玉律醒狮文化馆
// 绿道户外
'poi_dadingling_greenway', // 大顶岭绿道
'poi_amap_b0ldbsbeea', // 光明湖碧道
'poi_amap_b0kup558j4', // 茅洲河碧道
'poi_amap_b0jb2aurvw', // 五指耙森林公园(光明片区)
])
/** 主包缩略图目录(400×300 */
const POI_THUMB_BASE_URL = '/static/poi/photos'
/** pages-poi 分包大图目录(800×600),仅分包内页面可引用 */
const POI_FULL_BASE_URL = '/pages-poi/static/photos'
/**
* 卡片用缩略图 URL。主包内任何页面都可用。
*/
export function coverPhotoUrlFor(poiId: string): string {
return `${POI_THUMB_BASE_URL}/${poiId}.jpg`
}
/**
* 详情页 hero 用大图 URL。
*
* 只有 pages-poi 分包内的页面能引用 —— 分包资源要等分包下载后才存在,
* 主包页面拿到这个路径会加载失败。
*/
export function coverPhotoFullUrlFor(poiId: string): string {
return `${POI_FULL_BASE_URL}/${poiId}.jpg`
}
export function hasCoverPhoto(poiId: string): boolean {
return POI_COVER_PHOTOS_READY.has(poiId)
}
+218
View File
@@ -0,0 +1,218 @@
import type {
CheckInBadge,
CheckInBadgeCode,
CheckInBadgeDefinition,
CheckInEligibility,
CheckInLocationSnapshot,
CheckInProfile,
CheckInRecord,
CheckInResult,
} from './types'
import type { GeoPoint } from '@/domain/poi'
export const CHECK_IN_POINTS = 10
export const CHECK_IN_RADIUS_METERS = 200
export const CHECK_IN_MAX_ACCURACY_METERS = 100
export const CHECK_IN_BADGES: readonly CheckInBadgeDefinition[] = [
{
code: 'explorer',
name: '光明探索者',
description: '完成首个真实定位点位打卡',
icon: '01',
requiredCheckIns: 1,
},
{
code: 'traveller',
name: '光明旅行家',
description: '累计打卡 2 个开放点位',
icon: '02',
requiredCheckIns: 2,
},
{
code: 'check_in_master',
name: '全域打卡达人',
description: '累计打卡 3 个开放点位',
icon: '03',
requiredCheckIns: 3,
},
]
export class CheckInError extends Error {
constructor(
message: string,
readonly code: 'already_checked_in' | 'invalid_poi' | 'invalid_location' | 'not_eligible',
) {
super(message)
this.name = 'CheckInError'
}
}
export function createEmptyCheckInProfile(): CheckInProfile {
return { points: 0, badges: [], checkIns: [] }
}
export function cloneCheckInProfile(profile: CheckInProfile): CheckInProfile {
return {
points: profile.points,
badges: profile.badges.map(badge => ({ ...badge })),
checkIns: profile.checkIns.map(record => ({ ...record })),
}
}
function toRadians(value: number): number {
return value * Math.PI / 180
}
function isValidPoint(point: GeoPoint): boolean {
return Number.isFinite(point.longitude)
&& Number.isFinite(point.latitude)
&& point.longitude >= -180
&& point.longitude <= 180
&& point.latitude >= -90
&& point.latitude <= 90
}
export function distanceBetweenMeters(left: GeoPoint, right: GeoPoint): number {
if (!isValidPoint(left) || !isValidPoint(right))
return Number.NaN
const earthRadiusMeters = 6_371_008.8
const latitudeDelta = toRadians(right.latitude - left.latitude)
const longitudeDelta = toRadians(right.longitude - left.longitude)
const leftLatitude = toRadians(left.latitude)
const rightLatitude = toRadians(right.latitude)
const haversine = Math.sin(latitudeDelta / 2) ** 2
+ Math.cos(leftLatitude) * Math.cos(rightLatitude) * Math.sin(longitudeDelta / 2) ** 2
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
}
export function createCheckInRecordId(poiId: string, checkedInAt: string): string {
return `check-in:${poiId}:${checkedInAt}`
}
export function evaluateCheckInEligibility(
location: CheckInLocationSnapshot,
poi: GeoPoint,
allowedDistanceMeters = CHECK_IN_RADIUS_METERS,
): CheckInEligibility {
if (!Number.isFinite(allowedDistanceMeters) || allowedDistanceMeters <= 0) {
return {
eligible: false,
code: 'invalid_location',
distanceMeters: null,
allowedDistanceMeters: CHECK_IN_RADIUS_METERS,
message: '点位打卡范围配置无效,请稍后重试',
}
}
if (location.coordinateSystem !== 'GCJ02' || !isValidPoint(location)) {
return {
eligible: false,
code: 'invalid_location',
distanceMeters: null,
allowedDistanceMeters,
message: '定位结果无效,请重新获取当前位置',
}
}
const accuracy = location.accuracy
if (accuracy === null || !Number.isFinite(accuracy) || accuracy <= 0 || accuracy > CHECK_IN_MAX_ACCURACY_METERS) {
return {
eligible: false,
code: 'low_accuracy',
distanceMeters: null,
allowedDistanceMeters,
message: '当前位置精度不足,请到开阔处重新定位',
}
}
const distanceMeters = distanceBetweenMeters(location, poi)
if (!Number.isFinite(distanceMeters)) {
return {
eligible: false,
code: 'invalid_location',
distanceMeters: null,
allowedDistanceMeters,
message: '定位结果无效,请重新获取当前位置',
}
}
if (distanceMeters - accuracy > allowedDistanceMeters) {
return {
eligible: false,
code: 'too_far',
distanceMeters,
allowedDistanceMeters,
message: `请到点位 ${Math.round(allowedDistanceMeters)} 米范围内再打卡`,
}
}
if (distanceMeters + accuracy > allowedDistanceMeters) {
return {
eligible: false,
code: 'uncertain',
distanceMeters,
allowedDistanceMeters,
message: '当前位置接近打卡边界,请靠近点位后重新定位',
}
}
return {
eligible: true,
code: 'eligible',
distanceMeters,
allowedDistanceMeters,
message: '已进入点位打卡范围',
}
}
export function hasCheckedIn(profile: CheckInProfile, poiId: string): boolean {
const normalizedPoiId = poiId.trim()
return Boolean(normalizedPoiId) && profile.checkIns.some(record => record.poiId === normalizedPoiId)
}
function badgesForCheckInCount(checkInCount: number, obtainedAt: string): CheckInBadge[] {
return CHECK_IN_BADGES
.filter(definition => checkInCount >= definition.requiredCheckIns)
.map(definition => ({ code: definition.code, obtainedAt }))
}
export function applyCheckIn(
profile: CheckInProfile,
poi: { id: string, name: string } & GeoPoint,
location: CheckInLocationSnapshot,
now = Date.now(),
allowedDistanceMeters = CHECK_IN_RADIUS_METERS,
): CheckInResult {
const poiId = poi.id.trim()
const poiName = poi.name.trim()
if (!poiId || !poiName || !isValidPoint(poi))
throw new CheckInError('点位信息无效,请返回地图重新选择', 'invalid_poi')
if (hasCheckedIn(profile, poiId))
throw new CheckInError('该点位已经打卡,本机不会重复计分', 'already_checked_in')
const eligibility = evaluateCheckInEligibility(location, poi, allowedDistanceMeters)
if (!eligibility.eligible || eligibility.distanceMeters === null)
throw new CheckInError(eligibility.message, 'not_eligible')
const checkedInAt = new Date(now).toISOString()
const record: CheckInRecord = {
id: createCheckInRecordId(poiId, checkedInAt),
poiId,
poiName,
checkedInAt,
pointsAwarded: CHECK_IN_POINTS,
}
const previousBadgeCodes = new Set<CheckInBadgeCode>(profile.badges.map(badge => badge.code))
const earnedBadges = badgesForCheckInCount(profile.checkIns.length + 1, checkedInAt)
const newBadges = earnedBadges.filter(badge => !previousBadgeCodes.has(badge.code))
const nextProfile: CheckInProfile = {
points: profile.points + CHECK_IN_POINTS,
badges: [
...profile.badges.map(badge => ({ ...badge })),
...newBadges,
],
checkIns: [record, ...profile.checkIns.map(item => ({ ...item }))],
}
return { profile: nextProfile, record: { ...record }, newBadges: newBadges.map(badge => ({ ...badge })) }
}
+3
View File
@@ -0,0 +1,3 @@
export * from './check-in'
export * from './types'
export * from './validation'
+51
View File
@@ -0,0 +1,51 @@
import type { CoordinateSystem, GeoPoint } from '@/domain/poi'
export type CheckInBadgeCode = 'explorer' | 'traveller' | 'check_in_master'
export type CheckInEligibilityCode = 'eligible' | 'too_far' | 'uncertain' | 'low_accuracy' | 'invalid_location'
export interface CheckInLocationSnapshot extends GeoPoint {
accuracy: number | null
receivedAt: string
coordinateSystem: CoordinateSystem
}
export interface CheckInRecord {
id: string
poiId: string
poiName: string
checkedInAt: string
pointsAwarded: number
}
export interface CheckInBadge {
code: CheckInBadgeCode
obtainedAt: string
}
export interface CheckInProfile {
points: number
badges: CheckInBadge[]
checkIns: CheckInRecord[]
}
export interface CheckInEligibility {
eligible: boolean
code: CheckInEligibilityCode
distanceMeters: number | null
allowedDistanceMeters: number
message: string
}
export interface CheckInResult {
profile: CheckInProfile
record: CheckInRecord
newBadges: CheckInBadge[]
}
export interface CheckInBadgeDefinition {
code: CheckInBadgeCode
name: string
description: string
icon: string
requiredCheckIns: number
}
+105
View File
@@ -0,0 +1,105 @@
import type { CheckInBadge, CheckInBadgeCode, CheckInProfile, CheckInRecord } from './types'
import {
CHECK_IN_BADGES,
CHECK_IN_POINTS,
cloneCheckInProfile,
createCheckInRecordId,
} from './check-in'
const badgeCodes = new Set<CheckInBadgeCode>(CHECK_IN_BADGES.map(badge => badge.code))
const profileKeys = new Set(['points', 'badges', 'checkIns'])
const recordKeys = new Set(['id', 'poiId', 'poiName', 'checkedInAt', 'pointsAwarded'])
const badgeKeys = new Set(['code', 'obtainedAt'])
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasOnlyKeys(value: Record<string, unknown>, allowedKeys: ReadonlySet<string>): boolean {
return Object.keys(value).every(key => allowedKeys.has(key))
}
function readTimestamp(value: unknown): string | null {
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)))
return null
return new Date(value).toISOString()
}
function isCheckInRecordId(id: string, poiId: string, checkedInAt: string): boolean {
return id === createCheckInRecordId(poiId, checkedInAt)
}
function normalizeCheckInRecord(value: unknown): CheckInRecord | null {
if (!isRecord(value) || !hasOnlyKeys(value, recordKeys))
return null
const id = typeof value.id === 'string' ? value.id.trim() : ''
const poiId = typeof value.poiId === 'string' ? value.poiId.trim() : ''
const poiName = typeof value.poiName === 'string' ? value.poiName.trim() : ''
const checkedInAt = readTimestamp(value.checkedInAt)
if (!id
|| !poiId
|| !poiName
|| !checkedInAt
|| !isCheckInRecordId(id, poiId, checkedInAt)
|| value.pointsAwarded !== CHECK_IN_POINTS) {
return null
}
return { id, poiId, poiName, checkedInAt, pointsAwarded: CHECK_IN_POINTS }
}
function normalizeBadge(value: unknown): CheckInBadge | null {
if (!isRecord(value)
|| !hasOnlyKeys(value, badgeKeys)
|| typeof value.code !== 'string'
|| !badgeCodes.has(value.code as CheckInBadgeCode)) {
return null
}
const obtainedAt = readTimestamp(value.obtainedAt)
return obtainedAt ? { code: value.code as CheckInBadgeCode, obtainedAt } : null
}
export function normalizeCheckInProfile(value: unknown): CheckInProfile | null {
if (!isRecord(value)
|| !hasOnlyKeys(value, profileKeys)
|| !Array.isArray(value.checkIns)
|| !Array.isArray(value.badges)) {
return null
}
const checkIns = value.checkIns.map(normalizeCheckInRecord)
const badges = value.badges.map(normalizeBadge)
if (checkIns.some(record => !record) || badges.some(badge => !badge))
return null
const normalizedCheckIns = checkIns as CheckInRecord[]
const normalizedBadges = badges as CheckInBadge[]
if (new Set(normalizedCheckIns.map(record => record.poiId)).size !== normalizedCheckIns.length
|| new Set(normalizedCheckIns.map(record => record.id)).size !== normalizedCheckIns.length
|| new Set(normalizedBadges.map(badge => badge.code)).size !== normalizedBadges.length) {
return null
}
const expectedBadgeCodes = CHECK_IN_BADGES
.filter(badge => normalizedCheckIns.length >= badge.requiredCheckIns)
.map(badge => badge.code)
const actualBadgeCodes = normalizedBadges.map(badge => badge.code)
if (expectedBadgeCodes.length !== actualBadgeCodes.length
|| expectedBadgeCodes.some(code => !actualBadgeCodes.includes(code))) {
return null
}
const checkInTimes = new Map(normalizedCheckIns.map(record => [record.poiId, record.checkedInAt]))
if (CHECK_IN_BADGES.some((definition) => {
const badge = normalizedBadges.find(item => item.code === definition.code)
const thresholdRecord = normalizedCheckIns[normalizedCheckIns.length - definition.requiredCheckIns]
return badge && (!thresholdRecord || badge.obtainedAt !== checkInTimes.get(thresholdRecord.poiId))
})) {
return null
}
const expectedPoints = normalizedCheckIns.reduce((total, record) => total + record.pointsAwarded, 0)
if (value.points !== expectedPoints)
return null
return cloneCheckInProfile({ points: expectedPoints, badges: normalizedBadges, checkIns: normalizedCheckIns })
}
+2 -5
View File
@@ -18,11 +18,8 @@ export function formatOpeningHours(openingHours: PoiOpeningHours): string {
return openingHours.displayText?.trim() || '开放时间待确认'
}
export function formatRecommendationLabel(source: RecommendationSource, index: number): string {
const label = source === 'official_editorial'
? '官方推荐指数'
: 'POC 推荐指数(待审核)'
return `${label} ${index}/5`
export function formatRecommendationLabel(_source: RecommendationSource, index: number): string {
return `推荐指数 ${index}/5`
}
export function filterPois(pois: readonly Poi[], filter: PoiFilter = {}): Poi[] {
+44 -13
View File
@@ -12,7 +12,13 @@ import type {
// A conservative POC envelope used to catch obviously misplaced coordinates.
// Content review remains the authority for whether a point belongs to Guangming District.
const GUANGMING_POC_BOUNDS = {
/**
* 光明区 POC 的告警范围,用于提示「这个点位可能不在光明区」。
*
* 注意:这是内容审核的告警包络,不是运行时的坐标限制。真实用户可能站在范围之外
* (比如在市区规划行程),不能拿它去拒绝定位结果 —— 那是 isTrustworthyCoordinate 的活。
*/
export const GUANGMING_POC_BOUNDS = {
minLongitude: 113.78,
maxLongitude: 114.02,
minLatitude: 22.70,
@@ -28,6 +34,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* 运行时坐标可信判断,用于点位数据校验与地图/定位状态写入。
*
* 除了有限性和全球范围,还必须拒绝经纬度为 0 的坐标:
* 地图 SDK 尚未拿到定位、或定位接口降级时会回传 (0, 0),
* 它落在几内亚湾(Null Island)。微信 `<map>` 在境外坐标下会切到 HERE 底图,
* 视觉上就是「地图跑到非洲的海里」,还会因为境外瓦片加载失败而留下大片空白。
*/
export function isTrustworthyCoordinate(longitude: unknown, latitude: unknown): boolean {
return typeof longitude === 'number'
&& typeof latitude === 'number'
&& Number.isFinite(longitude)
&& Number.isFinite(latitude)
&& longitude !== 0
&& latitude !== 0
&& longitude >= -180
&& longitude <= 180
&& latitude >= -90
&& latitude <= 90
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
@@ -194,14 +221,7 @@ function validateOpeningHours(openingHours: PoiOpeningHours, poiId: string, issu
function validateCoordinates(poi: Poi, issues: PoiValidationIssue[]): void {
const { longitude, latitude } = poi
const id = isNonEmptyString(poi.id) ? poi.id : 'invalid_poi'
if (!Number.isFinite(longitude)
|| !Number.isFinite(latitude)
|| longitude < -180
|| longitude > 180
|| latitude < -90
|| latitude > 90
|| longitude === 0
|| latitude === 0) {
if (!isTrustworthyCoordinate(longitude, latitude)) {
addIssue(issues, 'COORDINATE_INVALID', `pois.${id}.coordinates`, '经纬度必须为非零的有限合法数值', id)
return
}
@@ -209,12 +229,23 @@ function validateCoordinates(poi: Poi, issues: PoiValidationIssue[]): void {
if (poi.coordinateSystem !== 'GCJ02')
addIssue(issues, 'COORDINATE_SYSTEM_INVALID', `pois.${id}.coordinateSystem`, '本期坐标系必须为 GCJ02', id)
if (longitude < GUANGMING_POC_BOUNDS.minLongitude
|| longitude > GUANGMING_POC_BOUNDS.maxLongitude
|| latitude < GUANGMING_POC_BOUNDS.minLatitude
|| latitude > GUANGMING_POC_BOUNDS.maxLatitude) {
if (!isWithinGuangmingArea(longitude, latitude))
addIssue(issues, 'OUTSIDE_TARGET_AREA', `pois.${id}.coordinates`, '坐标超出光明区 POC 粗校验包络,需内容负责人复核', id)
}
/**
* 坐标是否落在光明区 POC 粗校验包络内。
*
* 这是一个宽松的矩形包络,不是行政边界,也**不该**用来拒绝真实定位 ——
* 用户站在包络外面是完全正常的。它只回答一个问题:把地图移到这个坐标,
* 画面里还看得见本期点位吗?用途有两个:数据入库时的内容审核告警,
* 以及开屏定位时判断该不该把视野从聚类中心移到用户身上。
*/
export function isWithinGuangmingArea(longitude: number, latitude: number): boolean {
return longitude >= GUANGMING_POC_BOUNDS.minLongitude
&& longitude <= GUANGMING_POC_BOUNDS.maxLongitude
&& latitude >= GUANGMING_POC_BOUNDS.minLatitude
&& latitude <= GUANGMING_POC_BOUNDS.maxLatitude
}
function calculateDistanceMeters(left: Poi, right: Poi): number {
+47 -3
View File
@@ -228,6 +228,31 @@ function rankedCandidates(
})
}
function canCompleteMinimumRoute(
candidates: readonly PoiResolved[],
preferences: TravelPreferences,
cursor: PlanningOrigin | PoiResolved | null,
remainingMinutes: number,
stopsNeeded: number,
): boolean {
if (stopsNeeded <= 0)
return true
return candidates.some((poi, index) => {
const requiredMinutes = (cursor ? transferMinutes(cursor, poi, preferences) : 0)
+ activityMinutes(poi, preferences.pace)
if (requiredMinutes > remainingMinutes)
return false
return canCompleteMinimumRoute(
[...candidates.slice(0, index), ...candidates.slice(index + 1)],
preferences,
poi,
remainingMinutes - requiredMinutes,
stopsNeeded - 1,
)
})
}
function selectQuickPois(
preferences: TravelPreferences,
repository: PoiRepository,
@@ -238,13 +263,28 @@ function selectQuickPois(
const selected: PoiResolved[] = []
let usedMinutes = 0
let cursor: PlanningOrigin | PoiResolved | null = origin
const minimumTargetCount = Math.min(remaining.length, requestedMinutes >= 240 ? 3 : 1)
while (remaining.length > 0 && selected.length < 8) {
const ranked = rankedCandidates(remaining, preferences, selected, cursor)
const next = ranked.find((poi) => {
const fitting = ranked.filter((poi) => {
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
return usedMinutes + transfer + activityMinutes(poi, preferences.pace) <= requestedMinutes
})
const next = fitting.find((poi) => {
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
const requiredMinutes = transfer + activityMinutes(poi, preferences.pace)
const stopsNeeded = minimumTargetCount - selected.length - 1
if (stopsNeeded <= 0)
return true
return canCompleteMinimumRoute(
remaining.filter(candidate => candidate.id !== poi.id),
preferences,
poi,
requestedMinutes - usedMinutes - requiredMinutes,
stopsNeeded,
)
}) ?? fitting[0]
if (!next)
break
const transfer = cursor ? transferMinutes(cursor, next, preferences) : 0
@@ -254,8 +294,12 @@ function selectQuickPois(
cursor = next
}
if (selected.length === 0 && remaining.length > 0)
selected.push(rankedCandidates(remaining, preferences, [], origin)[0])
if (selected.length === 0 && remaining.length > 0) {
throw new TravelValidationError(
'当前起点、交通方式与所选时长无法容纳首个点位',
'quick_route_unreachable',
)
}
return selected
}
+3 -1
View File
@@ -16,8 +16,10 @@ import {
const DESTINATION = '深圳市光明区' as const
export type TravelValidationErrorCode = 'quick_route_unreachable'
export class TravelValidationError extends Error {
constructor(message: string) {
constructor(message: string, readonly code?: TravelValidationErrorCode) {
super(message)
this.name = 'TravelValidationError'
}
+7 -6
View File
@@ -1,9 +1,9 @@
{
"name": "光明文旅地图",
"appid": "",
"description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC",
"versionName": "0.2.0",
"versionCode": "20",
"description": "深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC",
"versionName": "0.3.0",
"versionCode": "30",
"transformPx": false,
"app-plus": {
"usingComponents": true,
@@ -46,14 +46,15 @@
},
"quickapp": {},
"mp-weixin": {
"appid": "wx946b42048d117dde",
"appid": "wxda4fb5048b5dc9a1",
"setting": {
"urlCheck": true
},
"usingComponents": true,
"lazyCodeLoading": "requiredComponents",
"permission": {
"scope.userLocation": {
"desc": "用于在全域地图显示实时位置,并优化文旅行程顺序"
"desc": "用于地图定位、行程优化和到点打卡判断"
}
},
"requiredPrivateInfos": [
@@ -61,7 +62,7 @@
"startLocationUpdate",
"onLocationChange"
],
"projectname": "【prod】光明文旅地图"
"projectname": "【dev】光明文旅地图"
},
"mp-alipay": {
"usingComponents": true
+414
View File
@@ -0,0 +1,414 @@
<script setup lang="ts">
import type { PoiImageAsset, PoiResolved } from '@/domain/poi'
import PageState from '@/components/poi/PageState.vue'
import PoiHeroGallery from '@/components/poi/PoiHeroGallery.vue'
import PoiTagList from '@/components/poi/PoiTagList.vue'
import { getPoiRepository } from '@/data/poi'
import { coverPhotoFullUrlFor } from '@/data/poi/photos'
import { formatRecommendationLabel } from '@/domain/poi'
import { getCheckInProfile } from '@/services/check-in'
const state = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
const poi = ref<PoiResolved | null>(null)
const hasCheckedIn = ref(false)
const checkInStorageError = ref(false)
/**
* hero 用分包大图(800×600)替换主包缩略图(400×300)。
*
* 本页在 pages-poi 分包内,能引用分包 static 资源;主包页面不行。
* 占位图仍指向主包,不做替换。
*/
const heroImages = computed<PoiImageAsset[]>(() => {
const current = poi.value
if (!current)
return []
return current.images.map(image => (
image.id === current.coverImageId && !image.isPlaceholder
? { ...image, url: coverPhotoFullUrlFor(current.id) }
: image
))
})
function loadPoi(poiId: string) {
state.value = 'loading'
poi.value = null
try {
if (!poiId.trim()) {
state.value = 'invalid'
return
}
const result = getPoiRepository().getPoiById(poiId)
if (!result) {
state.value = 'invalid'
return
}
poi.value = result
try {
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === result.id)
checkInStorageError.value = false
}
catch (error) {
console.error('Failed to load POI check-in state', error)
hasCheckedIn.value = false
checkInStorageError.value = true
}
state.value = 'ready'
}
catch (error) {
console.error('Failed to load POI detail', error)
state.value = 'error'
}
}
function openCheckIn() {
if (!poi.value)
return
uni.navigateTo({ url: `/pages/check-in/index?poiId=${encodeURIComponent(poi.value.id)}` })
}
function openCheckInRecords() {
uni.navigateTo({ url: '/pages/check-in/records' })
}
function returnToMap() {
uni.navigateBack({
fail: () => {
uni.reLaunch({ url: '/pages/map/index' })
},
})
}
function formatCoordinate(value: number): string {
return value.toFixed(6)
}
function formatUpdatedAt(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime()))
return '待确认'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
onLoad((query) => {
const rawPoiId = typeof query?.poiId === 'string' ? query.poiId : ''
try {
loadPoi(decodeURIComponent(rawPoiId))
}
catch {
state.value = 'invalid'
}
})
onShow(() => {
if (!poi.value)
return
try {
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === poi.value?.id)
checkInStorageError.value = false
}
catch (error) {
console.error('Failed to refresh POI check-in state', error)
hasCheckedIn.value = false
checkInStorageError.value = true
}
})
</script>
<template>
<view class="poi-detail-page">
<PageState v-if="state === 'loading'" state="loading" title="正在加载点位信息…" />
<PageState
v-else-if="state === 'invalid'"
state="empty"
title="点位不存在或已下线"
description="该点位可能已更新,请返回地图重新选择"
action-label="返回地图"
@action="returnToMap"
/>
<PageState
v-else-if="state === 'error'"
state="error"
title="点位信息加载失败"
description="暂时无法读取该点位,请稍后返回重试"
action-label="返回地图"
@action="returnToMap"
/>
<template v-else-if="poi">
<PoiHeroGallery :images="heroImages" :name="poi.name" />
<view class="poi-detail-page__content">
<view class="poi-detail-page__header">
<text class="poi-detail-page__category">{{ poi.category.name }}</text>
<text class="poi-detail-page__name">{{ poi.name }}</text>
<view class="poi-detail-page__recommendation">
{{ formatRecommendationLabel(poi.recommendationSource, poi.recommendationIndex) }}
</view>
<PoiTagList class="poi-detail-page__tags" :tags="poi.tags" />
</view>
<view class="check-in-entry" :class="{ 'check-in-entry--done': hasCheckedIn }">
<view class="check-in-entry__mark">{{ hasCheckedIn ? '✓' : '◎' }}</view>
<view class="check-in-entry__copy">
<text class="check-in-entry__title">{{ checkInStorageError ? '打卡数据需要处理' : hasCheckedIn ? '已留下光明足迹' : '到点打卡' }}</text>
<text>{{ checkInStorageError ? '本机打卡数据异常,请先处理' : hasCheckedIn ? '该点位已在本机完成打卡' : '到达点位附近后,使用本次真实定位打卡' }}</text>
</view>
<button @click="checkInStorageError ? openCheckInRecords() : openCheckIn()">{{ checkInStorageError ? '去处理' : hasCheckedIn ? '查看' : '去打卡' }}</button>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-row__label">开放时间</text>
<text class="info-row__value">{{ poi.openingHoursText }}</text>
</view>
<view class="info-row">
<text class="info-row__label">地址</text>
<text class="info-row__value">{{ poi.address }}</text>
</view>
<view class="info-row info-row--coordinates">
<text class="info-row__label">经纬度</text>
<view class="info-row__value info-row__coordinates">
<text>经度 {{ formatCoordinate(poi.longitude) }}</text>
<text>纬度 {{ formatCoordinate(poi.latitude) }}</text>
<text class="info-row__coordinate-system">坐标系 {{ poi.coordinateSystem }}</text>
</view>
</view>
<view class="info-row">
<text class="info-row__label">数据更新</text>
<text class="info-row__value">{{ formatUpdatedAt(poi.updatedAt) }}</text>
</view>
</view>
<view class="description-section">
<text class="description-section__title">点位简介</text>
<text class="description-section__body">{{ poi.description }}</text>
</view>
<view class="poc-notice">
<text class="poc-notice__title">POC 数据说明</text>
<text class="poc-notice__body">当前内容用于产品和技术可行性验证正式开放信息请以后续审核版本为准</text>
</view>
</view>
</template>
</view>
</template>
<route lang="yaml">
layout: map
style:
navigationBarTitleText: 点位详情
navigationBarBackgroundColor: '#ffffff'
navigationBarTextStyle: black
backgroundColor: '#f5f7f6'
</route>
<style scoped lang="scss">
.poi-detail-page {
min-height: 100vh;
background: #f5f7f6;
}
.poi-detail-page__content {
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.poi-detail-page__header {
display: flex;
flex-direction: column;
}
.poi-detail-page__category {
align-self: flex-start;
padding: 8rpx 18rpx;
font-size: 24rpx;
font-weight: 600;
line-height: 34rpx;
color: #0e6247;
background: #e8f4ee;
border-radius: 24rpx;
}
.poi-detail-page__name {
margin-top: 16rpx;
font-size: 40rpx;
font-weight: 700;
line-height: 56rpx;
color: #18201d;
}
.poi-detail-page__recommendation {
align-self: flex-start;
padding: 8rpx 16rpx;
margin-top: 16rpx;
font-size: 26rpx;
font-weight: 600;
line-height: 38rpx;
color: #934b00;
background: #fff3e0;
border-radius: 10rpx;
}
.poi-detail-page__tags {
margin-top: 18rpx;
}
.info-card,
.check-in-entry,
.description-section,
.poc-notice {
padding: 28rpx;
margin-top: 24rpx;
background: #fff;
border: 1rpx solid #dde5e1;
border-radius: 20rpx;
}
.check-in-entry {
display: flex;
gap: 18rpx;
align-items: center;
padding: 24rpx;
}
.check-in-entry--done {
background: #e8f4ee;
border-color: #cfe4d9;
}
.check-in-entry__mark {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 62rpx;
height: 62rpx;
font-size: 28rpx;
font-weight: 800;
color: #fff;
background: #167a5b;
border-radius: 50%;
}
.check-in-entry__copy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
font-size: 21rpx;
line-height: 32rpx;
color: #5e6b65;
}
.check-in-entry__title {
margin-bottom: 3rpx;
font-size: 27rpx;
font-weight: 750;
color: #18201d;
}
.check-in-entry button {
flex: none;
width: auto;
height: 60rpx;
padding: 0 22rpx;
margin: 0;
font-size: 23rpx;
font-weight: 700;
line-height: 60rpx;
color: #fff;
background: #167a5b;
border: 0;
border-radius: 30rpx;
}
.check-in-entry button::after {
border: 0;
}
.info-row {
display: flex;
gap: 24rpx;
align-items: flex-start;
padding: 20rpx 0;
border-bottom: 1rpx solid #edf1ef;
}
.info-row:first-child {
padding-top: 0;
}
.info-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.info-row__label {
flex: none;
width: 128rpx;
font-size: 26rpx;
line-height: 40rpx;
color: #5e6b65;
}
.info-row__value {
flex: 1;
font-size: 28rpx;
line-height: 40rpx;
color: #18201d;
}
.info-row__coordinates {
display: flex;
flex-direction: column;
min-width: 0;
}
.info-row__coordinate-system {
margin-top: 6rpx;
font-size: 24rpx;
color: #8a9690;
}
.description-section__title,
.poc-notice__title {
display: block;
font-size: 30rpx;
font-weight: 700;
line-height: 42rpx;
color: #18201d;
}
.description-section__body,
.poc-notice__body {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
line-height: 44rpx;
color: #425049;
white-space: pre-wrap;
}
.poc-notice {
background: #e8f4ee;
border-color: #cfe4d9;
}
.poc-notice__title {
font-size: 26rpx;
color: #0e6247;
}
.poc-notice__body {
font-size: 24rpx;
line-height: 38rpx;
color: #3d5a4d;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Some files were not shown because too many files have changed in this diff Show More