forked from zhouruizhe/gmTouringMiniApp
Compare commits
7
Commits
14ca296d7d
...
09f8ec7ecf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09f8ec7ecf | ||
|
|
12380cd8cf | ||
|
|
73664ca974 | ||
|
|
5511c85f58 | ||
|
|
3ef7266591 | ||
|
|
2844ecd5bb | ||
|
|
7deba0dd37 |
@@ -1,10 +1,8 @@
|
|||||||
# 光明文旅地图 POC
|
# 光明文旅地图 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 在审核白名单内推荐点位与顺序。当前不是道路级实时导航,也不包含打卡、登录或个人中心。
|
当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、文旅偏好收集、推荐行程展示和到点打卡。路线规划支持基于当前位置的快速规划,以及用户自选 2–8 个 POI、2–10 小时时长的自定义规划;当前交付统一使用本地规划器按时间预算动态排程。打卡使用用户每次主动触发的位置请求结果核验到点范围,并在本机记录积分、徽章和足迹;当前不是道路级实时导航,也不包含登录或云端个人中心。
|
||||||
|
|
||||||
## Todo:还需要再次合并打卡功能至main branch.
|
|
||||||
|
|
||||||
## 微信开发者工具
|
## 微信开发者工具
|
||||||
|
|
||||||
@@ -33,9 +31,9 @@ macOS 已安装微信开发者工具时,也可在另一个终端执行 `corepa
|
|||||||
corepack pnpm build:mp-weixin
|
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`。
|
生产验收时可直接导入 `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
|
```text
|
||||||
src/
|
src/
|
||||||
├── pages/map/index.vue # 小程序首页:全域地图
|
├── pages/map/index.vue # 小程序首页:全域地图
|
||||||
├── pages/assistant/index.vue # AI 文旅助手:偏好快捷入口
|
├── pages/assistant/index.vue # 文旅助手:偏好快捷入口
|
||||||
├── pages/planner/index.vue # 行程偏好表单
|
├── pages/planner/index.vue # 行程偏好表单
|
||||||
├── pages/itinerary/index.vue # 推荐路线与地图展示
|
├── pages/itinerary/index.vue # 推荐路线与地图展示
|
||||||
├── pages/poi/detail.vue # POI 详情
|
├── pages/poi/detail.vue # POI 详情
|
||||||
|
├── pages/check-in/ # 真实定位打卡、积分、徽章与足迹
|
||||||
├── components/map/ # 地图筛选和摘要组件
|
├── components/map/ # 地图筛选和摘要组件
|
||||||
├── components/poi/ # POI 图片、标签、状态和图集组件
|
├── components/poi/ # POI 图片、标签、状态和图集组件
|
||||||
├── components/travel/ # 行程点位展示组件
|
├── components/travel/ # 行程点位展示组件
|
||||||
@@ -60,9 +59,19 @@ src/
|
|||||||
└── static/ # 随包静态资源
|
└── 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 规划”是确定性排程,不代表大模型生成,旧版本遗留的远程路线缓存也不会在本期恢复。
|
||||||
|
|
||||||
|
仓库保留了可选的 `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`。
|
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。
|
||||||
|
|
||||||
|
|||||||
+14
-9
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
## 1. 当前交付边界
|
## 1. 当前交付边界
|
||||||
|
|
||||||
- 本项目是光明区文旅全域地图、POI 数字化展示、AI 文旅助手与行程规划 POC。
|
- 本项目是光明区文旅全域地图、POI 数字化展示、本地行程规划与真实定位打卡 POC;AI 模型接入留待后续版本。
|
||||||
- 目标端为微信小程序,当前只要求在一台实际 iOS 设备验证。
|
- 目标端为微信小程序,当前只要求在一台实际 iOS 设备验证。
|
||||||
- 用户主动授权后,地图可显示前台实时位置;行程会用本次位置快照优化首站和游览顺序。
|
- 用户主动授权后,地图可显示前台实时位置;当前位置快速规划要求使用 5 分钟内的位置快照优化首站和游览顺序。
|
||||||
- 行程由随包 POI 和确定性规则生成,用于验证推荐链路,不提供道路级实时路况、路径导航或到达承诺。
|
- 行程由随包 POI 和确定性规则生成,用于验证推荐链路,不提供道路级实时路况、路径导航或到达承诺。
|
||||||
- 不包含打卡、登录或个人中心;匿名偏好和最近行程保存在本机,精确位置只保留在内存会话中。
|
- 不包含登录或云端个人中心;匿名偏好、最近行程、打卡积分、徽章与足迹保存在本机,精确位置只用于内存中的本次判定。
|
||||||
- 当前随包数据明确标注为 `POC 审核样本`,不能宣称已覆盖光明区全部文旅资源。
|
- 当前随包数据明确标注为 `POC 审核样本`,不能宣称已覆盖光明区全部文旅资源。
|
||||||
|
|
||||||
## 2. 地图和高德能力说明
|
## 2. 地图和高德能力说明
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
|
|
||||||
1. 复制 `.env.example` 为 `.env.local`。
|
1. 复制 `.env.example` 为 `.env.local`。
|
||||||
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。
|
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。
|
||||||
3. 真机预览或上传前,将根目录 `project.config.json` 的 `appid` 从 `touristappid` 同步为相同的真实 AppID。
|
3. 真机预览或上传前,确认根目录 `project.config.json` 的 `appid` 与相同的真实 AppID 一致。
|
||||||
4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。服务端必须设置 `LLM_MODE=real` 才会作为真实 AI 展示;mock 仅用于联调演示。
|
4. 本期 `createPlan` 已固定使用本地 POC 规划器,`server/` 及远程适配层仅作为后续 AI 接入预留,不纳入本期验收。
|
||||||
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
|
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
|
||||||
|
|
||||||
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
|
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
|
||||||
@@ -48,7 +48,9 @@ corepack pnpm build:mp-weixin
|
|||||||
|
|
||||||
项目的微信开发与生产脚本默认对文件监听使用轮询,用于规避部分 macOS 会话中 `EMFILE: too many open files, watch` 问题。这不改变构建产物,但开发模式的本机资源占用会略有增加。
|
项目的微信开发与生产脚本默认对文件监听使用轮询,用于规避部分 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` 指向该目录。先运行开发编译,再用微信开发者工具直接导入项目根目录。
|
开发模式产物位于 `dist/dev/mp-weixin`;根目录 `project.config.json` 已将微信开发者工具的 `miniprogramRoot` 和 `srcMiniprogramRoot` 指向该目录。先运行开发编译,再用微信开发者工具直接导入项目根目录。
|
||||||
|
|
||||||
@@ -78,9 +80,12 @@ macOS 可在另一个终端执行 `corepack pnpm ide:mp-weixin` 调起已安装
|
|||||||
5. 返回地图后恢复分类、中心、缩放和选中点位。
|
5. 返回地图后恢复分类、中心、缩放和选中点位。
|
||||||
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
|
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
|
||||||
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
|
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
|
||||||
8. 从“AI 助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。
|
8. 从“文旅助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。
|
||||||
9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。
|
9. 关闭网络后验证本地规划仍可生成;确认当前业务入口不调用远程 AI,客户端产物不含模型凭证。
|
||||||
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
|
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
|
||||||
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。
|
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储不包含精确坐标,且调试面板没有远程规划请求。
|
||||||
|
12. 确认全部已发布 POI 的详情页均展示打卡入口,未发布或已下线 POI 不开放新打卡;从详情进入打卡页后,仅在点击按钮时重新请求一次位置,分别验证隐私授权、200 米范围内成功、范围外、精度不足、边界不确定和重复打卡。
|
||||||
|
13. 检查首次打卡增加 10 积分并解锁首枚徽章,重启后记录仍存在;“清除本机打卡数据”不得删除路线或偏好。
|
||||||
|
14. 扫描本机打卡存储,确认不包含经纬度、定位精度或轨迹;开发者工具模拟位置仅用于流程测试,正式成功验收必须使用 iOS 真机现场定位。
|
||||||
|
|
||||||
在项目方尚未提供正式 AppID、真实清权图片和冻结基准清单时,只能判定“样本链路通过”,不能判定正式内容或全域覆盖通过。
|
在项目方尚未提供正式 AppID、真实清权图片和冻结基准清单时,只能判定“样本链路通过”,不能判定正式内容或全域覆盖通过。
|
||||||
|
|||||||
+5
-4
@@ -10,9 +10,9 @@ const weixinAppId = manifestEnv.UNI_MP_WEIXIN_APPID || 'touristappid'
|
|||||||
export default defineManifestConfig({
|
export default defineManifestConfig({
|
||||||
'name': '光明文旅地图',
|
'name': '光明文旅地图',
|
||||||
'appid': '',
|
'appid': '',
|
||||||
'description': '深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC',
|
'description': '深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC',
|
||||||
'versionName': '0.2.0',
|
'versionName': '0.3.0',
|
||||||
'versionCode': '20',
|
'versionCode': '30',
|
||||||
'transformPx': false,
|
'transformPx': false,
|
||||||
'uniStatistics': {
|
'uniStatistics': {
|
||||||
enable: false,
|
enable: false,
|
||||||
@@ -69,9 +69,10 @@ export default defineManifestConfig({
|
|||||||
'mp-weixin': {
|
'mp-weixin': {
|
||||||
// Configure locally with UNI_MP_WEIXIN_APPID; do not commit project credentials.
|
// Configure locally with UNI_MP_WEIXIN_APPID; do not commit project credentials.
|
||||||
appid: weixinAppId,
|
appid: weixinAppId,
|
||||||
|
lazyCodeLoading: 'requiredComponents',
|
||||||
permission: {
|
permission: {
|
||||||
'scope.userLocation': {
|
'scope.userLocation': {
|
||||||
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
|
desc: '用于地图定位、行程优化和到点打卡判断',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
||||||
|
|||||||
+4
-2
@@ -5,10 +5,12 @@ export default defineUniPages({
|
|||||||
// uni-app compiler drops their manifest equivalents.
|
// uni-app compiler drops their manifest equivalents.
|
||||||
permission: {
|
permission: {
|
||||||
'scope.userLocation': {
|
'scope.userLocation': {
|
||||||
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
|
desc: '用于地图定位、行程优化和到点打卡判断',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
||||||
|
// 微信小程序仅在页面实际使用组件时注入对应代码,减少启动开销。
|
||||||
|
lazyCodeLoading: 'requiredComponents',
|
||||||
easycom: {
|
easycom: {
|
||||||
// 注意一定要放在custom里,否则无效,https://ask.dcloud.net.cn/question/131175
|
// 注意一定要放在custom里,否则无效,https://ask.dcloud.net.cn/question/131175
|
||||||
custom: {
|
custom: {
|
||||||
@@ -43,7 +45,7 @@ export default defineUniPages({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
pagePath: 'pages/assistant/index',
|
pagePath: 'pages/assistant/index',
|
||||||
text: 'AI 助手',
|
text: '文旅助手',
|
||||||
iconPath: 'static/tabbar/assistant.png',
|
iconPath: 'static/tabbar/assistant.png',
|
||||||
selectedIconPath: 'static/tabbar/assistant-selected.png',
|
selectedIconPath: 'static/tabbar/assistant-selected.png',
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"miniprogramRoot": "dist/dev/mp-weixin/",
|
"miniprogramRoot": "dist/dev/mp-weixin/",
|
||||||
"projectname": "光明文旅地图",
|
"projectname": "光明文旅地图",
|
||||||
"description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC",
|
"description": "深圳市光明区全域文旅地图、POI 数字化展示与本地行程规划 POC",
|
||||||
"setting": {
|
"setting": {
|
||||||
"es6": true,
|
"es6": true,
|
||||||
"postcss": true,
|
"postcss": true,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const expectedPages = [
|
|||||||
'pages/itinerary/index',
|
'pages/itinerary/index',
|
||||||
'pages/planner/index',
|
'pages/planner/index',
|
||||||
'pages/poi/detail',
|
'pages/poi/detail',
|
||||||
|
'pages/check-in/index',
|
||||||
|
'pages/check-in/records',
|
||||||
]
|
]
|
||||||
const expectedTabPages = [
|
const expectedTabPages = [
|
||||||
'pages/map/index',
|
'pages/map/index',
|
||||||
@@ -42,6 +44,29 @@ if (missingFiles.length > 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appConfig = JSON.parse(readFileSync(resolve(outputRoot, 'app.json'), 'utf8'))
|
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, 'pages/poi/detail.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 expectedPrivateInfos = ['getLocation', 'startLocationUpdate', 'onLocationChange']
|
||||||
const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? []
|
const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? []
|
||||||
if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) {
|
if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) {
|
||||||
@@ -55,6 +80,10 @@ if (locationPermissionDescriptionLength < 1 || locationPermissionDescriptionLeng
|
|||||||
console.error('app.json 缺少 scope.userLocation 用途说明。')
|
console.error('app.json 缺少 scope.userLocation 用途说明。')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
if (!locationPermissionDescription.includes('打卡')) {
|
||||||
|
console.error('scope.userLocation 用途说明未覆盖到点打卡。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
const missingPages = expectedPages.filter(page => !appConfig.pages?.includes(page))
|
const missingPages = expectedPages.filter(page => !appConfig.pages?.includes(page))
|
||||||
if (missingPages.length > 0) {
|
if (missingPages.length > 0) {
|
||||||
|
|||||||
+3
-7
@@ -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.
|
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.
|
||||||
|
|
||||||
|
This service is reserved for a later AI integration phase. The current mini-program `createPlan` entry point is hard-wired to the local planner and does not call this service.
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
`POST /api/v1/plans` accepts a planning request and a coordinate-free POI whitelist:
|
`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": {
|
"preferences": {
|
||||||
"destination": "深圳市光明区",
|
"destination": "深圳市光明区",
|
||||||
"themes": ["亲子"],
|
"themes": ["亲子"],
|
||||||
"duration": "half_day",
|
|
||||||
"pace": "moderate",
|
"pace": "moderate",
|
||||||
"interests": ["自然风光"],
|
"interests": ["自然风光"],
|
||||||
"adults": 2,
|
"transport": "public_transport"
|
||||||
"children": 1,
|
|
||||||
"childAges": [6],
|
|
||||||
"transport": "public_transport",
|
|
||||||
"budgetLevel": "standard",
|
|
||||||
"extraRequirements": ""
|
|
||||||
},
|
},
|
||||||
"durationMinutes": 240,
|
"durationMinutes": 240,
|
||||||
"selectedPoiIds": ["poi_a", "poi_b"],
|
"selectedPoiIds": ["poi_a", "poi_b"],
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ def _score(candidate: PoiCandidate, request: PlanRecommendationRequest) -> int:
|
|||||||
for interest in request.preferences.interests:
|
for interest in request.preferences.interests:
|
||||||
desired_tags.update(INTEREST_TAGS.get(interest, set()))
|
desired_tags.update(INTEREST_TAGS.get(interest, set()))
|
||||||
score += len(desired_tags.intersection(candidate.tag_codes)) * 8
|
score += len(desired_tags.intersection(candidate.tag_codes)) * 8
|
||||||
if request.preferences.children and "family_friendly" in candidate.tag_codes:
|
|
||||||
score += 8
|
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,24 +55,12 @@ Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "
|
|||||||
class TravelPreferences(APIModel):
|
class TravelPreferences(APIModel):
|
||||||
destination: Literal["深圳市光明区"] = "深圳市光明区"
|
destination: Literal["深圳市光明区"] = "深圳市光明区"
|
||||||
themes: list[Theme] = Field(default_factory=list, max_length=5)
|
themes: list[Theme] = Field(default_factory=list, max_length=5)
|
||||||
duration: Duration
|
|
||||||
pace: Pace
|
pace: Pace
|
||||||
interests: list[Interest] = Field(default_factory=list, max_length=5)
|
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
|
transport: Transport = Transport.PUBLIC_TRANSPORT
|
||||||
budget_level: BudgetLevel = BudgetLevel.STANDARD
|
|
||||||
extra_requirements: str = Field(default="", max_length=500)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_group(self) -> "TravelPreferences":
|
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):
|
if len(set(self.themes)) != len(self.themes):
|
||||||
raise ValueError("出行主题不能重复")
|
raise ValueError("出行主题不能重复")
|
||||||
if len(set(self.interests)) != len(self.interests):
|
if len(set(self.interests)) != len(self.interests):
|
||||||
|
|||||||
@@ -16,15 +16,9 @@ def preferences():
|
|||||||
return {
|
return {
|
||||||
"destination": "深圳市光明区",
|
"destination": "深圳市光明区",
|
||||||
"themes": ["亲子"],
|
"themes": ["亲子"],
|
||||||
"duration": "half_day",
|
|
||||||
"pace": "moderate",
|
"pace": "moderate",
|
||||||
"interests": ["自然风光"],
|
"interests": ["自然风光"],
|
||||||
"adults": 2,
|
|
||||||
"children": 1,
|
|
||||||
"childAges": [6],
|
|
||||||
"transport": "public_transport",
|
"transport": "public_transport",
|
||||||
"budgetLevel": "standard",
|
|
||||||
"extraRequirements": "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,9 @@ def custom_request() -> PlanRecommendationRequest:
|
|||||||
"preferences": {
|
"preferences": {
|
||||||
"destination": "深圳市光明区",
|
"destination": "深圳市光明区",
|
||||||
"themes": ["朋友"],
|
"themes": ["朋友"],
|
||||||
"duration": "half_day",
|
|
||||||
"pace": "moderate",
|
"pace": "moderate",
|
||||||
"interests": ["自然风光"],
|
"interests": ["自然风光"],
|
||||||
"adults": 2,
|
|
||||||
"children": 0,
|
|
||||||
"childAges": [],
|
|
||||||
"transport": "public_transport",
|
"transport": "public_transport",
|
||||||
"budgetLevel": "standard",
|
|
||||||
"extraRequirements": "",
|
|
||||||
},
|
},
|
||||||
"durationMinutes": 120,
|
"durationMinutes": 120,
|
||||||
"selectedPoiIds": ["poi_a", "poi_b", "poi_c"],
|
"selectedPoiIds": ["poi_a", "poi_b", "poi_c"],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
checkIns: []
|
||||||
select: [categoryCode: string | null]
|
select: [categoryCode: string | null]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@@ -21,7 +22,10 @@ const emit = defineEmits<{
|
|||||||
<text class="map-filter-header__title">光明区文旅资源</text>
|
<text class="map-filter-header__title">光明区文旅资源</text>
|
||||||
<text class="map-filter-header__scope">{{ coverageLabel }}</text>
|
<text class="map-filter-header__scope">{{ coverageLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="map-filter-header__count">共 {{ resultCount }} 个点位</text>
|
<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>
|
</view>
|
||||||
|
|
||||||
<scroll-view class="map-filter-header__scroll" scroll-x :show-scrollbar="false">
|
<scroll-view class="map-filter-header__scroll" scroll-x :show-scrollbar="false">
|
||||||
@@ -91,11 +95,36 @@ const emit = defineEmits<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map-filter-header__count {
|
.map-filter-header__count {
|
||||||
flex: none;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #167a5b;
|
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 {
|
.map-filter-header__scroll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './tasks'
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 })) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './check-in'
|
||||||
|
export * from './types'
|
||||||
|
export * from './validation'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -18,11 +18,8 @@ export function formatOpeningHours(openingHours: PoiOpeningHours): string {
|
|||||||
return openingHours.displayText?.trim() || '开放时间待确认'
|
return openingHours.displayText?.trim() || '开放时间待确认'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRecommendationLabel(source: RecommendationSource, index: number): string {
|
export function formatRecommendationLabel(_source: RecommendationSource, index: number): string {
|
||||||
const label = source === 'official_editorial'
|
return `推荐指数 ${index}/5`
|
||||||
? '官方推荐指数'
|
|
||||||
: 'POC 推荐指数(待审核)'
|
|
||||||
return `${label} ${index}/5`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterPois(pois: readonly Poi[], filter: PoiFilter = {}): Poi[] {
|
export function filterPois(pois: readonly Poi[], filter: PoiFilter = {}): Poi[] {
|
||||||
|
|||||||
@@ -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(
|
function selectQuickPois(
|
||||||
preferences: TravelPreferences,
|
preferences: TravelPreferences,
|
||||||
repository: PoiRepository,
|
repository: PoiRepository,
|
||||||
@@ -238,13 +263,28 @@ function selectQuickPois(
|
|||||||
const selected: PoiResolved[] = []
|
const selected: PoiResolved[] = []
|
||||||
let usedMinutes = 0
|
let usedMinutes = 0
|
||||||
let cursor: PlanningOrigin | PoiResolved | null = origin
|
let cursor: PlanningOrigin | PoiResolved | null = origin
|
||||||
|
const minimumTargetCount = Math.min(remaining.length, requestedMinutes >= 240 ? 3 : 1)
|
||||||
|
|
||||||
while (remaining.length > 0 && selected.length < 8) {
|
while (remaining.length > 0 && selected.length < 8) {
|
||||||
const ranked = rankedCandidates(remaining, preferences, selected, cursor)
|
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
|
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
|
||||||
return usedMinutes + transfer + activityMinutes(poi, preferences.pace) <= requestedMinutes
|
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)
|
if (!next)
|
||||||
break
|
break
|
||||||
const transfer = cursor ? transferMinutes(cursor, next, preferences) : 0
|
const transfer = cursor ? transferMinutes(cursor, next, preferences) : 0
|
||||||
@@ -254,9 +294,6 @@ function selectQuickPois(
|
|||||||
cursor = next
|
cursor = next
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.length === 0 && remaining.length > 0)
|
|
||||||
selected.push(rankedCandidates(remaining, preferences, [], origin)[0])
|
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "光明文旅地图",
|
"name": "光明文旅地图",
|
||||||
"appid": "",
|
"appid": "",
|
||||||
"description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC",
|
"description": "深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC",
|
||||||
"versionName": "0.2.0",
|
"versionName": "0.3.0",
|
||||||
"versionCode": "20",
|
"versionCode": "30",
|
||||||
"transformPx": false,
|
"transformPx": false,
|
||||||
"app-plus": {
|
"app-plus": {
|
||||||
"usingComponents": true,
|
"usingComponents": true,
|
||||||
@@ -51,9 +51,10 @@
|
|||||||
"urlCheck": true
|
"urlCheck": true
|
||||||
},
|
},
|
||||||
"usingComponents": true,
|
"usingComponents": true,
|
||||||
|
"lazyCodeLoading": "requiredComponents",
|
||||||
"permission": {
|
"permission": {
|
||||||
"scope.userLocation": {
|
"scope.userLocation": {
|
||||||
"desc": "用于在全域地图显示实时位置,并优化文旅行程顺序"
|
"desc": "用于地图定位、行程优化和到点打卡判断"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requiredPrivateInfos": [
|
"requiredPrivateInfos": [
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
"startLocationUpdate",
|
"startLocationUpdate",
|
||||||
"onLocationChange"
|
"onLocationChange"
|
||||||
],
|
],
|
||||||
"projectname": "【prod】光明文旅地图"
|
"projectname": "【dev】光明文旅地图"
|
||||||
},
|
},
|
||||||
"mp-alipay": {
|
"mp-alipay": {
|
||||||
"usingComponents": true
|
"usingComponents": true
|
||||||
|
|||||||
+26
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"permission": {
|
"permission": {
|
||||||
"scope.userLocation": {
|
"scope.userLocation": {
|
||||||
"desc": "用于在全域地图显示实时位置,并优化文旅行程顺序"
|
"desc": "用于地图定位、行程优化和到点打卡判断"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requiredPrivateInfos": [
|
"requiredPrivateInfos": [
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"startLocationUpdate",
|
"startLocationUpdate",
|
||||||
"onLocationChange"
|
"onLocationChange"
|
||||||
],
|
],
|
||||||
|
"lazyCodeLoading": "requiredComponents",
|
||||||
"easycom": {
|
"easycom": {
|
||||||
"custom": {
|
"custom": {
|
||||||
"^u--(.*)": "uview-plus/components/u-$1/u-$1.vue",
|
"^u--(.*)": "uview-plus/components/u-$1/u-$1.vue",
|
||||||
@@ -33,7 +34,29 @@
|
|||||||
"type": "page",
|
"type": "page",
|
||||||
"layout": "map",
|
"layout": "map",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "AI 文旅助手",
|
"navigationBarTitleText": "文旅助手",
|
||||||
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#f4f8f6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/check-in/index",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "map",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "点位打卡",
|
||||||
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#f4f8f6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/check-in/records",
|
||||||
|
"type": "page",
|
||||||
|
"layout": "map",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的打卡",
|
||||||
"navigationBarBackgroundColor": "#ffffff",
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"backgroundColor": "#f4f8f6"
|
"backgroundColor": "#f4f8f6"
|
||||||
@@ -96,7 +119,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pagePath": "pages/assistant/index",
|
"pagePath": "pages/assistant/index",
|
||||||
"text": "AI 助手",
|
"text": "文旅助手",
|
||||||
"iconPath": "static/tabbar/assistant.png",
|
"iconPath": "static/tabbar/assistant.png",
|
||||||
"selectedIconPath": "static/tabbar/assistant-selected.png"
|
"selectedIconPath": "static/tabbar/assistant-selected.png"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ onShow(loadAssistantHome)
|
|||||||
<view class="assistant-hero__glow assistant-hero__glow--two" />
|
<view class="assistant-hero__glow assistant-hero__glow--two" />
|
||||||
<view class="assistant-hero__top">
|
<view class="assistant-hero__top">
|
||||||
<view>
|
<view>
|
||||||
<text class="assistant-hero__eyebrow">GUANGMING AI GUIDE</text>
|
<text class="assistant-hero__eyebrow">GUANGMING LOCAL GUIDE</text>
|
||||||
<text class="assistant-hero__title">光明文旅 AI 助手</text>
|
<text class="assistant-hero__title">光明文旅助手</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="assistant-hero__status">
|
<view class="assistant-hero__status">
|
||||||
<view class="assistant-hero__status-dot" />
|
<view class="assistant-hero__status-dot" />
|
||||||
@@ -99,7 +99,7 @@ onShow(loadAssistantHome)
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="assistant-bot" aria-label="AI 助手形象">
|
<view class="assistant-bot" aria-label="文旅助手形象">
|
||||||
<view class="assistant-bot__antenna" />
|
<view class="assistant-bot__antenna" />
|
||||||
<view class="assistant-bot__head">
|
<view class="assistant-bot__head">
|
||||||
<view class="assistant-bot__face">
|
<view class="assistant-bot__face">
|
||||||
@@ -108,7 +108,7 @@ onShow(loadAssistantHome)
|
|||||||
<view class="assistant-bot__eye" />
|
<view class="assistant-bot__eye" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="assistant-bot__body">AI</view>
|
<view class="assistant-bot__body">POC</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="assistant-hero__speech">
|
<view class="assistant-hero__speech">
|
||||||
@@ -166,7 +166,7 @@ onShow(loadAssistantHome)
|
|||||||
|
|
||||||
<view class="assistant-notice">
|
<view class="assistant-notice">
|
||||||
<text class="assistant-notice__title">POC 能力边界</text>
|
<text class="assistant-notice__title">POC 能力边界</text>
|
||||||
<text>未配置在线 AI 服务时,助手会使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。</text>
|
<text>本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -175,7 +175,7 @@ onShow(loadAssistantHome)
|
|||||||
<route lang="yaml">
|
<route lang="yaml">
|
||||||
layout: map
|
layout: map
|
||||||
style:
|
style:
|
||||||
navigationBarTitleText: AI 文旅助手
|
navigationBarTitleText: 文旅助手
|
||||||
navigationBarBackgroundColor: '#ffffff'
|
navigationBarBackgroundColor: '#ffffff'
|
||||||
navigationBarTextStyle: black
|
navigationBarTextStyle: black
|
||||||
backgroundColor: '#f4f8f6'
|
backgroundColor: '#f4f8f6'
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckInBadge, CheckInBadgeDefinition, CheckInProfile, CheckInResult } from '@/domain/check-in'
|
||||||
|
import type { PoiResolved } from '@/domain/poi'
|
||||||
|
import PageState from '@/components/poi/PageState.vue'
|
||||||
|
import PoiImage from '@/components/poi/PoiImage.vue'
|
||||||
|
import { getCheckInTask, getCheckInTasks } from '@/data/check-in'
|
||||||
|
import { getPoiRepository } from '@/data/poi'
|
||||||
|
import {
|
||||||
|
CHECK_IN_BADGES,
|
||||||
|
CHECK_IN_POINTS,
|
||||||
|
CheckInError,
|
||||||
|
} from '@/domain/check-in'
|
||||||
|
import { checkInAtPoi, getCheckInProfile } from '@/services/check-in'
|
||||||
|
import { getCurrentGcj02Location, isLocationPermissionDenied } from '@/services/location'
|
||||||
|
|
||||||
|
const pageState = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
|
||||||
|
const poi = shallowRef<PoiResolved | null>(null)
|
||||||
|
const profile = ref<CheckInProfile>({ points: 0, badges: [], checkIns: [] })
|
||||||
|
const locating = ref(false)
|
||||||
|
const requestVersion = ref(0)
|
||||||
|
const routePoiId = ref('')
|
||||||
|
const privacyAuthorizationRequired = ref(false)
|
||||||
|
|
||||||
|
const checkedRecord = computed(() => poi.value
|
||||||
|
? profile.value.checkIns.find(record => record.poiId === poi.value?.id) ?? null
|
||||||
|
: null)
|
||||||
|
const progressText = computed(() => {
|
||||||
|
const availablePoiIds = new Set(getCheckInTasks().map(task => task.poiId))
|
||||||
|
const completedCount = profile.value.checkIns.filter(record => availablePoiIds.has(record.poiId)).length
|
||||||
|
return `${completedCount} / ${availablePoiIds.size}`
|
||||||
|
})
|
||||||
|
const checkInRadius = computed(() => poi.value ? getCheckInTask(poi.value.id)?.radiusMeters ?? 0 : 0)
|
||||||
|
|
||||||
|
function loadPage(poiId: string) {
|
||||||
|
pageState.value = 'loading'
|
||||||
|
try {
|
||||||
|
const result = getPoiRepository().getPoiById(poiId)
|
||||||
|
if (!result || !getCheckInTask(result.id)) {
|
||||||
|
pageState.value = 'invalid'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
poi.value = result
|
||||||
|
profile.value = getCheckInProfile()
|
||||||
|
pageState.value = 'ready'
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to load check-in page', error)
|
||||||
|
pageState.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToPoi() {
|
||||||
|
uni.navigateBack({
|
||||||
|
fail: () => uni.reLaunch({ url: '/pages/map/index' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDataRecovery() {
|
||||||
|
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecords() {
|
||||||
|
cancelPendingLocation()
|
||||||
|
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPendingLocation() {
|
||||||
|
if (!locating.value)
|
||||||
|
return
|
||||||
|
requestVersion.value += 1
|
||||||
|
locating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime()))
|
||||||
|
return '时间待确认'
|
||||||
|
const pad = (number: number) => String(number).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeDefinition(badge: CheckInBadge): CheckInBadgeDefinition | null {
|
||||||
|
return CHECK_IN_BADGES.find(definition => definition.code === badge.code) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkInSuccessContent(result: CheckInResult): string {
|
||||||
|
const badges = result.newBadges
|
||||||
|
.map(badgeDefinition)
|
||||||
|
.filter((badge): badge is CheckInBadgeDefinition => Boolean(badge))
|
||||||
|
return badges.length > 0
|
||||||
|
? `已记录本次到访并获得 ${CHECK_IN_POINTS} 积分,同时解锁“${badges.map(badge => badge.name).join('、')}”。`
|
||||||
|
: `已记录本次到访并获得 ${CHECK_IN_POINTS} 积分。`
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLocationPermissionGuide() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '需要位置权限',
|
||||||
|
content: '打卡必须使用本次真实定位判断是否到达点位附近。授权仅在你点击打卡时触发。',
|
||||||
|
confirmText: '去设置',
|
||||||
|
success: result => result.confirm && uni.openSetting({}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function privacyAuthorizationSupported(): boolean {
|
||||||
|
try {
|
||||||
|
return typeof uni.requirePrivacyAuthorize === 'function'
|
||||||
|
&& (typeof uni.canIUse !== 'function' || uni.canIUse('requirePrivacyAuthorize'))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePrivacyAuthorization(): Promise<void> {
|
||||||
|
if (!privacyAuthorizationSupported())
|
||||||
|
return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.requirePrivacyAuthorize({
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: error => reject(error),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivacyAuthorizationDenied(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object')
|
||||||
|
return false
|
||||||
|
const message = String((error as { errMsg?: unknown }).errMsg ?? '')
|
||||||
|
return /privacy/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptPrivacyAuthorization() {
|
||||||
|
privacyAuthorizationRequired.value = false
|
||||||
|
requestCheckIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCheckIn() {
|
||||||
|
if (!poi.value || locating.value || checkedRecord.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
const currentRequest = ++requestVersion.value
|
||||||
|
locating.value = true
|
||||||
|
try {
|
||||||
|
await requirePrivacyAuthorization()
|
||||||
|
if (currentRequest !== requestVersion.value)
|
||||||
|
return
|
||||||
|
const location = await getCurrentGcj02Location({ highAccuracyExpireTime: 10000, timeoutMs: 15000 })
|
||||||
|
if (currentRequest !== requestVersion.value)
|
||||||
|
return
|
||||||
|
const result = checkInAtPoi(poi.value.id, location)
|
||||||
|
profile.value = result.profile
|
||||||
|
uni.showModal({
|
||||||
|
title: '打卡成功',
|
||||||
|
content: checkInSuccessContent(result),
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '查看记录',
|
||||||
|
success: modalResult => modalResult.confirm && openRecords(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (currentRequest !== requestVersion.value)
|
||||||
|
return
|
||||||
|
if (isPrivacyAuthorizationDenied(error)) {
|
||||||
|
privacyAuthorizationRequired.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isLocationPermissionDenied(error)) {
|
||||||
|
showLocationPermissionGuide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : '暂时无法完成打卡,请稍后重试'
|
||||||
|
uni.showModal({
|
||||||
|
title: error instanceof CheckInError && error.code === 'not_eligible' ? '尚不能打卡' : '打卡未完成',
|
||||||
|
content: message,
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (currentRequest === requestVersion.value)
|
||||||
|
locating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
const rawPoiId = typeof query?.poiId === 'string' ? query.poiId : ''
|
||||||
|
try {
|
||||||
|
routePoiId.value = decodeURIComponent(rawPoiId)
|
||||||
|
loadPage(routePoiId.value)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
pageState.value = 'invalid'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (pageState.value === 'error' && routePoiId.value) {
|
||||||
|
loadPage(routePoiId.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
profile.value = getCheckInProfile()
|
||||||
|
if (poi.value && getCheckInTask(poi.value.id))
|
||||||
|
pageState.value = 'ready'
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to refresh check-in profile', error)
|
||||||
|
pageState.value = 'error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
cancelPendingLocation()
|
||||||
|
})
|
||||||
|
|
||||||
|
onHide(cancelPendingLocation)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="check-in-page">
|
||||||
|
<PageState v-if="pageState === 'loading'" state="loading" title="正在准备打卡…" />
|
||||||
|
<PageState
|
||||||
|
v-else-if="pageState === 'invalid'"
|
||||||
|
state="empty"
|
||||||
|
title="该点位暂不可打卡"
|
||||||
|
description="点位可能已更新,请返回地图重新选择"
|
||||||
|
action-label="返回地图"
|
||||||
|
@action="returnToPoi"
|
||||||
|
/>
|
||||||
|
<PageState
|
||||||
|
v-else-if="pageState === 'error'"
|
||||||
|
state="error"
|
||||||
|
title="打卡数据暂不可用"
|
||||||
|
description="请前往“我的打卡”检查并按提示处理本机数据"
|
||||||
|
action-label="去处理"
|
||||||
|
@action="openDataRecovery"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else-if="poi">
|
||||||
|
<view class="check-in-hero">
|
||||||
|
<text class="check-in-hero__eyebrow">ON-SITE CHECK-IN</text>
|
||||||
|
<text class="check-in-hero__title">抵达光明,留下足迹</text>
|
||||||
|
<text class="check-in-hero__description">每次打卡都使用本次真实定位核验;位置只用于当场判断,不写入本机记录。</text>
|
||||||
|
<button class="check-in-summary" @click="openRecords">
|
||||||
|
<view>
|
||||||
|
<text class="check-in-summary__label">本机积分</text>
|
||||||
|
<text class="check-in-summary__value">{{ profile.points }}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="check-in-summary__label">已打卡</text>
|
||||||
|
<text class="check-in-summary__value">{{ progressText }}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="check-in-summary__label">徽章</text>
|
||||||
|
<text class="check-in-summary__value">{{ profile.badges.length }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="check-in-summary__arrow">›</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="check-in-content">
|
||||||
|
<view class="poi-check-card">
|
||||||
|
<PoiImage
|
||||||
|
:src="poi.coverImage.url"
|
||||||
|
:alt="poi.coverImage.alt"
|
||||||
|
width="152rpx"
|
||||||
|
height="152rpx"
|
||||||
|
radius="18rpx"
|
||||||
|
/>
|
||||||
|
<view class="poi-check-card__copy">
|
||||||
|
<text class="poi-check-card__category">{{ poi.category.name }}</text>
|
||||||
|
<text class="poi-check-card__name">{{ poi.name }}</text>
|
||||||
|
<text class="poi-check-card__address">{{ poi.address }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="checkedRecord" class="check-in-status check-in-status--done">
|
||||||
|
<view class="check-in-status__mark">✓</view>
|
||||||
|
<view class="check-in-status__copy">
|
||||||
|
<text class="check-in-status__title">该点位已完成打卡</text>
|
||||||
|
<text>{{ formatTime(checkedRecord.checkedInAt) }} · 获得 {{ checkedRecord.pointsAwarded }} 积分</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="check-in-status">
|
||||||
|
<view class="check-in-status__mark">◎</view>
|
||||||
|
<view class="check-in-status__copy">
|
||||||
|
<text class="check-in-status__title">请在点位附近发起打卡</text>
|
||||||
|
<text>需在 {{ checkInRadius }} 米范围内,且定位精度满足要求</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="check-in-action"
|
||||||
|
:class="{ 'check-in-action--done': checkedRecord }"
|
||||||
|
:disabled="locating || Boolean(checkedRecord)"
|
||||||
|
@click="requestCheckIn"
|
||||||
|
>
|
||||||
|
{{ checkedRecord ? '已完成打卡' : locating ? '正在获取本次位置…' : `验证位置并打卡 +${CHECK_IN_POINTS}` }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<view v-if="privacyAuthorizationRequired" class="check-in-privacy-consent">
|
||||||
|
<text class="check-in-privacy-consent__title">打卡需要位置隐私授权</text>
|
||||||
|
<text>同意后才会请求本次位置;位置仅用于到点判断,不保存精确经纬度。</text>
|
||||||
|
<button
|
||||||
|
id="check-in-privacy-agree"
|
||||||
|
open-type="agreePrivacyAuthorization"
|
||||||
|
@agreeprivacyauthorization="acceptPrivacyAuthorization"
|
||||||
|
>
|
||||||
|
同意并继续打卡
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="check-in-rules">
|
||||||
|
<text class="check-in-rules__title">打卡规则</text>
|
||||||
|
<view><text>01</text><text>每个开放打卡点位在本机仅可打卡一次</text></view>
|
||||||
|
<view><text>02</text><text>每次打卡重新请求 GCJ-02 位置,不复用历史定位</text></view>
|
||||||
|
<view><text>03</text><text>成功后增加积分,并按累计点位数解锁徽章</text></view>
|
||||||
|
<view><text>04</text><text>系统级虚拟定位无法由本 POC 完全识别,正式版需增加风控</text></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<route lang="yaml">
|
||||||
|
layout: map
|
||||||
|
style:
|
||||||
|
navigationBarTitleText: 点位打卡
|
||||||
|
navigationBarBackgroundColor: '#ffffff'
|
||||||
|
navigationBarTextStyle: black
|
||||||
|
backgroundColor: '#f4f8f6'
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.check-in-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #18201d;
|
||||||
|
background: #f4f8f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-hero {
|
||||||
|
padding: 46rpx 32rpx 34rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(145deg, #123d35, #08724d 62%, #07573d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-hero__eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
color: #9fe0bd;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-hero__title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 58rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-hero__description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 23rpx;
|
||||||
|
line-height: 38rpx;
|
||||||
|
color: rgb(239 255 246 / 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 22rpx 24rpx;
|
||||||
|
margin: 30rpx 0 0;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
background: rgb(255 255 255 / 11%);
|
||||||
|
border: 1rpx solid rgb(255 255 255 / 22%);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-summary::after,
|
||||||
|
.check-in-action::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-summary view {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-summary__label {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: rgb(239 255 246 / 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-summary__value {
|
||||||
|
margin-top: 5rpx;
|
||||||
|
font-size: 29rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-summary__arrow {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #9fe0bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-content {
|
||||||
|
padding: 28rpx 28rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.poi-check-card,
|
||||||
|
.check-in-status,
|
||||||
|
.check-in-rules,
|
||||||
|
.check-in-privacy-consent {
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx solid #dce6e1;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
box-shadow: 0 10rpx 28rpx rgb(20 78 53 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-privacy-consent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
color: #5e6b65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-privacy-consent__title {
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #18201d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-privacy-consent button {
|
||||||
|
width: 100%;
|
||||||
|
height: 70rpx;
|
||||||
|
margin: 18rpx 0 0;
|
||||||
|
font-size: 23rpx;
|
||||||
|
line-height: 70rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: #167a5b;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-privacy-consent button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poi-check-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 22rpx;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poi-check-card__copy {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poi-check-card__category {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #16805c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poi-check-card__name {
|
||||||
|
margin-top: 5rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 42rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poi-check-card__address {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 7rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
color: #718078;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-status {
|
||||||
|
display: flex;
|
||||||
|
gap: 18rpx;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
color: #5e6b65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-status--done {
|
||||||
|
color: #315d4a;
|
||||||
|
background: #e8f4ee;
|
||||||
|
border-color: #cfe4d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-status__mark {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
background: #167a5b;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-status__copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-status__title {
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 38rpx;
|
||||||
|
color: #18201d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-action {
|
||||||
|
width: 100%;
|
||||||
|
height: 92rpx;
|
||||||
|
margin: 24rpx 0 0;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 92rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: #167a5b;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 14rpx 30rpx rgb(14 98 71 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-action--done,
|
||||||
|
.check-in-action[disabled] {
|
||||||
|
color: #718078;
|
||||||
|
background: #e3e9e6;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-rules {
|
||||||
|
padding: 26rpx;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-rules__title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-rules view {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
color: #5e6b65;
|
||||||
|
border-bottom: 1rpx solid #edf1ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-rules view:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-rules view text:first-child {
|
||||||
|
flex: none;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
color: #16805c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckInBadgeDefinition, CheckInProfile } from '@/domain/check-in'
|
||||||
|
import type { PoiResolved } from '@/domain/poi'
|
||||||
|
import { getCheckInTasks } from '@/data/check-in'
|
||||||
|
import { getPoiRepository } from '@/data/poi'
|
||||||
|
import { CHECK_IN_BADGES } from '@/domain/check-in'
|
||||||
|
import { clearCheckInProfile, getCheckInProfile } from '@/services/check-in'
|
||||||
|
|
||||||
|
interface BadgeView extends CheckInBadgeDefinition {
|
||||||
|
obtainedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = ref<CheckInProfile>({ points: 0, badges: [], checkIns: [] })
|
||||||
|
const storageError = ref('')
|
||||||
|
const availablePois = shallowRef<PoiResolved[]>([])
|
||||||
|
const badgeViews = computed<BadgeView[]>(() => CHECK_IN_BADGES.map(definition => ({
|
||||||
|
...definition,
|
||||||
|
obtainedAt: profile.value.badges.find(badge => badge.code === definition.code)?.obtainedAt ?? null,
|
||||||
|
})))
|
||||||
|
const availableCheckInCount = computed(() => {
|
||||||
|
const availablePoiIds = new Set(availablePois.value.map(poi => poi.id))
|
||||||
|
return profile.value.checkIns.filter(record => availablePoiIds.has(record.poiId)).length
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime()))
|
||||||
|
return '时间待确认'
|
||||||
|
const pad = (number: number) => String(number).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPoi(poiId: string) {
|
||||||
|
if (!getPoiRepository().getPoiById(poiId)) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '点位已下线',
|
||||||
|
content: '该点位暂不在当前开放列表中,本机历史打卡记录仍会保留。',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAvailablePois() {
|
||||||
|
const repository = getPoiRepository()
|
||||||
|
availablePois.value = getCheckInTasks().flatMap((task) => {
|
||||||
|
const poi = repository.getPoiById(task.poiId)
|
||||||
|
return poi ? [poi] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function poiCheckedIn(poiId: string): boolean {
|
||||||
|
return profile.value.checkIns.some(record => record.poiId === poiId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalRecords() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '清除本机打卡数据',
|
||||||
|
content: '积分、徽章和打卡记录将从本机删除且无法恢复,不影响路线、偏好或其他数据。',
|
||||||
|
confirmText: '确认清除',
|
||||||
|
confirmColor: '#b53a32',
|
||||||
|
success: (result) => {
|
||||||
|
if (!result.confirm)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
clearCheckInProfile()
|
||||||
|
profile.value = getCheckInProfile()
|
||||||
|
storageError.value = ''
|
||||||
|
uni.showToast({ title: '打卡数据已清除', icon: 'success' })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '清除失败',
|
||||||
|
content: error instanceof Error ? error.message : '请稍后重试',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadAvailablePois()
|
||||||
|
try {
|
||||||
|
profile.value = getCheckInProfile()
|
||||||
|
storageError.value = ''
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
storageError.value = error instanceof Error ? error.message : '本机打卡记录暂时无法读取'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="records-page">
|
||||||
|
<view class="records-hero">
|
||||||
|
<text class="records-hero__eyebrow">LOCAL JOURNEY</text>
|
||||||
|
<text class="records-hero__title">我的光明足迹</text>
|
||||||
|
<view class="records-stats">
|
||||||
|
<view><text>{{ profile.points }}</text><text>本机积分</text></view>
|
||||||
|
<view><text>{{ profile.checkIns.length }}</text><text>打卡点位</text></view>
|
||||||
|
<view><text>{{ profile.badges.length }}</text><text>已获徽章</text></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="records-content">
|
||||||
|
<view v-if="storageError" class="records-error">
|
||||||
|
<text class="records-error__title">打卡数据需要处理</text>
|
||||||
|
<text>{{ storageError }}</text>
|
||||||
|
<button @click="clearLocalRecords">清除异常打卡数据</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="records-section">
|
||||||
|
<view class="records-section__heading">
|
||||||
|
<view><text class="records-section__kicker">BADGES</text><text class="records-section__title">光明徽章</text></view>
|
||||||
|
<text>{{ profile.badges.length }} / {{ badgeViews.length }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="badge-grid">
|
||||||
|
<view
|
||||||
|
v-for="badge in badgeViews"
|
||||||
|
:key="badge.code"
|
||||||
|
class="badge-card"
|
||||||
|
:class="{ 'badge-card--locked': !badge.obtainedAt }"
|
||||||
|
>
|
||||||
|
<view class="badge-card__icon">{{ badge.icon }}</view>
|
||||||
|
<text class="badge-card__name">{{ badge.name }}</text>
|
||||||
|
<text class="badge-card__description">{{ badge.description }}</text>
|
||||||
|
<text class="badge-card__time">{{ badge.obtainedAt ? formatTime(badge.obtainedAt) : '尚未解锁' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="records-section">
|
||||||
|
<view class="records-section__heading">
|
||||||
|
<view><text class="records-section__kicker">PLACES</text><text class="records-section__title">开放打卡点位</text></view>
|
||||||
|
<text>{{ availableCheckInCount }} / {{ availablePois.length }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="check-in-poi-list">
|
||||||
|
<button v-for="poi in availablePois" :key="poi.id" class="check-in-poi-card" @click="openPoi(poi.id)">
|
||||||
|
<view class="check-in-poi-card__mark" :class="{ 'check-in-poi-card__mark--done': poiCheckedIn(poi.id) }">
|
||||||
|
{{ poiCheckedIn(poi.id) ? '✓' : '◎' }}
|
||||||
|
</view>
|
||||||
|
<view class="check-in-poi-card__copy">
|
||||||
|
<text>{{ poi.name }}</text>
|
||||||
|
<text>{{ poi.category.name }} · {{ poiCheckedIn(poi.id) ? '已打卡' : '可前往打卡' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="check-in-poi-card__arrow">›</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="records-section">
|
||||||
|
<view class="records-section__heading">
|
||||||
|
<view><text class="records-section__kicker">HISTORY</text><text class="records-section__title">打卡记录</text></view>
|
||||||
|
<text>{{ profile.checkIns.length }} 条</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="profile.checkIns.length" class="record-list">
|
||||||
|
<button v-for="record in profile.checkIns" :key="record.id" class="record-card" @click="openPoi(record.poiId)">
|
||||||
|
<view class="record-card__index">✓</view>
|
||||||
|
<view class="record-card__copy">
|
||||||
|
<text class="record-card__name">{{ record.poiName }}</text>
|
||||||
|
<text>{{ formatTime(record.checkedInAt) }} · +{{ record.pointsAwarded }} 积分</text>
|
||||||
|
</view>
|
||||||
|
<text class="record-card__arrow">›</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view v-else class="record-empty">
|
||||||
|
<view class="record-empty__mark">◎</view>
|
||||||
|
<text class="record-empty__title">还没有光明足迹</text>
|
||||||
|
<text>从地图选择一个点位,到达现场后即可开始第一次打卡。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="records-privacy">
|
||||||
|
<text class="records-privacy__title">匿名本机数据</text>
|
||||||
|
<text>打卡记录、积分和徽章仅保存在当前设备;不登录、不上传,也不保存打卡时的精确经纬度。</text>
|
||||||
|
<button v-if="profile.checkIns.length" @click="clearLocalRecords">清除本机打卡数据</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<route lang="yaml">
|
||||||
|
layout: map
|
||||||
|
style:
|
||||||
|
navigationBarTitleText: 我的打卡
|
||||||
|
navigationBarBackgroundColor: '#ffffff'
|
||||||
|
navigationBarTextStyle: black
|
||||||
|
backgroundColor: '#f4f8f6'
|
||||||
|
</route>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.records-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #18201d;
|
||||||
|
background: #f4f8f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-hero {
|
||||||
|
padding: 46rpx 32rpx 36rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(145deg, #123d35, #08724d 62%, #07573d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-hero__eyebrow,
|
||||||
|
.records-section__kicker {
|
||||||
|
display: block;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
color: #9fe0bd;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-hero__title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 58rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-stats view {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 18rpx;
|
||||||
|
background: rgb(255 255 255 / 11%);
|
||||||
|
border: 1rpx solid rgb(255 255 255 / 20%);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-stats view text:first-child {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-stats view text:last-child {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: rgb(239 255 246 / 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-content {
|
||||||
|
padding: 8rpx 28rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-section {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
color: #6d3a36;
|
||||||
|
background: #fff5f3;
|
||||||
|
border: 1rpx solid #ead6d2;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-error__title {
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #873e38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-error button {
|
||||||
|
width: 100%;
|
||||||
|
height: 64rpx;
|
||||||
|
margin: 16rpx 0 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: #873e38;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-error button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-section__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
font-size: 21rpx;
|
||||||
|
color: #718078;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-section__kicker {
|
||||||
|
color: #16805c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-section__title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 7rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 48rpx;
|
||||||
|
color: #18201d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 22rpx 12rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #e8f4ee;
|
||||||
|
border: 1rpx solid #cfe4d9;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-card--locked {
|
||||||
|
color: #718078;
|
||||||
|
background: #f0f3f1;
|
||||||
|
border-color: #e0e6e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-card__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
background: #167a5b;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-card--locked .badge-card__icon {
|
||||||
|
background: #a8b3ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-card__name {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-card__description,
|
||||||
|
.badge-card__time {
|
||||||
|
margin-top: 7rpx;
|
||||||
|
font-size: 17rpx;
|
||||||
|
line-height: 27rpx;
|
||||||
|
color: #637069;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-card__time {
|
||||||
|
color: #16805c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 18rpx;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx solid #dce6e1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-card::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-card__mark {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 54rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
color: #16805c;
|
||||||
|
background: #e8f4ee;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-card__mark--done {
|
||||||
|
color: #fff;
|
||||||
|
background: #167a5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-card__copy {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-card__copy text:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 38rpx;
|
||||||
|
color: #18201d;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-card__copy text:last-child {
|
||||||
|
margin-top: 3rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
|
color: #718078;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-in-poi-card__arrow {
|
||||||
|
font-size: 38rpx;
|
||||||
|
color: #16805c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 18rpx;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 22rpx;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx solid #dce6e1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card::after,
|
||||||
|
.records-privacy button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__index {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: #167a5b;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__copy {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 31rpx;
|
||||||
|
color: #718078;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 38rpx;
|
||||||
|
color: #18201d;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__arrow {
|
||||||
|
font-size: 38rpx;
|
||||||
|
color: #16805c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 52rpx 30rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
color: #718078;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx solid #dce6e1;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-empty__mark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #16805c;
|
||||||
|
background: #e8f4ee;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-empty__title {
|
||||||
|
margin: 18rpx 0 7rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #18201d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-privacy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-top: 36rpx;
|
||||||
|
font-size: 21rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
color: #637069;
|
||||||
|
background: #eef6f2;
|
||||||
|
border: 1rpx solid #d6e7df;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-privacy__title {
|
||||||
|
margin-bottom: 7rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0e6247;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-privacy button {
|
||||||
|
width: 100%;
|
||||||
|
height: 70rpx;
|
||||||
|
margin: 18rpx 0 0;
|
||||||
|
font-size: 23rpx;
|
||||||
|
line-height: 70rpx;
|
||||||
|
color: #8d3732;
|
||||||
|
background: #fff;
|
||||||
|
border: 1rpx solid #e8d6d4;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+132
-18
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PoiResolved } from '@/domain/poi'
|
import type { PoiResolved } from '@/domain/poi'
|
||||||
import type { ItineraryItem, PlanResponse, TravelPreferences } from '@/domain/travel'
|
import type { ItineraryItem, PlanningOrigin, PlanResponse, TravelPreferences } from '@/domain/travel'
|
||||||
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
|
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { adjustPlan, loadPlan, loadPreferences, savePlan } from '@/services/travel-assistant'
|
import { adjustPlan, getSessionPlanningOrigin, loadPlan, loadPreferences, savePlan } from '@/services/travel-assistant'
|
||||||
import { useMapStore } from '@/stores'
|
import { useMapStore } from '@/stores'
|
||||||
|
|
||||||
interface ResolvedItineraryItem {
|
interface ResolvedItineraryItem {
|
||||||
@@ -31,6 +31,11 @@ interface RouteMarker {
|
|||||||
anchor: { x: number, y: number }
|
anchor: { x: number, y: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RoutePoint {
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
type DurationFitStatus = PlanResponse['itinerary']['durationFitStatus']
|
type DurationFitStatus = PlanResponse['itinerary']['durationFitStatus']
|
||||||
|
|
||||||
interface DurationFitNotice {
|
interface DurationFitNotice {
|
||||||
@@ -47,7 +52,10 @@ const adjusting = ref(false)
|
|||||||
const mapReady = ref(false)
|
const mapReady = ref(false)
|
||||||
const mapError = ref(false)
|
const mapError = ref(false)
|
||||||
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
|
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
|
||||||
const quickPrompts = ['节奏慢一点', '换成室内场馆', '增加亲子体验']
|
const planningOrigin = shallowRef<PlanningOrigin | null>(null)
|
||||||
|
const quickPrompts = computed(() => plan.value?.itinerary.planningMode === 'custom'
|
||||||
|
? ['节奏慢一点', '改为步行', '改成半日游']
|
||||||
|
: ['节奏慢一点', '换成室内场馆', '增加亲子体验'])
|
||||||
|
|
||||||
const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
|
const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
|
||||||
if (!plan.value)
|
if (!plan.value)
|
||||||
@@ -59,7 +67,7 @@ const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
|
const poiRouteMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
latitude: poi.latitude,
|
latitude: poi.latitude,
|
||||||
longitude: poi.longitude,
|
longitude: poi.longitude,
|
||||||
@@ -68,7 +76,7 @@ const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ po
|
|||||||
height: uni.upx2px(76),
|
height: uni.upx2px(76),
|
||||||
anchor: { x: 0.5, y: 1 },
|
anchor: { x: 0.5, y: 1 },
|
||||||
callout: {
|
callout: {
|
||||||
content: `${index + 1} · ${poi.name}`,
|
content: String(index + 1),
|
||||||
color: '#18201d',
|
color: '#18201d',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
borderRadius: 7,
|
borderRadius: 7,
|
||||||
@@ -79,11 +87,47 @@ const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ po
|
|||||||
},
|
},
|
||||||
})))
|
})))
|
||||||
|
|
||||||
const routePolyline = computed(() => [{
|
const originMarker = computed<RouteMarker | null>(() => {
|
||||||
points: resolvedItems.value.map(({ poi }) => ({
|
if (!plan.value?.itinerary.startsFromCurrentLocation || !planningOrigin.value)
|
||||||
|
return null
|
||||||
|
return {
|
||||||
|
id: 900000001,
|
||||||
|
latitude: planningOrigin.value.latitude,
|
||||||
|
longitude: planningOrigin.value.longitude,
|
||||||
|
iconPath: '/static/markers/default-selected.png',
|
||||||
|
width: uni.upx2px(56),
|
||||||
|
height: uni.upx2px(66),
|
||||||
|
anchor: { x: 0.5, y: 1 },
|
||||||
|
callout: {
|
||||||
|
content: '起点',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 11,
|
||||||
|
borderRadius: 7,
|
||||||
|
bgColor: '#b45a1b',
|
||||||
|
padding: 5,
|
||||||
|
display: 'ALWAYS',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeMarkers = computed<RouteMarker[]>(() => [
|
||||||
|
...(originMarker.value ? [originMarker.value] : []),
|
||||||
|
...poiRouteMarkers.value,
|
||||||
|
])
|
||||||
|
|
||||||
|
const routePoints = computed<RoutePoint[]>(() => [
|
||||||
|
...(originMarker.value
|
||||||
|
? [{ latitude: originMarker.value.latitude, longitude: originMarker.value.longitude }]
|
||||||
|
: []),
|
||||||
|
...resolvedItems.value.map(({ poi }) => ({
|
||||||
latitude: poi.latitude,
|
latitude: poi.latitude,
|
||||||
longitude: poi.longitude,
|
longitude: poi.longitude,
|
||||||
})),
|
})),
|
||||||
|
])
|
||||||
|
|
||||||
|
const routePolyline = computed(() => [{
|
||||||
|
points: routePoints.value,
|
||||||
color: '#167a5bcc',
|
color: '#167a5bcc',
|
||||||
width: 6,
|
width: 6,
|
||||||
dottedLine: false,
|
dottedLine: false,
|
||||||
@@ -115,13 +159,23 @@ const planningSourceLabel = computed(() => {
|
|||||||
return '真实 AI 推荐'
|
return '真实 AI 推荐'
|
||||||
if (plan.value?.source === 'remote_mock')
|
if (plan.value?.source === 'remote_mock')
|
||||||
return '远端演示规划'
|
return '远端演示规划'
|
||||||
return '本地智能规划'
|
return '本地 POC 规划'
|
||||||
|
})
|
||||||
|
const planningBasisLabel = computed(() => {
|
||||||
|
const location = plan.value?.itinerary.startsFromCurrentLocation
|
||||||
|
? planningOrigin.value ? '已结合本次当前位置' : '已结合定位(起点坐标未保留)'
|
||||||
|
: '光明区全域'
|
||||||
|
const selection = plan.value?.itinerary.planningMode === 'custom'
|
||||||
|
? '完整保留自选点位并优化顺序'
|
||||||
|
: '按偏好、时长和推荐指数自动选点'
|
||||||
|
return `${location} · ${selection}`
|
||||||
})
|
})
|
||||||
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
|
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
|
||||||
|
|
||||||
function loadStoredPlan() {
|
function loadStoredPlan() {
|
||||||
plan.value = loadPlan()
|
plan.value = loadPlan()
|
||||||
preferences.value = loadPreferences()
|
preferences.value = loadPreferences()
|
||||||
|
planningOrigin.value = plan.value ? getSessionPlanningOrigin(plan.value.conversationId) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMinutes(minutes: number): string {
|
function formatMinutes(minutes: number): string {
|
||||||
@@ -186,7 +240,7 @@ function formatDate(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fitRoutePoints() {
|
function fitRoutePoints() {
|
||||||
if (!mapReady.value || !mapContext.value || resolvedItems.value.length === 0)
|
if (!mapReady.value || !mapContext.value || routePoints.value.length === 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
const context = mapContext.value
|
const context = mapContext.value
|
||||||
@@ -195,7 +249,7 @@ function fitRoutePoints() {
|
|||||||
return
|
return
|
||||||
try {
|
try {
|
||||||
context.includePoints({
|
context.includePoints({
|
||||||
points: resolvedItems.value.map(({ poi }) => ({ latitude: poi.latitude, longitude: poi.longitude })),
|
points: routePoints.value,
|
||||||
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(70), uni.upx2px(70)],
|
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(70), uni.upx2px(70)],
|
||||||
fail: handleMapError,
|
fail: handleMapError,
|
||||||
})
|
})
|
||||||
@@ -243,10 +297,11 @@ function focusOnMap(poiId: string) {
|
|||||||
uni.switchTab({ url: '/pages/map/index' })
|
uni.switchTab({ url: '/pages/map/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusFirstOnMap() {
|
function openFullRouteOnMap() {
|
||||||
const poi = resolvedItems.value[0]?.poi
|
mapStore.setCategory(null)
|
||||||
if (poi)
|
mapStore.selectPoi(null)
|
||||||
focusOnMap(poi.id)
|
mapStore.showPlannedRoute()
|
||||||
|
uni.switchTab({ url: '/pages/map/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveRoute() {
|
function saveRoute() {
|
||||||
@@ -273,6 +328,7 @@ async function sendAdjustment(quick?: string) {
|
|||||||
const response = await adjustPlan(plan.value.conversationId, content)
|
const response = await adjustPlan(plan.value.conversationId, content)
|
||||||
savePlan(response)
|
savePlan(response)
|
||||||
plan.value = response
|
plan.value = response
|
||||||
|
planningOrigin.value = getSessionPlanningOrigin(response.conversationId)
|
||||||
preferences.value = loadPreferences()
|
preferences.value = loadPreferences()
|
||||||
message.value = ''
|
message.value = ''
|
||||||
await showDurationFitNotice(response.itinerary.durationFitStatus)
|
await showDurationFitNotice(response.itinerary.durationFitStatus)
|
||||||
@@ -315,6 +371,11 @@ onReady(initializeMap)
|
|||||||
<view><text>行程点位</text><text>{{ resolvedItems.length }} 个</text></view>
|
<view><text>行程点位</text><text>{{ resolvedItems.length }} 个</text></view>
|
||||||
</view>
|
</view>
|
||||||
<text class="route-hero__summary">{{ plan.itinerary.summary }}</text>
|
<text class="route-hero__summary">{{ plan.itinerary.summary }}</text>
|
||||||
|
<view class="route-hero__basis">
|
||||||
|
<text>本次规划依据</text>
|
||||||
|
<text>{{ planningBasisLabel }}</text>
|
||||||
|
<text>{{ plan.assistantMessage }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="plan.changeSummary" class="change-notice">
|
<view v-if="plan.changeSummary" class="change-notice">
|
||||||
@@ -335,7 +396,9 @@ onReady(initializeMap)
|
|||||||
<view class="route-map-card__heading">
|
<view class="route-map-card__heading">
|
||||||
<view>
|
<view>
|
||||||
<text class="route-map-card__title">路线点位分布</text>
|
<text class="route-map-card__title">路线点位分布</text>
|
||||||
<text class="route-map-card__description">按推荐顺序直线连接,并非实际道路导航</text>
|
<text class="route-map-card__description">
|
||||||
|
{{ originMarker ? '含当前位置起点及全部游览节点' : '展示全部游览节点' }},按规划顺序直线连接
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<button @click="fitRoutePoints">查看全程</button>
|
<button @click="fitRoutePoints">查看全程</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -357,7 +420,11 @@ onReady(initializeMap)
|
|||||||
:show-compass="false"
|
:show-compass="false"
|
||||||
@error="handleMapError"
|
@error="handleMapError"
|
||||||
/>
|
/>
|
||||||
<view class="route-map-card__notice">推荐顺序 · 非实时导航</view>
|
<view class="route-node-sequence">
|
||||||
|
<text v-if="originMarker" class="route-node-sequence__origin">起点 · 当前位置</text>
|
||||||
|
<text v-for="({ poi }, index) in resolvedItems" :key="poi.id">{{ index + 1 }} · {{ poi.name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="route-map-card__notice">完整规划顺序 · 直线示意,非道路级实时导航</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="route-section">
|
<view class="route-section">
|
||||||
@@ -401,7 +468,7 @@ onReady(initializeMap)
|
|||||||
v-model="message"
|
v-model="message"
|
||||||
:disabled="adjusting"
|
:disabled="adjusting"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
placeholder="例如:第二个地点换成室内项目"
|
:placeholder="plan.itinerary.planningMode === 'custom' ? '例如:节奏慢一点,或改为步行' : '例如:换成室内场馆'"
|
||||||
placeholder-class="composer__placeholder"
|
placeholder-class="composer__placeholder"
|
||||||
confirm-type="send"
|
confirm-type="send"
|
||||||
@confirm="sendAdjustment()"
|
@confirm="sendAdjustment()"
|
||||||
@@ -427,7 +494,7 @@ onReady(initializeMap)
|
|||||||
|
|
||||||
<view class="bottom-actions">
|
<view class="bottom-actions">
|
||||||
<button class="bottom-actions__save" @click="saveRoute">保存路线</button>
|
<button class="bottom-actions__save" @click="saveRoute">保存路线</button>
|
||||||
<button class="bottom-actions__map" @click="focusFirstOnMap">在全域地图查看</button>
|
<button class="bottom-actions__map" @click="openFullRouteOnMap">在全域地图查看完整路线</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -559,6 +626,29 @@ style:
|
|||||||
color: rgb(238 255 245 / 75%);
|
color: rgb(238 255 245 / 75%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-hero__basis {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5rpx;
|
||||||
|
padding: 16rpx 18rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
background: rgb(255 255 255 / 9%);
|
||||||
|
border: 1rpx solid rgb(255 255 255 / 15%);
|
||||||
|
border-radius: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-hero__basis text {
|
||||||
|
font-size: 19rpx;
|
||||||
|
line-height: 30rpx;
|
||||||
|
color: rgb(238 255 245 / 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-hero__basis text:first-child {
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #b9f0d0;
|
||||||
|
}
|
||||||
|
|
||||||
.change-notice {
|
.change-notice {
|
||||||
padding: 18rpx 28rpx;
|
padding: 18rpx 28rpx;
|
||||||
font-size: 21rpx;
|
font-size: 21rpx;
|
||||||
@@ -676,6 +766,30 @@ style:
|
|||||||
background: #eef3f0;
|
background: #eef3f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-node-sequence {
|
||||||
|
display: flex;
|
||||||
|
gap: 10rpx;
|
||||||
|
padding: 16rpx 20rpx 4rpx;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #f8faf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-node-sequence text {
|
||||||
|
flex: none;
|
||||||
|
padding: 7rpx 13rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
color: #285542;
|
||||||
|
background: #e8f4ee;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-node-sequence .route-node-sequence__origin {
|
||||||
|
color: #8a431d;
|
||||||
|
background: #fff0e5;
|
||||||
|
}
|
||||||
|
|
||||||
.route-map-card__notice {
|
.route-map-card__notice {
|
||||||
padding: 12rpx 20rpx;
|
padding: 12rpx 20rpx;
|
||||||
font-size: 18rpx;
|
font-size: 18rpx;
|
||||||
|
|||||||
+125
-21
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PoiCategory, PoiDatasetMeta, PoiSummary } from '@/domain/poi'
|
import type { PoiCategory, PoiDatasetMeta, PoiSummary } from '@/domain/poi'
|
||||||
|
import type { PlanResponse } from '@/domain/travel'
|
||||||
import type { PoiMapMarker } from '@/services/map'
|
import type { PoiMapMarker } from '@/services/map'
|
||||||
import MapFilterHeader from '@/components/map/MapFilterHeader.vue'
|
import MapFilterHeader from '@/components/map/MapFilterHeader.vue'
|
||||||
import PoiSummaryCard from '@/components/map/PoiSummaryCard.vue'
|
import PoiSummaryCard from '@/components/map/PoiSummaryCard.vue'
|
||||||
import PageState from '@/components/poi/PageState.vue'
|
import PageState from '@/components/poi/PageState.vue'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { buildMarkerIdMap, createPoiMarkers } from '@/services/map'
|
import { buildMarkerIdMap, createPoiMarkers } from '@/services/map'
|
||||||
|
import { getSessionPlanningOrigin, loadPlan } from '@/services/travel-assistant'
|
||||||
import { useLocationStore, useMapStore } from '@/stores'
|
import { useLocationStore, useMapStore } from '@/stores'
|
||||||
|
|
||||||
const MAP_ID = 'guangming-cultural-map'
|
const MAP_ID = 'guangming-cultural-map'
|
||||||
@@ -18,6 +20,7 @@ const mapError = ref(false)
|
|||||||
const categories = ref<PoiCategory[]>([])
|
const categories = ref<PoiCategory[]>([])
|
||||||
const allPoiSummaries = ref<PoiSummary[]>([])
|
const allPoiSummaries = ref<PoiSummary[]>([])
|
||||||
const datasetMeta = ref<PoiDatasetMeta | null>(null)
|
const datasetMeta = ref<PoiDatasetMeta | null>(null)
|
||||||
|
const activePlan = shallowRef<PlanResponse | null>(null)
|
||||||
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
|
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
|
||||||
const markerIdToPoiId = shallowRef(new Map<number, string>())
|
const markerIdToPoiId = shallowRef(new Map<number, string>())
|
||||||
const lastMarkerTapAt = ref(0)
|
const lastMarkerTapAt = ref(0)
|
||||||
@@ -25,6 +28,8 @@ const navigating = ref(false)
|
|||||||
let includePointsRequestVersion = 0
|
let includePointsRequestVersion = 0
|
||||||
let locationListenerAttached = false
|
let locationListenerAttached = false
|
||||||
let locationUpdatesStarted = false
|
let locationUpdatesStarted = false
|
||||||
|
let locationUpdatesStarting = false
|
||||||
|
let locationUpdatesRequested = false
|
||||||
let locationRequestVersion = 0
|
let locationRequestVersion = 0
|
||||||
|
|
||||||
const activeCategoryCode = computed(() => mapStore.categoryCode)
|
const activeCategoryCode = computed(() => mapStore.categoryCode)
|
||||||
@@ -50,16 +55,84 @@ const selectedPoi = computed(() => {
|
|||||||
return null
|
return null
|
||||||
return allPoiSummaries.value.find(poi => poi.id === selectedPoiId.value) ?? null
|
return allPoiSummaries.value.find(poi => poi.id === selectedPoiId.value) ?? null
|
||||||
})
|
})
|
||||||
const markers = computed<PoiMapMarker[]>(() => createPoiMarkers(
|
const markers = computed<PoiMapMarker[]>(() => {
|
||||||
filteredPois.value,
|
const baseMarkers = createPoiMarkers(
|
||||||
categories.value,
|
filteredPois.value,
|
||||||
markerIdToPoiId.value,
|
categories.value,
|
||||||
selectedPoiId.value,
|
markerIdToPoiId.value,
|
||||||
))
|
selectedPoiId.value,
|
||||||
|
)
|
||||||
|
if (!activePlan.value)
|
||||||
|
return baseMarkers
|
||||||
|
|
||||||
|
const routeOrder = new Map(activePlan.value.itinerary.items.map((item, index) => [item.placeId, index + 1]))
|
||||||
|
return baseMarkers.map((marker) => {
|
||||||
|
const poiId = markerIdToPoiId.value.get(marker.id)
|
||||||
|
const order = poiId ? routeOrder.get(poiId) : undefined
|
||||||
|
if (!order)
|
||||||
|
return marker
|
||||||
|
return {
|
||||||
|
...marker,
|
||||||
|
callout: {
|
||||||
|
...marker.callout,
|
||||||
|
content: selectedPoiId.value === poiId ? `${order} · ${marker.callout.content}` : `路线 ${order}`,
|
||||||
|
color: '#ffffff',
|
||||||
|
bgColor: '#167a5b',
|
||||||
|
display: 'ALWAYS',
|
||||||
|
},
|
||||||
|
zIndex: Math.max(marker.zIndex, 8),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
const mapPoints = computed<Array<{ latitude: number, longitude: number }>>(() => filteredPois.value.map(poi => ({
|
const mapPoints = computed<Array<{ latitude: number, longitude: number }>>(() => filteredPois.value.map(poi => ({
|
||||||
latitude: poi.latitude,
|
latitude: poi.latitude,
|
||||||
longitude: poi.longitude,
|
longitude: poi.longitude,
|
||||||
})))
|
})))
|
||||||
|
const plannedPois = computed(() => {
|
||||||
|
if (!activePlan.value)
|
||||||
|
return []
|
||||||
|
const repository = getPoiRepository()
|
||||||
|
return activePlan.value.itinerary.items.flatMap((item) => {
|
||||||
|
const poi = repository.getPoiById(item.placeId)
|
||||||
|
return poi ? [poi] : []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const plannedOrigin = computed(() => activePlan.value
|
||||||
|
? getSessionPlanningOrigin(activePlan.value.conversationId)
|
||||||
|
: null)
|
||||||
|
const plannedRoutePoints = computed(() => [
|
||||||
|
...(activePlan.value?.itinerary.startsFromCurrentLocation && plannedOrigin.value
|
||||||
|
? [{ latitude: plannedOrigin.value.latitude, longitude: plannedOrigin.value.longitude }]
|
||||||
|
: []),
|
||||||
|
...plannedPois.value.map(poi => ({ latitude: poi.latitude, longitude: poi.longitude })),
|
||||||
|
])
|
||||||
|
const plannedRoutePolyline = computed(() => plannedRoutePoints.value.length > 1
|
||||||
|
? [{
|
||||||
|
points: plannedRoutePoints.value,
|
||||||
|
color: '#167a5bcc',
|
||||||
|
width: 6,
|
||||||
|
dottedLine: false,
|
||||||
|
arrowLine: true,
|
||||||
|
borderColor: '#ffffffcc',
|
||||||
|
borderWidth: 2,
|
||||||
|
}]
|
||||||
|
: [])
|
||||||
|
|
||||||
|
function loadActivePlan() {
|
||||||
|
activePlan.value = mapStore.plannedRouteVisible ? loadPlan() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function includePlannedRoute() {
|
||||||
|
const context = mapContext.value
|
||||||
|
if (!context || !mapReady.value || plannedRoutePoints.value.length === 0)
|
||||||
|
return
|
||||||
|
nextTick(() => {
|
||||||
|
context.includePoints({
|
||||||
|
points: plannedRoutePoints.value,
|
||||||
|
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(150), uni.upx2px(70)],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function loadDataset() {
|
function loadDataset() {
|
||||||
pageState.value = 'loading'
|
pageState.value = 'loading'
|
||||||
@@ -161,6 +234,8 @@ function selectCategory(categoryCode: string | null) {
|
|||||||
return
|
return
|
||||||
|
|
||||||
mapStore.setCategory(categoryCode)
|
mapStore.setCategory(categoryCode)
|
||||||
|
mapStore.hidePlannedRoute()
|
||||||
|
activePlan.value = null
|
||||||
if (selectedPoi.value && categoryCode && selectedPoi.value.categoryCode !== categoryCode)
|
if (selectedPoi.value && categoryCode && selectedPoi.value.categoryCode !== categoryCode)
|
||||||
mapStore.selectPoi(null)
|
mapStore.selectPoi(null)
|
||||||
|
|
||||||
@@ -244,6 +319,8 @@ function updateViewportWithScale(longitude: number, latitude: number, fallbackSc
|
|||||||
function resetToAll() {
|
function resetToAll() {
|
||||||
mapStore.setCategory(null)
|
mapStore.setCategory(null)
|
||||||
mapStore.selectPoi(null)
|
mapStore.selectPoi(null)
|
||||||
|
mapStore.hidePlannedRoute()
|
||||||
|
activePlan.value = null
|
||||||
const target = datasetMeta.value?.defaultViewport
|
const target = datasetMeta.value?.defaultViewport
|
||||||
if (target)
|
if (target)
|
||||||
mapStore.resetViewport(target)
|
mapStore.resetViewport(target)
|
||||||
@@ -279,16 +356,24 @@ function detachLocationListener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startForegroundLocationUpdates() {
|
function startForegroundLocationUpdates() {
|
||||||
if (locationUpdatesStarted)
|
if (locationUpdatesStarted || locationUpdatesStarting || !locationUpdatesRequested)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
locationUpdatesStarting = true
|
||||||
attachLocationListener()
|
attachLocationListener()
|
||||||
uni.startLocationUpdate({
|
uni.startLocationUpdate({
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
success: () => {
|
success: () => {
|
||||||
|
locationUpdatesStarting = false
|
||||||
|
if (!locationUpdatesRequested) {
|
||||||
|
uni.stopLocationUpdate()
|
||||||
|
detachLocationListener()
|
||||||
|
return
|
||||||
|
}
|
||||||
locationUpdatesStarted = true
|
locationUpdatesStarted = true
|
||||||
},
|
},
|
||||||
fail: (error) => {
|
fail: (error) => {
|
||||||
|
locationUpdatesStarting = false
|
||||||
detachLocationListener()
|
detachLocationListener()
|
||||||
console.error('Failed to start foreground location updates', error)
|
console.error('Failed to start foreground location updates', error)
|
||||||
},
|
},
|
||||||
@@ -296,6 +381,7 @@ function startForegroundLocationUpdates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopForegroundLocationUpdates() {
|
function stopForegroundLocationUpdates() {
|
||||||
|
locationUpdatesRequested = false
|
||||||
locationRequestVersion += 1
|
locationRequestVersion += 1
|
||||||
detachLocationListener()
|
detachLocationListener()
|
||||||
if (locationUpdatesStarted) {
|
if (locationUpdatesStarted) {
|
||||||
@@ -324,6 +410,7 @@ function requestUserLocation() {
|
|||||||
uni.openSetting({
|
uni.openSetting({
|
||||||
success: (settings) => {
|
success: (settings) => {
|
||||||
if (settings.authSetting['scope.userLocation']) {
|
if (settings.authSetting['scope.userLocation']) {
|
||||||
|
locationUpdatesRequested = true
|
||||||
locationStore.startLocating()
|
locationStore.startLocating()
|
||||||
requestCurrentLocation()
|
requestCurrentLocation()
|
||||||
}
|
}
|
||||||
@@ -332,11 +419,13 @@ function requestUserLocation() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (locationReady.value) {
|
if (locationReady.value) {
|
||||||
|
locationUpdatesRequested = true
|
||||||
centerOnUserLocation()
|
centerOnUserLocation()
|
||||||
startForegroundLocationUpdates()
|
startForegroundLocationUpdates()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
locationUpdatesRequested = true
|
||||||
locationStore.startLocating()
|
locationStore.startLocating()
|
||||||
requestCurrentLocation()
|
requestCurrentLocation()
|
||||||
}
|
}
|
||||||
@@ -395,6 +484,10 @@ function openDetail(poiId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCheckInRecords() {
|
||||||
|
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||||
|
}
|
||||||
|
|
||||||
function initializeMap() {
|
function initializeMap() {
|
||||||
includePointsRequestVersion += 1
|
includePointsRequestVersion += 1
|
||||||
mapReady.value = false
|
mapReady.value = false
|
||||||
@@ -404,7 +497,10 @@ function initializeMap() {
|
|||||||
try {
|
try {
|
||||||
mapContext.value = uni.createMapContext(MAP_ID)
|
mapContext.value = uni.createMapContext(MAP_ID)
|
||||||
mapReady.value = true
|
mapReady.value = true
|
||||||
includeFilteredPoints()
|
if (activePlan.value)
|
||||||
|
includePlannedRoute()
|
||||||
|
else
|
||||||
|
includeFilteredPoints()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error('Failed to initialize map', error)
|
console.error('Failed to initialize map', error)
|
||||||
@@ -431,7 +527,10 @@ onReady(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
if (locationReady.value)
|
loadActivePlan()
|
||||||
|
if (activePlan.value)
|
||||||
|
includePlannedRoute()
|
||||||
|
if (locationReady.value && locationUpdatesRequested)
|
||||||
startForegroundLocationUpdates()
|
startForegroundLocationUpdates()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -447,6 +546,7 @@ onUnload(stopForegroundLocationUpdates)
|
|||||||
:result-count="filteredPois.length"
|
:result-count="filteredPois.length"
|
||||||
:coverage-label="datasetMeta?.coverageLabel ?? 'POC 示例数据'"
|
:coverage-label="datasetMeta?.coverageLabel ?? 'POC 示例数据'"
|
||||||
:loading="pageState === 'loading'"
|
:loading="pageState === 'loading'"
|
||||||
|
@check-ins="openCheckInRecords"
|
||||||
@select="selectCategory"
|
@select="selectCategory"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -492,6 +592,7 @@ onUnload(stopForegroundLocationUpdates)
|
|||||||
:latitude="viewport.latitude"
|
:latitude="viewport.latitude"
|
||||||
:scale="viewport.scale"
|
:scale="viewport.scale"
|
||||||
:markers="markers"
|
:markers="markers"
|
||||||
|
:polyline="plannedRoutePolyline"
|
||||||
:show-location="locationReady"
|
:show-location="locationReady"
|
||||||
:enable-rotate="false"
|
:enable-rotate="false"
|
||||||
:enable-overlooking="false"
|
:enable-overlooking="false"
|
||||||
@@ -511,6 +612,9 @@ onUnload(stopForegroundLocationUpdates)
|
|||||||
>
|
>
|
||||||
{{ locationButtonText }}
|
{{ locationButtonText }}
|
||||||
</cover-view>
|
</cover-view>
|
||||||
|
<cover-view v-if="activePlan" class="map-page__route" @tap="includePlannedRoute">
|
||||||
|
完整路线 · {{ plannedPois.length }} 站
|
||||||
|
</cover-view>
|
||||||
</map>
|
</map>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -520,10 +624,6 @@ onUnload(stopForegroundLocationUpdates)
|
|||||||
@close="mapStore.selectPoi(null)"
|
@close="mapStore.selectPoi(null)"
|
||||||
@detail="openDetail"
|
@detail="openDetail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<view class="map-page__disclaimer">
|
|
||||||
{{ datasetMeta?.disclaimer }}
|
|
||||||
</view>
|
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -628,13 +728,17 @@ style:
|
|||||||
background: #167a5b;
|
background: #167a5b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page__disclaimer {
|
.map-page__route {
|
||||||
flex: none;
|
position: absolute;
|
||||||
padding: 8rpx 24rpx calc(8rpx + env(safe-area-inset-bottom));
|
bottom: 28rpx;
|
||||||
font-size: 20rpx;
|
left: 24rpx;
|
||||||
line-height: 30rpx;
|
padding: 14rpx 22rpx;
|
||||||
color: #8a9690;
|
font-size: 24rpx;
|
||||||
text-align: center;
|
font-weight: 700;
|
||||||
background: #f5f7f6;
|
line-height: 36rpx;
|
||||||
|
color: #fff;
|
||||||
|
background: #167a5b;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgb(24 32 29 / 16%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+38
-12
@@ -2,7 +2,7 @@
|
|||||||
import type { PoiSummary } from '@/domain/poi'
|
import type { PoiSummary } from '@/domain/poi'
|
||||||
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
|
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
import { createPlan, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
|
||||||
import { useLocationStore } from '@/stores'
|
import { useLocationStore } from '@/stores'
|
||||||
|
|
||||||
interface PlannerPreset {
|
interface PlannerPreset {
|
||||||
@@ -17,6 +17,7 @@ const MAX_DURATION_MINUTES = 10 * 60
|
|||||||
const DURATION_STEP_MINUTES = 30
|
const DURATION_STEP_MINUTES = 30
|
||||||
const MIN_CUSTOM_POIS = 2
|
const MIN_CUSTOM_POIS = 2
|
||||||
const MAX_CUSTOM_POIS = 8
|
const MAX_CUSTOM_POIS = 8
|
||||||
|
const LOCATION_MAX_AGE_MS = 5 * 60 * 1000
|
||||||
const themeOptions: Theme[] = ['亲子', '情侣', '朋友', '银发', '研学']
|
const themeOptions: Theme[] = ['亲子', '情侣', '朋友', '银发', '研学']
|
||||||
const interestOptions: Interest[] = ['自然风光', '文化场馆', '生态科普', '美食', '摄影']
|
const interestOptions: Interest[] = ['自然风光', '文化场馆', '生态科普', '美食', '摄影']
|
||||||
const paceOptions: Array<{ label: string, description: string, value: Pace }> = [
|
const paceOptions: Array<{ label: string, description: string, value: Pace }> = [
|
||||||
@@ -53,10 +54,17 @@ const planningMode = ref<PlanningRequest['mode']>('quick')
|
|||||||
const durationMinutes = ref(4 * 60)
|
const durationMinutes = ref(4 * 60)
|
||||||
const selectedPoiIds = ref<string[]>([])
|
const selectedPoiIds = ref<string[]>([])
|
||||||
const customStartsFromCurrentLocation = ref(false)
|
const customStartsFromCurrentLocation = ref(false)
|
||||||
|
const locationFreshnessCheckedAt = ref(Date.now())
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const locationStore = useLocationStore()
|
const locationStore = useLocationStore()
|
||||||
const remoteEnabled = computed(() => isRemoteTravelAssistantEnabled())
|
const locationAvailable = computed(() => {
|
||||||
const locationAvailable = computed(() => locationStore.status === 'ready' && Boolean(locationStore.snapshot))
|
const checkedAt = locationFreshnessCheckedAt.value
|
||||||
|
const snapshot = locationStore.snapshot
|
||||||
|
return locationStore.status === 'ready'
|
||||||
|
&& Boolean(snapshot)
|
||||||
|
&& Number.isFinite(Date.parse(snapshot?.capturedAt ?? ''))
|
||||||
|
&& checkedAt - Date.parse(snapshot?.capturedAt ?? '') <= LOCATION_MAX_AGE_MS
|
||||||
|
})
|
||||||
const poiSummaries = shallowRef<PoiSummary[]>(getPoiRepository().getPoiSummaries())
|
const poiSummaries = shallowRef<PoiSummary[]>(getPoiRepository().getPoiSummaries())
|
||||||
const poiGroups = computed(() => {
|
const poiGroups = computed(() => {
|
||||||
const groups = new Map<string, PoiSummary[]>()
|
const groups = new Map<string, PoiSummary[]>()
|
||||||
@@ -74,23 +82,28 @@ const durationText = computed(() => {
|
|||||||
})
|
})
|
||||||
const usesCurrentLocation = computed(() => locationAvailable.value
|
const usesCurrentLocation = computed(() => locationAvailable.value
|
||||||
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
|
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
|
||||||
const planningEngineLabel = computed(() => remoteEnabled.value
|
const planningEngineLabel = '本地 POC 规划'
|
||||||
? '在线智能服务'
|
|
||||||
: '本地智能规划')
|
|
||||||
const locationActionText = computed(() => {
|
const locationActionText = computed(() => {
|
||||||
if (locationStore.status === 'locating')
|
if (locationStore.status === 'locating')
|
||||||
return '定位中…'
|
return '定位中…'
|
||||||
if (locationStore.status === 'denied')
|
if (locationStore.status === 'denied')
|
||||||
return '开启位置权限'
|
return '开启位置权限'
|
||||||
return locationAvailable.value ? '刷新当前位置' : '获取当前位置'
|
return locationStore.snapshot ? '刷新当前位置' : '获取当前位置'
|
||||||
})
|
})
|
||||||
const locationDescription = computed(() => {
|
const locationDescription = computed(() => {
|
||||||
if (locationStore.status === 'locating')
|
if (locationStore.status === 'locating')
|
||||||
return '正在获取位置,请稍候。'
|
return '正在获取位置,请稍候。'
|
||||||
|
if (locationStore.status === 'denied') {
|
||||||
|
return planningMode.value === 'quick'
|
||||||
|
? '位置权限未开启;请前往设置授权,或改用自选点位规划。'
|
||||||
|
: '位置权限未开启;自选点位规划仍可继续使用。'
|
||||||
|
}
|
||||||
if (planningMode.value === 'quick') {
|
if (planningMode.value === 'quick') {
|
||||||
return locationAvailable.value
|
return locationAvailable.value
|
||||||
? '将从当前位置出发并优先安排较近点位;可随时刷新。'
|
? '将从当前位置出发并优先安排较近点位;可随时刷新。'
|
||||||
: '尚未获取位置,提交后会按光明区全域生成路线。'
|
: locationStore.snapshot
|
||||||
|
? '上次定位已超过 5 分钟,请刷新后再开始快速规划。'
|
||||||
|
: '快速规划需要先主动获取当前位置。'
|
||||||
}
|
}
|
||||||
if (!locationAvailable.value)
|
if (!locationAvailable.value)
|
||||||
return '可选获取位置,用于优化自选点位的起点和顺序。'
|
return '可选获取位置,用于优化自选点位的起点和顺序。'
|
||||||
@@ -236,6 +249,7 @@ function locateFromUserAction() {
|
|||||||
uni.showToast({ title: '定位结果无效', icon: 'none' })
|
uni.showToast({ title: '定位结果无效', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
locationFreshnessCheckedAt.value = Date.now()
|
||||||
if (planningMode.value === 'custom')
|
if (planningMode.value === 'custom')
|
||||||
customStartsFromCurrentLocation.value = true
|
customStartsFromCurrentLocation.value = true
|
||||||
uni.showToast({ title: '已更新当前位置', icon: 'success' })
|
uni.showToast({ title: '已更新当前位置', icon: 'success' })
|
||||||
@@ -272,6 +286,7 @@ function showDurationFitNotice(status: 'overflow' | 'underfilled'): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
locationFreshnessCheckedAt.value = Date.now()
|
||||||
if (!form.themes.length && !form.interests.length) {
|
if (!form.themes.length && !form.interests.length) {
|
||||||
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
|
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
|
||||||
return
|
return
|
||||||
@@ -285,6 +300,16 @@ async function submit() {
|
|||||||
uni.showToast({ title: `请选择 ${MIN_CUSTOM_POIS}-${MAX_CUSTOM_POIS} 个点位`, icon: 'none' })
|
uni.showToast({ title: `请选择 ${MIN_CUSTOM_POIS}-${MAX_CUSTOM_POIS} 个点位`, icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (planningMode.value === 'quick' && !locationAvailable.value) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '请先获取当前位置',
|
||||||
|
content: '当前位置快速规划需要一次近期定位;如暂不授权,可改用自选点位规划。',
|
||||||
|
confirmText: locationStore.status === 'denied' ? '去设置' : '立即定位',
|
||||||
|
cancelText: '暂不使用',
|
||||||
|
success: result => result.confirm && requestCurrentLocation(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -330,6 +355,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
|
locationFreshnessCheckedAt.value = Date.now()
|
||||||
const storedRequest = loadPlanningRequest()
|
const storedRequest = loadPlanningRequest()
|
||||||
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
|
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
|
||||||
if (storedPreferences)
|
if (storedPreferences)
|
||||||
@@ -394,9 +420,7 @@ onShow(() => {
|
|||||||
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
|
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip">
|
<text class="planning-mode-tip">本期由本机完成选点、顺序、交通估算与时长核算。</text>
|
||||||
在线 AI 将在你选择的点位内建议顺序,本机负责定位、交通与时长核算。
|
|
||||||
</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
|
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
|
||||||
@@ -624,7 +648,9 @@ onShow(() => {
|
|||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<text class="planner-footer">匿名使用 · 偏好和路线仅保存在本机</text>
|
<text class="planner-footer">
|
||||||
|
匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import PoiHeroGallery from '@/components/poi/PoiHeroGallery.vue'
|
|||||||
import PoiTagList from '@/components/poi/PoiTagList.vue'
|
import PoiTagList from '@/components/poi/PoiTagList.vue'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { formatRecommendationLabel } from '@/domain/poi'
|
import { formatRecommendationLabel } from '@/domain/poi'
|
||||||
|
import { getCheckInProfile } from '@/services/check-in'
|
||||||
|
|
||||||
const state = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
|
const state = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
|
||||||
const poi = ref<PoiResolved | null>(null)
|
const poi = ref<PoiResolved | null>(null)
|
||||||
|
const hasCheckedIn = ref(false)
|
||||||
|
const checkInStorageError = ref(false)
|
||||||
|
|
||||||
function loadPoi(poiId: string) {
|
function loadPoi(poiId: string) {
|
||||||
state.value = 'loading'
|
state.value = 'loading'
|
||||||
@@ -26,6 +29,15 @@ function loadPoi(poiId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
poi.value = result
|
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'
|
state.value = 'ready'
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -34,6 +46,16 @@ function loadPoi(poiId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function returnToMap() {
|
||||||
uni.navigateBack({
|
uni.navigateBack({
|
||||||
fail: () => {
|
fail: () => {
|
||||||
@@ -65,6 +87,20 @@ onLoad((query) => {
|
|||||||
state.value = 'invalid'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -100,6 +136,15 @@ onLoad((query) => {
|
|||||||
<PoiTagList class="poi-detail-page__tags" :tags="poi.tags" />
|
<PoiTagList class="poi-detail-page__tags" :tags="poi.tags" />
|
||||||
</view>
|
</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-card">
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text class="info-row__label">开放时间</text>
|
<text class="info-row__label">开放时间</text>
|
||||||
@@ -197,6 +242,7 @@ style:
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-card,
|
.info-card,
|
||||||
|
.check-in-entry,
|
||||||
.description-section,
|
.description-section,
|
||||||
.poc-notice {
|
.poc-notice {
|
||||||
padding: 28rpx;
|
padding: 28rpx;
|
||||||
@@ -206,6 +252,68 @@ style:
|
|||||||
border-radius: 20rpx;
|
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 {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { checkInAtPoi, getCheckInProfile } from './service'
|
||||||
|
export {
|
||||||
|
CHECK_IN_STORAGE_KEY,
|
||||||
|
CheckInStorageError,
|
||||||
|
clearCheckInProfile,
|
||||||
|
loadCheckInProfile,
|
||||||
|
saveCheckInProfile,
|
||||||
|
} from './storage'
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { getCheckInTask } from '@/data/check-in'
|
||||||
|
import { getPoiRepository } from '@/data/poi'
|
||||||
|
import {
|
||||||
|
applyCheckIn,
|
||||||
|
type CheckInLocationSnapshot,
|
||||||
|
type CheckInProfile,
|
||||||
|
type CheckInResult,
|
||||||
|
} from '@/domain/check-in'
|
||||||
|
import { loadCheckInProfile, saveCheckInProfile } from './storage'
|
||||||
|
|
||||||
|
export function checkInAtPoi(
|
||||||
|
poiId: string,
|
||||||
|
location: CheckInLocationSnapshot,
|
||||||
|
now = Date.now(),
|
||||||
|
): CheckInResult {
|
||||||
|
const poi = getPoiRepository().getPoiById(poiId)
|
||||||
|
const task = poi ? getCheckInTask(poi.id) : null
|
||||||
|
if (!poi || !task)
|
||||||
|
throw new Error('该点位不存在或已下线,暂时无法打卡')
|
||||||
|
const result = applyCheckIn(loadCheckInProfile(), poi, location, now, task.radiusMeters)
|
||||||
|
saveCheckInProfile(result.profile)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCheckInProfile(): CheckInProfile {
|
||||||
|
return loadCheckInProfile()
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { getCheckInTasks } from '@/data/check-in'
|
||||||
|
import {
|
||||||
|
CHECK_IN_BADGES,
|
||||||
|
CHECK_IN_POINTS,
|
||||||
|
type CheckInBadge,
|
||||||
|
type CheckInProfile,
|
||||||
|
type CheckInRecord,
|
||||||
|
cloneCheckInProfile,
|
||||||
|
createCheckInRecordId,
|
||||||
|
createEmptyCheckInProfile,
|
||||||
|
normalizeCheckInProfile,
|
||||||
|
} from '@/domain/check-in'
|
||||||
|
|
||||||
|
export const CHECK_IN_STORAGE_KEY = 'guangming:check-in-profile'
|
||||||
|
const CHECK_IN_STORAGE_SCHEMA_VERSION = 2
|
||||||
|
const LEGACY_CHECK_IN_STORAGE_SCHEMA_VERSION = 1
|
||||||
|
const envelopeKeys = new Set(['schemaVersion', 'savedAt', 'payload'])
|
||||||
|
|
||||||
|
interface CheckInStorageEnvelope {
|
||||||
|
schemaVersion: typeof CHECK_IN_STORAGE_SCHEMA_VERSION
|
||||||
|
savedAt: string
|
||||||
|
payload: CheckInProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CheckInStorageError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'CheckInStorageError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacyTimestamp(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)))
|
||||||
|
return null
|
||||||
|
return new Date(value).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyProfile(value: unknown): CheckInProfile | null {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value.checkIns))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const enabledPoiIds = new Set(getCheckInTasks().map(task => task.poiId))
|
||||||
|
const uniquePoiIds = new Set<string>()
|
||||||
|
const checkIns: CheckInRecord[] = []
|
||||||
|
for (const entry of value.checkIns) {
|
||||||
|
if (!isRecord(entry))
|
||||||
|
return null
|
||||||
|
const poiId = typeof entry.poiId === 'string' ? entry.poiId.trim() : ''
|
||||||
|
const poiName = typeof entry.poiName === 'string' ? entry.poiName.trim() : ''
|
||||||
|
const checkedInAt = readLegacyTimestamp(entry.checkedInAt)
|
||||||
|
if (!poiId || !poiName || !checkedInAt)
|
||||||
|
return null
|
||||||
|
if (!enabledPoiIds.has(poiId) || uniquePoiIds.has(poiId))
|
||||||
|
continue
|
||||||
|
uniquePoiIds.add(poiId)
|
||||||
|
checkIns.push({
|
||||||
|
id: createCheckInRecordId(poiId, checkedInAt),
|
||||||
|
poiId,
|
||||||
|
poiName,
|
||||||
|
checkedInAt,
|
||||||
|
pointsAwarded: CHECK_IN_POINTS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIns.sort((left, right) => right.checkedInAt.localeCompare(left.checkedInAt))
|
||||||
|
const badges: CheckInBadge[] = CHECK_IN_BADGES
|
||||||
|
.filter(definition => checkIns.length >= definition.requiredCheckIns)
|
||||||
|
.map((definition) => {
|
||||||
|
const thresholdRecord = checkIns[checkIns.length - definition.requiredCheckIns]
|
||||||
|
return { code: definition.code, obtainedAt: thresholdRecord.checkedInAt }
|
||||||
|
})
|
||||||
|
return normalizeCheckInProfile({
|
||||||
|
points: checkIns.length * CHECK_IN_POINTS,
|
||||||
|
badges,
|
||||||
|
checkIns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValidatedProfile(value: Record<string, unknown>): { profile: CheckInProfile, migrated: boolean } | null {
|
||||||
|
if (value.schemaVersion === CHECK_IN_STORAGE_SCHEMA_VERSION) {
|
||||||
|
const profile = normalizeCheckInProfile(value.payload)
|
||||||
|
return profile ? { profile, migrated: false } : null
|
||||||
|
}
|
||||||
|
if (value.schemaVersion === LEGACY_CHECK_IN_STORAGE_SCHEMA_VERSION) {
|
||||||
|
const profile = migrateLegacyProfile(value.payload)
|
||||||
|
return profile ? { profile, migrated: true } : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCheckInProfile(): CheckInProfile {
|
||||||
|
let value: unknown
|
||||||
|
try {
|
||||||
|
value = uni.getStorageSync(CHECK_IN_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const reason = error instanceof Error && error.message ? `:${error.message}` : ''
|
||||||
|
throw new CheckInStorageError(`无法读取本机打卡记录${reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined || value === null || value === '')
|
||||||
|
return createEmptyCheckInProfile()
|
||||||
|
|
||||||
|
if (!isRecord(value)
|
||||||
|
|| Object.keys(value).some(key => !envelopeKeys.has(key))
|
||||||
|
|| typeof value.savedAt !== 'string'
|
||||||
|
|| !Number.isFinite(Date.parse(value.savedAt))) {
|
||||||
|
throw new CheckInStorageError('本机打卡记录异常,请先在“我的打卡”中清除后重试')
|
||||||
|
}
|
||||||
|
const result = readValidatedProfile(value)
|
||||||
|
if (!result)
|
||||||
|
throw new CheckInStorageError('本机打卡记录异常,请先在“我的打卡”中清除后重试')
|
||||||
|
if (result.migrated)
|
||||||
|
saveCheckInProfile(result.profile)
|
||||||
|
return result.profile
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCheckInProfile(profile: CheckInProfile): void {
|
||||||
|
const normalized = normalizeCheckInProfile(profile)
|
||||||
|
if (!normalized)
|
||||||
|
throw new Error('打卡记录格式无效,未写入本机')
|
||||||
|
|
||||||
|
const envelope: CheckInStorageEnvelope = {
|
||||||
|
schemaVersion: CHECK_IN_STORAGE_SCHEMA_VERSION,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
payload: cloneCheckInProfile(normalized),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(CHECK_IN_STORAGE_KEY, envelope)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const reason = error instanceof Error && error.message ? `:${error.message}` : ''
|
||||||
|
throw new Error(`无法保存本机打卡记录${reason}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCheckInProfile(): void {
|
||||||
|
try {
|
||||||
|
uni.removeStorageSync(CHECK_IN_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const reason = error instanceof Error && error.message ? `:${error.message}` : ''
|
||||||
|
throw new Error(`无法清除本机打卡记录${reason}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { CheckInLocationSnapshot } from '@/domain/check-in'
|
||||||
|
|
||||||
|
interface CurrentLocationOptions {
|
||||||
|
highAccuracyExpireTime?: number
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationOptionsSupported(): boolean {
|
||||||
|
try {
|
||||||
|
return typeof uni.canIUse !== 'function'
|
||||||
|
|| uni.canIUse('getLocation.object.isHighAccuracy')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocationPermissionDenied(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object')
|
||||||
|
return false
|
||||||
|
const message = String((error as { errMsg?: unknown }).errMsg ?? '')
|
||||||
|
return !/privacy/i.test(message)
|
||||||
|
&& /auth deny|auth denied|authorize:fail|permission/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentGcj02Location(options: CurrentLocationOptions = {}): Promise<CheckInLocationSnapshot> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const highAccuracyExpireTime = Math.max(3000, options.highAccuracyExpireTime ?? 8000)
|
||||||
|
const timeoutMs = Math.max(3000, options.timeoutMs ?? 15000)
|
||||||
|
let settled = false
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled)
|
||||||
|
return
|
||||||
|
settled = true
|
||||||
|
if (timeout)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
finish(() => reject(new Error('定位超时,请到开阔处重试')))
|
||||||
|
}, timeoutMs)
|
||||||
|
const commonOptions = {
|
||||||
|
type: 'gcj02' as const,
|
||||||
|
success: (result: UniNamespace.GetLocationSuccess) => {
|
||||||
|
const longitude = Number(result.longitude)
|
||||||
|
const latitude = Number(result.latitude)
|
||||||
|
const accuracy = Number(result.accuracy ?? result.horizontalAccuracy)
|
||||||
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
|
||||||
|
finish(() => reject(new Error('定位结果无效,请稍后重试')))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finish(() => resolve({
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
accuracy: Number.isFinite(accuracy) && accuracy >= 0 ? accuracy : null,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
coordinateSystem: 'GCJ02',
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
fail: (error: unknown) => finish(() => reject(error)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationOptionsSupported()) {
|
||||||
|
uni.getLocation({
|
||||||
|
...commonOptions,
|
||||||
|
isHighAccuracy: true,
|
||||||
|
highAccuracyExpireTime,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.getLocation(commonOptions)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './current-location'
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export { isRemoteTravelAssistantEnabled } from './remote'
|
export { adjustPlan, createPlan, getSessionPlanningOrigin } from './service'
|
||||||
export { adjustPlan, createPlan } from './service'
|
|
||||||
export {
|
export {
|
||||||
loadPlan,
|
loadPlan,
|
||||||
loadPlanningRequest,
|
loadPlanningRequest,
|
||||||
|
|||||||
@@ -198,7 +198,13 @@ export async function createRemoteRecommendations(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
mode: planningRequest.mode,
|
mode: planningRequest.mode,
|
||||||
preferences: planningRequest.preferences,
|
preferences: {
|
||||||
|
destination: planningRequest.preferences.destination,
|
||||||
|
themes: [...planningRequest.preferences.themes],
|
||||||
|
pace: planningRequest.preferences.pace,
|
||||||
|
interests: [...planningRequest.preferences.interests],
|
||||||
|
transport: planningRequest.preferences.transport,
|
||||||
|
},
|
||||||
durationMinutes: planningRequest.durationMinutes,
|
durationMinutes: planningRequest.durationMinutes,
|
||||||
selectedPoiIds: [...planningRequest.selectedPoiIds],
|
selectedPoiIds: [...planningRequest.selectedPoiIds],
|
||||||
candidates,
|
candidates,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
clonePlanResponse,
|
clonePlanResponse,
|
||||||
cloneTravelPreferences,
|
cloneTravelPreferences,
|
||||||
createLocalPlan,
|
createLocalPlan,
|
||||||
createRecommendedPlan,
|
|
||||||
normalizePlanningRequest,
|
normalizePlanningRequest,
|
||||||
normalizeTravelPreferences,
|
normalizeTravelPreferences,
|
||||||
type PlanningOrigin,
|
type PlanningOrigin,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
type TravelPreferences,
|
type TravelPreferences,
|
||||||
TravelValidationError,
|
TravelValidationError,
|
||||||
} from '@/domain/travel'
|
} from '@/domain/travel'
|
||||||
import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote'
|
|
||||||
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
|
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
|
||||||
|
|
||||||
interface LocalSession {
|
interface LocalSession {
|
||||||
@@ -100,28 +98,6 @@ export async function createPlan(
|
|||||||
selectedPoiIds: [],
|
selectedPoiIds: [],
|
||||||
...(normalizePlanningOrigin(planningOrigin) ? { origin: normalizePlanningOrigin(planningOrigin)! } : {}),
|
...(normalizePlanningOrigin(planningOrigin) ? { origin: normalizePlanningOrigin(planningOrigin)! } : {}),
|
||||||
}, repository)
|
}, repository)
|
||||||
if (isRemoteTravelAssistantEnabled()) {
|
|
||||||
const candidates = repository.getPublishedPois().map(poi => ({
|
|
||||||
id: poi.id,
|
|
||||||
name: poi.name,
|
|
||||||
categoryCode: poi.categoryCode,
|
|
||||||
tagCodes: poi.tagCodes,
|
|
||||||
summary: poi.summary,
|
|
||||||
recommendationIndex: poi.recommendationIndex,
|
|
||||||
}))
|
|
||||||
const remote = await createRemoteRecommendations(request, candidates)
|
|
||||||
const plan = createRecommendedPlan(
|
|
||||||
request,
|
|
||||||
remote.recommendations,
|
|
||||||
repository,
|
|
||||||
remote.assistantMessage,
|
|
||||||
remote.generationMode === 'real' ? 'remote_ai' : 'remote_mock',
|
|
||||||
)
|
|
||||||
rememberLocalSession(plan, request)
|
|
||||||
savePlanningRequest(request)
|
|
||||||
return clonePlanResponse(plan, repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = createLocalPlan(request, repository)
|
const plan = createLocalPlan(request, repository)
|
||||||
rememberLocalSession(plan, request)
|
rememberLocalSession(plan, request)
|
||||||
savePlanningRequest(request)
|
savePlanningRequest(request)
|
||||||
@@ -152,10 +128,19 @@ export async function adjustPlan(conversationId: string, message: string): Promi
|
|||||||
)
|
)
|
||||||
if (session.plan.source !== 'local_poc') {
|
if (session.plan.source !== 'local_poc') {
|
||||||
adjusted.plan.assistantMessage = '已在本机按你的补充要求重新核算路线;如需 AI 重新选点,请返回规划页重新生成。'
|
adjusted.plan.assistantMessage = '已在本机按你的补充要求重新核算路线;如需 AI 重新选点,请返回规划页重新生成。'
|
||||||
adjusted.plan.source = session.plan.source
|
adjusted.plan.source = 'local_poc'
|
||||||
}
|
}
|
||||||
rememberLocalSession(adjusted.plan, adjusted.request)
|
rememberLocalSession(adjusted.plan, adjusted.request)
|
||||||
savePlanningRequest(adjusted.request)
|
savePlanningRequest(adjusted.request)
|
||||||
savePreferences(adjusted.preferences)
|
savePreferences(adjusted.preferences)
|
||||||
return clonePlanResponse(adjusted.plan, repository)
|
return clonePlanResponse(adjusted.plan, repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exact planning origins live only in this in-memory session and are never persisted. */
|
||||||
|
export function getSessionPlanningOrigin(conversationId: string): PlanningOrigin | null {
|
||||||
|
const normalizedConversationId = conversationId.trim()
|
||||||
|
if (!normalizedConversationId)
|
||||||
|
return null
|
||||||
|
const origin = localSessions.get(normalizedConversationId)?.planningOrigin
|
||||||
|
return origin ? { ...origin } : null
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ export function loadPlan(): PlanResponse | null {
|
|||||||
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
|
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
|
||||||
return null
|
return null
|
||||||
try {
|
try {
|
||||||
return normalizePlanResponse(envelope.payload, repository)
|
const plan = normalizePlanResponse(envelope.payload, repository)
|
||||||
|
return plan.source === 'local_poc' ? plan : null
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface MapSessionState {
|
|||||||
categoryCode: string | null
|
categoryCode: string | null
|
||||||
selectedPoiId: string | null
|
selectedPoiId: string | null
|
||||||
viewport: MapViewport
|
viewport: MapViewport
|
||||||
|
plannedRouteVisible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFinitePoint(point: GeoPoint): boolean {
|
function isFinitePoint(point: GeoPoint): boolean {
|
||||||
@@ -22,6 +23,7 @@ export const useMapStore = defineStore('map-session', {
|
|||||||
categoryCode: null,
|
categoryCode: null,
|
||||||
selectedPoiId: null,
|
selectedPoiId: null,
|
||||||
viewport: { ...DEFAULT_GUANGMING_VIEWPORT },
|
viewport: { ...DEFAULT_GUANGMING_VIEWPORT },
|
||||||
|
plannedRouteVisible: false,
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
setCategory(categoryCode: string | null) {
|
setCategory(categoryCode: string | null) {
|
||||||
@@ -39,10 +41,17 @@ export const useMapStore = defineStore('map-session', {
|
|||||||
resetViewport(viewport: MapViewport = DEFAULT_GUANGMING_VIEWPORT) {
|
resetViewport(viewport: MapViewport = DEFAULT_GUANGMING_VIEWPORT) {
|
||||||
this.viewport = { ...viewport }
|
this.viewport = { ...viewport }
|
||||||
},
|
},
|
||||||
|
showPlannedRoute() {
|
||||||
|
this.plannedRouteVisible = true
|
||||||
|
},
|
||||||
|
hidePlannedRoute() {
|
||||||
|
this.plannedRouteVisible = false
|
||||||
|
},
|
||||||
resetSession(viewport: MapViewport = DEFAULT_GUANGMING_VIEWPORT) {
|
resetSession(viewport: MapViewport = DEFAULT_GUANGMING_VIEWPORT) {
|
||||||
this.categoryCode = null
|
this.categoryCode = null
|
||||||
this.selectedPoiId = null
|
this.selectedPoiId = null
|
||||||
this.viewport = { ...viewport }
|
this.viewport = { ...viewport }
|
||||||
|
this.plannedRouteVisible = false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getCheckInTask, getCheckInTasks } from '../src/data/check-in'
|
||||||
|
import { getPoiRepository } from '../src/data/poi'
|
||||||
|
import {
|
||||||
|
applyCheckIn,
|
||||||
|
CHECK_IN_BADGES,
|
||||||
|
CHECK_IN_MAX_ACCURACY_METERS,
|
||||||
|
CHECK_IN_RADIUS_METERS,
|
||||||
|
type CheckInLocationSnapshot,
|
||||||
|
createCheckInRecordId,
|
||||||
|
createEmptyCheckInProfile,
|
||||||
|
distanceBetweenMeters,
|
||||||
|
evaluateCheckInEligibility,
|
||||||
|
normalizeCheckInProfile,
|
||||||
|
} from '../src/domain/check-in'
|
||||||
|
import {
|
||||||
|
CHECK_IN_STORAGE_KEY,
|
||||||
|
checkInAtPoi,
|
||||||
|
clearCheckInProfile,
|
||||||
|
loadCheckInProfile,
|
||||||
|
saveCheckInProfile,
|
||||||
|
} from '../src/services/check-in'
|
||||||
|
|
||||||
|
const poi = {
|
||||||
|
id: 'poi_hongqiao_park',
|
||||||
|
name: '虹桥公园',
|
||||||
|
longitude: 113.944,
|
||||||
|
latitude: 22.755,
|
||||||
|
}
|
||||||
|
|
||||||
|
function location(overrides: Partial<CheckInLocationSnapshot> = {}): CheckInLocationSnapshot {
|
||||||
|
return {
|
||||||
|
longitude: poi.longitude,
|
||||||
|
latitude: poi.latitude,
|
||||||
|
accuracy: 20,
|
||||||
|
receivedAt: '2026-07-31T02:00:00.000Z',
|
||||||
|
coordinateSystem: 'GCJ02',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('check-in domain', () => {
|
||||||
|
it('exposes every published POI through the task catalogue used by detail pages', () => {
|
||||||
|
const publishedPoiIds = getPoiRepository().getPublishedPois().map(item => item.id)
|
||||||
|
const tasks = getCheckInTasks()
|
||||||
|
|
||||||
|
expect(tasks.map(task => task.poiId)).toEqual(publishedPoiIds)
|
||||||
|
expect(tasks).toHaveLength(publishedPoiIds.length)
|
||||||
|
expect(tasks.every(task => task.radiusMeters === CHECK_IN_RADIUS_METERS)).toBe(true)
|
||||||
|
expect(publishedPoiIds.every(poiId => getCheckInTask(poiId)?.poiId === poiId)).toBe(true)
|
||||||
|
expect(getCheckInTask('poi_not_published')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes short GCJ-02 distances in metres', () => {
|
||||||
|
expect(distanceBetweenMeters(poi, poi)).toBe(0)
|
||||||
|
expect(distanceBetweenMeters(poi, { ...poi, latitude: poi.latitude + 0.001 })).toBeCloseTo(111, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires an accurate location inside the check-in radius', () => {
|
||||||
|
expect(evaluateCheckInEligibility(location(), poi)).toMatchObject({ eligible: true, code: 'eligible' })
|
||||||
|
expect(evaluateCheckInEligibility(location({ latitude: poi.latitude + 0.003 }), poi)).toMatchObject({
|
||||||
|
eligible: false,
|
||||||
|
code: 'too_far',
|
||||||
|
allowedDistanceMeters: CHECK_IN_RADIUS_METERS,
|
||||||
|
})
|
||||||
|
expect(evaluateCheckInEligibility(location({ latitude: poi.latitude + 0.0017, accuracy: 20 }), poi).code)
|
||||||
|
.toBe('uncertain')
|
||||||
|
expect(evaluateCheckInEligibility(location({ accuracy: 0 }), poi).code).toBe('low_accuracy')
|
||||||
|
expect(evaluateCheckInEligibility(location({ accuracy: CHECK_IN_MAX_ACCURACY_METERS + 1 }), poi).code)
|
||||||
|
.toBe('low_accuracy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies conservative distance and accuracy boundary comparisons', () => {
|
||||||
|
const offsetLocation = location({ latitude: poi.latitude + 0.001, accuracy: 20 })
|
||||||
|
const distance = distanceBetweenMeters(offsetLocation, poi)
|
||||||
|
|
||||||
|
expect(evaluateCheckInEligibility(offsetLocation, poi, distance + 20).code).toBe('eligible')
|
||||||
|
expect(evaluateCheckInEligibility(offsetLocation, poi, distance + 20 - 0.001).code).toBe('uncertain')
|
||||||
|
expect(evaluateCheckInEligibility(offsetLocation, poi, distance - 20).code).toBe('uncertain')
|
||||||
|
expect(evaluateCheckInEligibility(offsetLocation, poi, distance - 20 - 0.001).code).toBe('too_far')
|
||||||
|
expect(evaluateCheckInEligibility(location({ accuracy: CHECK_IN_MAX_ACCURACY_METERS }), poi).code)
|
||||||
|
.toBe('eligible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('awards points and threshold badges without persisting exact coordinates', () => {
|
||||||
|
const now = Date.parse('2026-07-31T02:00:20.000Z')
|
||||||
|
const result = applyCheckIn(createEmptyCheckInProfile(), poi, location(), now)
|
||||||
|
|
||||||
|
expect(result.profile.points).toBe(10)
|
||||||
|
expect(result.profile.badges.map(badge => badge.code)).toEqual(['explorer'])
|
||||||
|
expect(result.record).toMatchObject({ poiId: poi.id, pointsAwarded: 10 })
|
||||||
|
expect(JSON.stringify(result.profile)).not.toContain(String(poi.longitude))
|
||||||
|
expect(() => applyCheckIn(result.profile, poi, location(), now + 1000)).toThrow('不会重复计分')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the backwards-compatible 1/2/3 cumulative badge thresholds', () => {
|
||||||
|
expect(CHECK_IN_BADGES.map(badge => badge.requiredCheckIns)).toEqual([1, 2, 3])
|
||||||
|
|
||||||
|
let profile = createEmptyCheckInProfile()
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
const result = applyCheckIn(
|
||||||
|
profile,
|
||||||
|
{ ...poi, id: `poi_${index}`, name: `点位 ${index}` },
|
||||||
|
location(),
|
||||||
|
Date.parse('2026-07-31T02:00:20.000Z') + index,
|
||||||
|
)
|
||||||
|
profile = result.profile
|
||||||
|
}
|
||||||
|
expect(profile.badges.map(badge => badge.code)).toEqual(['explorer', 'traveller', 'check_in_master'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed profiles and inconsistent point totals', () => {
|
||||||
|
const result = applyCheckIn(
|
||||||
|
createEmptyCheckInProfile(),
|
||||||
|
poi,
|
||||||
|
location(),
|
||||||
|
Date.parse('2026-07-31T02:00:20.000Z'),
|
||||||
|
)
|
||||||
|
expect(normalizeCheckInProfile({ ...result.profile, points: 999 })).toBeNull()
|
||||||
|
expect(normalizeCheckInProfile({ ...result.profile, badges: [...result.profile.badges, result.profile.badges[0]] })).toBeNull()
|
||||||
|
expect(normalizeCheckInProfile({
|
||||||
|
...result.profile,
|
||||||
|
checkIns: [{ ...result.profile.checkIns[0], longitude: 113.94 }],
|
||||||
|
})).toBeNull()
|
||||||
|
expect(normalizeCheckInProfile({ ...result.profile, location: location() })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('check-in storage', () => {
|
||||||
|
const values = new Map<string, unknown>()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
values.clear()
|
||||||
|
vi.mocked(uni.getStorageSync).mockImplementation(key => values.get(key))
|
||||||
|
vi.mocked(uni.setStorageSync).mockImplementation((key, value) => values.set(key, value))
|
||||||
|
vi.mocked(uni.removeStorageSync).mockImplementation(key => values.delete(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows the service gate to check in at every published POI', () => {
|
||||||
|
const publishedPois = getPoiRepository().getPublishedPois()
|
||||||
|
|
||||||
|
publishedPois.forEach((publishedPoi, index) => {
|
||||||
|
values.clear()
|
||||||
|
const result = checkInAtPoi(
|
||||||
|
publishedPoi.id,
|
||||||
|
location({
|
||||||
|
longitude: publishedPoi.longitude,
|
||||||
|
latitude: publishedPoi.latitude,
|
||||||
|
}),
|
||||||
|
Date.parse('2026-07-31T02:00:20.000Z') + index,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.record.poiId).toBe(publishedPoi.id)
|
||||||
|
expect(loadCheckInProfile().checkIns[0].poiId).toBe(publishedPoi.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unknown or retired POI at the service gate', () => {
|
||||||
|
const publishedPoi = getPoiRepository().getPublishedPois()[0]
|
||||||
|
|
||||||
|
expect(() => checkInAtPoi(
|
||||||
|
'poi_not_published',
|
||||||
|
location({ longitude: publishedPoi.longitude, latitude: publishedPoi.latitude }),
|
||||||
|
)).toThrow('不存在或已下线')
|
||||||
|
expect(values.has(CHECK_IN_STORAGE_KEY)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses canonical repository coordinates at the service boundary', () => {
|
||||||
|
const publishedPoi = getPoiRepository().getPublishedPois()[0]
|
||||||
|
|
||||||
|
expect(() => checkInAtPoi(
|
||||||
|
publishedPoi.id,
|
||||||
|
location({ longitude: publishedPoi.longitude + 0.01, latitude: publishedPoi.latitude }),
|
||||||
|
)).toThrow('请到点位')
|
||||||
|
expect(values.has(CHECK_IN_STORAGE_KEY)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips only the dedicated anonymous profile key', () => {
|
||||||
|
const result = applyCheckIn(
|
||||||
|
createEmptyCheckInProfile(),
|
||||||
|
poi,
|
||||||
|
location(),
|
||||||
|
Date.parse('2026-07-31T02:00:20.000Z'),
|
||||||
|
)
|
||||||
|
saveCheckInProfile(result.profile)
|
||||||
|
|
||||||
|
const loaded = loadCheckInProfile()
|
||||||
|
loaded.checkIns[0].poiName = 'changed'
|
||||||
|
expect(loadCheckInProfile().checkIns[0].poiName).toBe('虹桥公园')
|
||||||
|
expect([...values.keys()]).toEqual([CHECK_IN_STORAGE_KEY])
|
||||||
|
expect(JSON.stringify(values.get(CHECK_IN_STORAGE_KEY))).not.toContain(String(poi.longitude))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed for malformed reads and clears only check-in data', () => {
|
||||||
|
values.set(CHECK_IN_STORAGE_KEY, { broken: true })
|
||||||
|
values.set('guangming:last-plan', { keep: true })
|
||||||
|
expect(() => loadCheckInProfile()).toThrow('本机打卡记录异常')
|
||||||
|
|
||||||
|
clearCheckInProfile()
|
||||||
|
expect(values.has(CHECK_IN_STORAGE_KEY)).toBe(false)
|
||||||
|
expect(values.has('guangming:last-plan')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown storage, envelope, and badge fields', () => {
|
||||||
|
const savedAt = '2026-07-31T02:00:20.000Z'
|
||||||
|
values.set(CHECK_IN_STORAGE_KEY, { schemaVersion: 99, savedAt, payload: createEmptyCheckInProfile() })
|
||||||
|
expect(() => loadCheckInProfile()).toThrow('本机打卡记录异常')
|
||||||
|
|
||||||
|
values.set(CHECK_IN_STORAGE_KEY, {
|
||||||
|
schemaVersion: 2,
|
||||||
|
savedAt,
|
||||||
|
payload: createEmptyCheckInProfile(),
|
||||||
|
longitude: 113.94,
|
||||||
|
})
|
||||||
|
expect(() => loadCheckInProfile()).toThrow('本机打卡记录异常')
|
||||||
|
|
||||||
|
const checkedInAt = savedAt
|
||||||
|
values.set(CHECK_IN_STORAGE_KEY, {
|
||||||
|
schemaVersion: 2,
|
||||||
|
savedAt,
|
||||||
|
payload: {
|
||||||
|
points: 10,
|
||||||
|
checkIns: [{
|
||||||
|
id: createCheckInRecordId(poi.id, checkedInAt),
|
||||||
|
poiId: poi.id,
|
||||||
|
poiName: poi.name,
|
||||||
|
checkedInAt,
|
||||||
|
pointsAwarded: 10,
|
||||||
|
}],
|
||||||
|
badges: [{ code: 'explorer', obtainedAt: checkedInAt, accuracy: 20 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(() => loadCheckInProfile()).toThrow('本机打卡记录异常')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves valid v2 history when a POI is no longer enabled', () => {
|
||||||
|
const checkedInAt = '2026-07-31T02:00:20.000Z'
|
||||||
|
const legacyPoiId = 'poi_legacy_hangzhou'
|
||||||
|
values.set(CHECK_IN_STORAGE_KEY, {
|
||||||
|
schemaVersion: 2,
|
||||||
|
savedAt: checkedInAt,
|
||||||
|
payload: {
|
||||||
|
points: 10,
|
||||||
|
checkIns: [{
|
||||||
|
id: createCheckInRecordId(legacyPoiId, checkedInAt),
|
||||||
|
poiId: legacyPoiId,
|
||||||
|
poiName: '旧点位',
|
||||||
|
checkedInAt,
|
||||||
|
pointsAwarded: 10,
|
||||||
|
}],
|
||||||
|
badges: [{ code: 'explorer', obtainedAt: checkedInAt }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
points: 10,
|
||||||
|
badges: [{ code: 'explorer', obtainedAt: checkedInAt }],
|
||||||
|
checkIns: [{
|
||||||
|
id: createCheckInRecordId(legacyPoiId, checkedInAt),
|
||||||
|
poiId: legacyPoiId,
|
||||||
|
poiName: '旧点位',
|
||||||
|
checkedInAt,
|
||||||
|
pointsAwarded: 10,
|
||||||
|
}],
|
||||||
|
} as const
|
||||||
|
expect(loadCheckInProfile()).toEqual(profile)
|
||||||
|
expect(() => saveCheckInProfile(profile)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrates legacy data to the current published POIs and badge rules', () => {
|
||||||
|
const firstTime = '2026-07-31T02:00:20.000Z'
|
||||||
|
const secondTime = '2026-07-31T03:00:20.000Z'
|
||||||
|
values.set(CHECK_IN_STORAGE_KEY, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
savedAt: secondTime,
|
||||||
|
payload: {
|
||||||
|
points: 30,
|
||||||
|
badges: [{ code: 'explorer', obtainedAt: firstTime }],
|
||||||
|
checkIns: [
|
||||||
|
{ poiId: 'poi_hongqiao_park', poiName: '虹桥公园', checkedInAt: firstTime },
|
||||||
|
{ poiId: 'poi_gm_culture_center', poiName: '光明文化艺术中心', checkedInAt: secondTime },
|
||||||
|
{ poiId: 'poi_legacy_hangzhou', poiName: '旧点位', checkedInAt: secondTime },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const migrated = loadCheckInProfile()
|
||||||
|
expect(migrated.points).toBe(20)
|
||||||
|
expect(migrated.checkIns.map(record => record.poiId)).toEqual([
|
||||||
|
'poi_gm_culture_center',
|
||||||
|
'poi_hongqiao_park',
|
||||||
|
])
|
||||||
|
expect(migrated.badges.map(badge => badge.code)).toEqual(['explorer', 'traveller'])
|
||||||
|
expect(values.get(CHECK_IN_STORAGE_KEY)).toMatchObject({ schemaVersion: 2, payload: migrated })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not report success when the atomic profile write fails', () => {
|
||||||
|
const result = applyCheckIn(
|
||||||
|
createEmptyCheckInProfile(),
|
||||||
|
poi,
|
||||||
|
location(),
|
||||||
|
Date.parse('2026-07-31T02:00:20.000Z'),
|
||||||
|
)
|
||||||
|
vi.mocked(uni.setStorageSync).mockImplementation(() => {
|
||||||
|
throw new Error('quota exceeded')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => saveCheckInProfile(result.profile)).toThrow('无法保存本机打卡记录:quota exceeded')
|
||||||
|
expect(values.has(CHECK_IN_STORAGE_KEY)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getCurrentGcj02Location, isLocationPermissionDenied } from '../src/services/location'
|
||||||
|
|
||||||
|
describe('current location adapter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
Object.assign(uni, {
|
||||||
|
canIUse: vi.fn(() => true),
|
||||||
|
getLocation: vi.fn(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requests a new high-accuracy GCJ-02 sample', async () => {
|
||||||
|
vi.mocked(uni.getLocation).mockImplementation((options) => {
|
||||||
|
options.success?.({ longitude: 113.94, latitude: 22.76, accuracy: 18 } as UniNamespace.GetLocationSuccess)
|
||||||
|
return undefined as never
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(getCurrentGcj02Location()).resolves.toMatchObject({
|
||||||
|
longitude: 113.94,
|
||||||
|
latitude: 22.76,
|
||||||
|
accuracy: 18,
|
||||||
|
receivedAt: expect.any(String),
|
||||||
|
coordinateSystem: 'GCJ02',
|
||||||
|
})
|
||||||
|
expect(uni.getLocation).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'gcj02',
|
||||||
|
isHighAccuracy: true,
|
||||||
|
highAccuracyExpireTime: 8000,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a separate native request for every location lookup', async () => {
|
||||||
|
vi.mocked(uni.getLocation).mockImplementation((options) => {
|
||||||
|
options.success?.({ longitude: 113.94, latitude: 22.76, accuracy: 18 } as UniNamespace.GetLocationSuccess)
|
||||||
|
return undefined as never
|
||||||
|
})
|
||||||
|
|
||||||
|
await getCurrentGcj02Location()
|
||||||
|
await getCurrentGcj02Location()
|
||||||
|
expect(uni.getLocation).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the basic object shape on older base libraries', async () => {
|
||||||
|
vi.mocked(uni.canIUse).mockReturnValue(false)
|
||||||
|
vi.mocked(uni.getLocation).mockImplementation((options) => {
|
||||||
|
options.success?.({ longitude: 113.94, latitude: 22.76, accuracy: 20 } as UniNamespace.GetLocationSuccess)
|
||||||
|
return undefined as never
|
||||||
|
})
|
||||||
|
|
||||||
|
await getCurrentGcj02Location()
|
||||||
|
expect(uni.getLocation).toHaveBeenCalledWith(expect.not.objectContaining({ isHighAccuracy: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('times out once and ignores a late callback', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
let success: ((result: UniNamespace.GetLocationSuccess) => void) | undefined
|
||||||
|
vi.mocked(uni.getLocation).mockImplementation((options) => {
|
||||||
|
success = options.success
|
||||||
|
return undefined as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = getCurrentGcj02Location({ timeoutMs: 3000 })
|
||||||
|
const rejection = expect(request).rejects.toThrow('定位超时')
|
||||||
|
await vi.advanceTimersByTimeAsync(3001)
|
||||||
|
await rejection
|
||||||
|
success?.({ longitude: 113.94, latitude: 22.76, accuracy: 20 } as UniNamespace.GetLocationSuccess)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('identifies WeChat permission denials', () => {
|
||||||
|
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail auth deny' })).toBe(true)
|
||||||
|
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail privacy permission is not authorized' })).toBe(false)
|
||||||
|
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail system error' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,6 +18,15 @@ describe('map session store', () => {
|
|||||||
expect(store.viewport).toEqual({ longitude: 113.94, latitude: 22.76, scale: 13 })
|
expect(store.viewport).toEqual({ longitude: 113.94, latitude: 22.76, scale: 13 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows and clears the planned-route overlay explicitly', () => {
|
||||||
|
const store = useMapStore()
|
||||||
|
expect(store.plannedRouteVisible).toBe(false)
|
||||||
|
store.showPlannedRoute()
|
||||||
|
expect(store.plannedRouteVisible).toBe(true)
|
||||||
|
store.hidePlannedRoute()
|
||||||
|
expect(store.plannedRouteVisible).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores invalid viewport updates', () => {
|
it('ignores invalid viewport updates', () => {
|
||||||
const store = useMapStore()
|
const store = useMapStore()
|
||||||
store.updateViewport({ longitude: Number.NaN, latitude: 22.76, scale: 13 })
|
store.updateViewport({ longitude: Number.NaN, latitude: 22.76, scale: 13 })
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { vi } from 'vitest'
|
|||||||
import { unref } from 'vue'
|
import { unref } from 'vue'
|
||||||
|
|
||||||
const uniMock = {
|
const uniMock = {
|
||||||
|
canIUse: vi.fn(),
|
||||||
|
getLocation: vi.fn(),
|
||||||
getStorageSync: vi.fn(),
|
getStorageSync: vi.fn(),
|
||||||
getSystemInfoSync: vi.fn(() => ({ uniPlatform: 'mp-weixin' })),
|
getSystemInfoSync: vi.fn(() => ({ uniPlatform: 'mp-weixin' })),
|
||||||
|
removeStorageSync: vi.fn(),
|
||||||
|
setStorageSync: vi.fn(),
|
||||||
setStorage: vi.fn(),
|
setStorage: vi.fn(),
|
||||||
showToast: vi.fn(),
|
showToast: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
normalizePlanningRequest,
|
normalizePlanningRequest,
|
||||||
normalizePlanResponse,
|
normalizePlanResponse,
|
||||||
normalizeTravelPreferences,
|
normalizeTravelPreferences,
|
||||||
|
type Pace,
|
||||||
type PlanningRequest,
|
type PlanningRequest,
|
||||||
type PlanResponse,
|
type PlanResponse,
|
||||||
resolveCanonicalPoiId,
|
resolveCanonicalPoiId,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from '../src/domain/travel'
|
} from '../src/domain/travel'
|
||||||
import {
|
import {
|
||||||
createPlan,
|
createPlan,
|
||||||
|
getSessionPlanningOrigin,
|
||||||
loadPlan,
|
loadPlan,
|
||||||
loadPlanningRequest,
|
loadPlanningRequest,
|
||||||
loadPreferences,
|
loadPreferences,
|
||||||
@@ -191,6 +193,44 @@ describe('canonical POI IDs', () => {
|
|||||||
describe('deterministic local POC planner', () => {
|
describe('deterministic local POC planner', () => {
|
||||||
const repository = getPoiRepository()
|
const repository = getPoiRepository()
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[120, 'relaxed', 1],
|
||||||
|
[120, 'moderate', 1],
|
||||||
|
[120, 'compact', 2],
|
||||||
|
[240, 'relaxed', 3],
|
||||||
|
[240, 'moderate', 3],
|
||||||
|
[240, 'compact', 3],
|
||||||
|
[480, 'relaxed', 4],
|
||||||
|
[480, 'moderate', 5],
|
||||||
|
[480, 'compact', 6],
|
||||||
|
[600, 'relaxed', 5],
|
||||||
|
[600, 'moderate', 6],
|
||||||
|
[600, 'compact', 7],
|
||||||
|
] as const)('packs %i-minute %s quick plans into %i POIs without overflow', (durationMinutes, pace, expectedCount) => {
|
||||||
|
const plan = createLocalPlan({
|
||||||
|
mode: 'quick',
|
||||||
|
preferences: preferences({ pace: pace as Pace }),
|
||||||
|
durationMinutes,
|
||||||
|
selectedPoiIds: [],
|
||||||
|
}, repository)
|
||||||
|
|
||||||
|
expect(plan.itinerary.items).toHaveLength(expectedCount)
|
||||||
|
expect(plan.itinerary.items.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(plan.itinerary.items.length).toBeLessThanOrEqual(8)
|
||||||
|
expect(plan.itinerary.totalMinutes).toBeLessThanOrEqual(durationMinutes)
|
||||||
|
expect(plan.itinerary.durationFitStatus).not.toBe('overflow')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a quick plan when even the first stop cannot fit the budget', () => {
|
||||||
|
expect(() => createLocalPlan({
|
||||||
|
mode: 'quick',
|
||||||
|
preferences: preferences({ pace: 'relaxed', transport: 'walking' }),
|
||||||
|
durationMinutes: 120,
|
||||||
|
selectedPoiIds: [],
|
||||||
|
origin: { longitude: 114.3, latitude: 22.3, coordinateSystem: 'GCJ02' },
|
||||||
|
}, repository)).toThrow('没有可用于规划的地点')
|
||||||
|
})
|
||||||
|
|
||||||
it('returns the same route and canonical IDs for equivalent input', () => {
|
it('returns the same route and canonical IDs for equivalent input', () => {
|
||||||
const first = createLocalPlan(preferences({ themes: ['研学', '亲子'], interests: ['摄影', '自然风光'] }), repository)
|
const first = createLocalPlan(preferences({ themes: ['研学', '亲子'], interests: ['摄影', '自然风光'] }), repository)
|
||||||
const second = createLocalPlan(preferences({ themes: ['亲子', '研学'], interests: ['自然风光', '摄影'] }), repository)
|
const second = createLocalPlan(preferences({ themes: ['亲子', '研学'], interests: ['自然风光', '摄影'] }), repository)
|
||||||
@@ -302,6 +342,22 @@ describe('deterministic local POC planner', () => {
|
|||||||
expect(request).not.toHaveBeenCalled()
|
expect(request).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exposes the exact origin only from the current in-memory planning session', async () => {
|
||||||
|
const plan = await createPlan(preferences(), {
|
||||||
|
longitude: 113.94,
|
||||||
|
latitude: 22.76,
|
||||||
|
coordinateSystem: 'GCJ02',
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = getSessionPlanningOrigin(plan.conversationId)
|
||||||
|
expect(first).toEqual({ longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' })
|
||||||
|
if (first)
|
||||||
|
first.longitude = 0
|
||||||
|
expect(getSessionPlanningOrigin(plan.conversationId)?.longitude).toBe(113.94)
|
||||||
|
expect(getSessionPlanningOrigin('unknown-session')).toBeNull()
|
||||||
|
expect(JSON.stringify(loadPlanningRequest())).not.toContain('113.94')
|
||||||
|
})
|
||||||
|
|
||||||
it('preserves all custom selections, reports overflow, and retains them during adjustment', () => {
|
it('preserves all custom selections, reports overflow, and retains them during adjustment', () => {
|
||||||
const selectedPoiIds = repository.getPublishedPois().slice(0, 8).map(poi => poi.id)
|
const selectedPoiIds = repository.getPublishedPois().slice(0, 8).map(poi => poi.id)
|
||||||
const request: PlanningRequest = {
|
const request: PlanningRequest = {
|
||||||
@@ -484,6 +540,14 @@ describe('travel storage', () => {
|
|||||||
expect(loadPlan()).toBeNull()
|
expect(loadPlan()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not restore remote plans while AI planning is frozen', () => {
|
||||||
|
const plan = createLocalPlan(preferences(), getPoiRepository())
|
||||||
|
const remotePlan = { ...plan, source: 'remote_ai' as const }
|
||||||
|
values.set(PLAN_STORAGE_KEY, storageEnvelope(remotePlan))
|
||||||
|
|
||||||
|
expect(loadPlan()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('contains storage read failures and reports write failures', () => {
|
it('contains storage read failures and reports write failures', () => {
|
||||||
vi.mocked(uni.getStorageSync).mockImplementation(() => {
|
vi.mocked(uni.getStorageSync).mockImplementation(() => {
|
||||||
throw new Error('read failed')
|
throw new Error('read failed')
|
||||||
|
|||||||
Vendored
+2
@@ -6,6 +6,8 @@
|
|||||||
interface NavigateToOptions {
|
interface NavigateToOptions {
|
||||||
url: "/pages/map/index" |
|
url: "/pages/map/index" |
|
||||||
"/pages/assistant/index" |
|
"/pages/assistant/index" |
|
||||||
|
"/pages/check-in/index" |
|
||||||
|
"/pages/check-in/records" |
|
||||||
"/pages/itinerary/index" |
|
"/pages/itinerary/index" |
|
||||||
"/pages/planner/index" |
|
"/pages/planner/index" |
|
||||||
"/pages/poi/detail";
|
"/pages/poi/detail";
|
||||||
|
|||||||
Reference in New Issue
Block a user