diff --git a/README.md b/README.md index 27b8841..073adf0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 光明文旅地图 POC -基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅问答式偏好收集和行程规划。 +基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅偏好收集、行程规划和真实定位到点打卡。 -当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。路线规划支持基于当前位置的快速规划,以及用户自选 2–8 个 POI、2–10 小时时长的自定义规划;本地规划器按时间预算动态排程,配置服务端后可由受控 AI 在审核白名单内推荐点位与顺序。当前不是道路级实时导航,也不包含打卡、登录或个人中心。 +当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、文旅偏好收集、推荐行程展示和到点打卡。路线规划支持基于当前位置的快速规划,以及用户自选 2–8 个 POI、2–10 小时时长的自定义规划;当前交付统一使用本地规划器按时间预算动态排程。打卡使用用户每次主动触发的位置请求结果核验到点范围,并在本机记录积分、徽章和足迹;当前不是道路级实时导航,也不包含登录或云端个人中心。 ## 微信开发者工具 @@ -31,9 +31,9 @@ macOS 已安装微信开发者工具时,也可在另一个终端执行 `corepa corepack pnpm build:mp-weixin ``` -开发编译后可运行 `corepack pnpm verify:mp-weixin`,检查微信工程配置、启动页、五个业务页面、三个 tabBar 入口、六个图标和页面四件套是否完整。 +开发编译后可运行 `corepack pnpm verify:mp-weixin`,检查微信工程配置、启动页、七个业务页面、三个 tabBar 入口、六个图标和页面四件套是否完整。 -生产产物位于 `dist/build/mp-weixin`。微信 AppID 通过未提交的 `.env.local` 中 `UNI_MP_WEIXIN_APPID` 配置;根目录 IDE 配置暂用 `touristappid`,避免误用模板中的其他项目身份。游客身份只能本地编译,真机预览或上传前必须同时把 `project.config.json` 的 `appid` 换成项目方确认的真实 AppID。详细说明见 [微信小程序构建与验收说明](./docs/微信小程序构建与验收说明.md)。 +生产产物位于 `dist/build/mp-weixin`。微信 AppID 通过未提交的 `.env.local` 中 `UNI_MP_WEIXIN_APPID` 配置;根目录 `project.config.json` 也必须使用同一个项目 AppID。详细说明见 [微信小程序构建与验收说明](./docs/微信小程序构建与验收说明.md)。 生产验收时可直接导入 `dist/build/mp-weixin`,macOS 也可运行 `corepack pnpm ide:mp-weixin:build`。根目录始终用于开发模式,不会因执行生产构建而自动切换到 `dist/build/mp-weixin`。 @@ -42,10 +42,11 @@ corepack pnpm build:mp-weixin ```text src/ ├── pages/map/index.vue # 小程序首页:全域地图 -├── pages/assistant/index.vue # AI 文旅助手:偏好快捷入口 +├── pages/assistant/index.vue # 文旅助手:偏好快捷入口 ├── pages/planner/index.vue # 行程偏好表单 ├── pages/itinerary/index.vue # 推荐路线与地图展示 ├── pages/poi/detail.vue # POI 详情 +├── pages/check-in/ # 真实定位打卡、积分、徽章与足迹 ├── components/map/ # 地图筛选和摘要组件 ├── components/poi/ # POI 图片、标签、状态和图集组件 ├── components/travel/ # 行程点位展示组件 @@ -58,9 +59,19 @@ src/ └── static/ # 随包静态资源 ``` -不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序使用本地确定性 POC 规划器。配置受控后端地址后,服务端 AI 只接收偏好和不含坐标的 POI 白名单,返回推荐 ID、理由和顺序;当前位置、交通估算、时长适配及最终路线仍由小程序本机计算。服务端默认 `LLM_MODE=mock`,只有部署时设置 `LLM_MODE=real` 及模型凭据才会标记为真实 AI。微信公众平台还需配置 HTTPS 合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。 +## AI 后续接入说明 -实时位置仅由用户主动触发,并只在地图页处于前台时持续更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存,也不会发送给远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》。 +本期 AI 能力留空,不纳入交付和验收。当前 `createPlan` 业务入口固定走本地规划器,即使误配 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 也不会发起模型请求;页面展示的“本地 POC 规划”是确定性排程,不代表大模型生成,旧版本遗留的远程路线缓存也不会在本期恢复。 + +仓库保留了可选的 `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 的 ``,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。 diff --git a/docs/微信小程序构建与验收说明.md b/docs/微信小程序构建与验收说明.md index d7c1b42..7e5c338 100644 --- a/docs/微信小程序构建与验收说明.md +++ b/docs/微信小程序构建与验收说明.md @@ -2,11 +2,11 @@ ## 1. 当前交付边界 -- 本项目是光明区文旅全域地图、POI 数字化展示、AI 文旅助手与行程规划 POC。 +- 本项目是光明区文旅全域地图、POI 数字化展示、本地行程规划与真实定位打卡 POC;AI 模型接入留待后续版本。 - 目标端为微信小程序,当前只要求在一台实际 iOS 设备验证。 -- 用户主动授权后,地图可显示前台实时位置;行程会用本次位置快照优化首站和游览顺序。 +- 用户主动授权后,地图可显示前台实时位置;当前位置快速规划要求使用 5 分钟内的位置快照优化首站和游览顺序。 - 行程由随包 POI 和确定性规则生成,用于验证推荐链路,不提供道路级实时路况、路径导航或到达承诺。 -- 不包含打卡、登录或个人中心;匿名偏好和最近行程保存在本机,精确位置只保留在内存会话中。 +- 不包含登录或云端个人中心;匿名偏好、最近行程、打卡积分、徽章与足迹保存在本机,精确位置只用于内存中的本次判定。 - 当前随包数据明确标注为 `POC 审核样本`,不能宣称已覆盖光明区全部文旅资源。 ## 2. 地图和高德能力说明 @@ -26,8 +26,8 @@ 1. 复制 `.env.example` 为 `.env.local`。 2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。 -3. 真机预览或上传前,将根目录 `project.config.json` 的 `appid` 从 `touristappid` 同步为相同的真实 AppID。 -4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。服务端必须设置 `LLM_MODE=real` 才会作为真实 AI 展示;mock 仅用于联调演示。 +3. 真机预览或上传前,确认根目录 `project.config.json` 的 `appid` 与相同的真实 AppID 一致。 +4. 本期 `createPlan` 已固定使用本地 POC 规划器,`server/` 及远程适配层仅作为后续 AI 接入预留,不纳入本期验收。 5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。 `project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。 @@ -48,7 +48,9 @@ corepack pnpm build:mp-weixin 项目的微信开发与生产脚本默认对文件监听使用轮询,用于规避部分 macOS 会话中 `EMFILE: too many open files, watch` 问题。这不改变构建产物,但开发模式的本机资源占用会略有增加。 -开发模式启动后可运行 `corepack pnpm verify:mp-weixin`,自动检查根目录 IDE 配置、启动页、五个业务页面、三个 tabBar 入口、图标和微信页面四件套是否完整。 +微信产物启用了 `lazyCodeLoading: "requiredComponents"`,页面只注入实际使用的自定义组件,以减少小程序启动开销。该能力需要微信基础库 2.11.1 及以上;目标基础库尚未冻结时,应在 iOS 真机验收所用微信版本中确认兼容性。 + +开发模式启动后可运行 `corepack pnpm verify:mp-weixin`,自动检查根目录 IDE 配置、启动页、七个业务页面、三个 tabBar 入口、图标和微信页面四件套是否完整。 开发模式产物位于 `dist/dev/mp-weixin`;根目录 `project.config.json` 已将微信开发者工具的 `miniprogramRoot` 和 `srcMiniprogramRoot` 指向该目录。先运行开发编译,再用微信开发者工具直接导入项目根目录。 @@ -78,9 +80,12 @@ macOS 可在另一个终端执行 `corepack pnpm ide:mp-weixin` 调起已安装 5. 返回地图后恢复分类、中心、缩放和选中点位。 6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。 7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。 -8. 从“AI 助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。 -9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。 +8. 从“文旅助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。 +9. 关闭网络后验证本地规划仍可生成;确认当前业务入口不调用远程 AI,客户端产物不含模型凭证。 10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。 -11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。 +11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储不包含精确坐标,且调试面板没有远程规划请求。 +12. 确认全部已发布 POI 的详情页均展示打卡入口,未发布或已下线 POI 不开放新打卡;从详情进入打卡页后,仅在点击按钮时重新请求一次位置,分别验证隐私授权、200 米范围内成功、范围外、精度不足、边界不确定和重复打卡。 +13. 检查首次打卡增加 10 积分并解锁首枚徽章,重启后记录仍存在;“清除本机打卡数据”不得删除路线或偏好。 +14. 扫描本机打卡存储,确认不包含经纬度、定位精度或轨迹;开发者工具模拟位置仅用于流程测试,正式成功验收必须使用 iOS 真机现场定位。 在项目方尚未提供正式 AppID、真实清权图片和冻结基准清单时,只能判定“样本链路通过”,不能判定正式内容或全域覆盖通过。 diff --git a/manifest.config.ts b/manifest.config.ts index 630be42..006f792 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -10,9 +10,9 @@ const weixinAppId = manifestEnv.UNI_MP_WEIXIN_APPID || 'touristappid' export default defineManifestConfig({ 'name': '光明文旅地图', 'appid': '', - 'description': '深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC', - 'versionName': '0.2.0', - 'versionCode': '20', + 'description': '深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC', + 'versionName': '0.3.0', + 'versionCode': '30', 'transformPx': false, 'uniStatistics': { enable: false, @@ -69,9 +69,10 @@ export default defineManifestConfig({ 'mp-weixin': { // Configure locally with UNI_MP_WEIXIN_APPID; do not commit project credentials. appid: weixinAppId, + lazyCodeLoading: 'requiredComponents', permission: { 'scope.userLocation': { - desc: '用于在全域地图显示实时位置,并优化文旅行程顺序', + desc: '用于地图定位、行程优化和到点打卡判断', }, }, requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'], diff --git a/pages.config.ts b/pages.config.ts index 408ddf0..aea5e1d 100644 --- a/pages.config.ts +++ b/pages.config.ts @@ -5,10 +5,12 @@ export default defineUniPages({ // uni-app compiler drops their manifest equivalents. permission: { 'scope.userLocation': { - desc: '用于在全域地图显示实时位置,并优化文旅行程顺序', + desc: '用于地图定位、行程优化和到点打卡判断', }, }, requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'], + // 微信小程序仅在页面实际使用组件时注入对应代码,减少启动开销。 + lazyCodeLoading: 'requiredComponents', easycom: { // 注意一定要放在custom里,否则无效,https://ask.dcloud.net.cn/question/131175 custom: { @@ -43,7 +45,7 @@ export default defineUniPages({ }, { pagePath: 'pages/assistant/index', - text: 'AI 助手', + text: '文旅助手', iconPath: 'static/tabbar/assistant.png', selectedIconPath: 'static/tabbar/assistant-selected.png', }, diff --git a/project.config.json b/project.config.json index 3dff548..93967fa 100644 --- a/project.config.json +++ b/project.config.json @@ -1,7 +1,7 @@ { "miniprogramRoot": "dist/dev/mp-weixin/", "projectname": "光明文旅地图", - "description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC", + "description": "深圳市光明区全域文旅地图、POI 数字化展示与本地行程规划 POC", "setting": { "es6": true, "postcss": true, diff --git a/scripts/verify-weixin-output.mjs b/scripts/verify-weixin-output.mjs index c188d3c..b64c77b 100644 --- a/scripts/verify-weixin-output.mjs +++ b/scripts/verify-weixin-output.mjs @@ -11,6 +11,8 @@ const expectedPages = [ 'pages/itinerary/index', 'pages/planner/index', 'pages/poi/detail', + 'pages/check-in/index', + 'pages/check-in/records', ] const expectedTabPages = [ 'pages/map/index', @@ -42,6 +44,29 @@ if (missingFiles.length > 0) { } const appConfig = JSON.parse(readFileSync(resolve(outputRoot, 'app.json'), 'utf8')) +if (appConfig.lazyCodeLoading !== 'requiredComponents') { + console.error(`组件按需注入配置错误:期望 requiredComponents,实际为 ${appConfig.lazyCodeLoading ?? '未配置'}`) + process.exit(1) +} +const checkInPageScript = readFileSync(resolve(outputRoot, 'pages/check-in/index.js'), 'utf8') +const checkInPageMarkup = readFileSync(resolve(outputRoot, 'pages/check-in/index.wxml'), 'utf8') +if (!checkInPageScript.includes('requirePrivacyAuthorize') + || !checkInPageMarkup.includes('open-type="agreePrivacyAuthorization"')) { + console.error('打卡页缺少微信位置隐私授权兼容链。') + process.exit(1) +} +const poiDetailMarkup = readFileSync(resolve(outputRoot, 'pages/poi/detail.wxml'), 'utf8') +const checkInEntryIndex = poiDetailMarkup.indexOf('check-in-entry') +const checkInEntryStart = poiDetailMarkup.lastIndexOf('', checkInEntryIndex) +const checkInEntryTag = checkInEntryIndex >= 0 && checkInEntryStart >= 0 && checkInEntryEnd >= 0 + ? poiDetailMarkup.slice(checkInEntryStart, checkInEntryEnd + 1) + : '' +const visibilityAttributes = ['wx:if=', 'wx:elif=', 'wx:else', 'hidden='] +if (!checkInEntryTag || visibilityAttributes.some(attribute => checkInEntryTag.includes(attribute))) { + console.error('景点详情页必须无条件渲染打卡入口。') + process.exit(1) +} const expectedPrivateInfos = ['getLocation', 'startLocationUpdate', 'onLocationChange'] const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? [] if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) { @@ -55,6 +80,10 @@ if (locationPermissionDescriptionLength < 1 || locationPermissionDescriptionLeng console.error('app.json 缺少 scope.userLocation 用途说明。') process.exit(1) } +if (!locationPermissionDescription.includes('打卡')) { + console.error('scope.userLocation 用途说明未覆盖到点打卡。') + process.exit(1) +} const missingPages = expectedPages.filter(page => !appConfig.pages?.includes(page)) if (missingPages.length > 0) { diff --git a/server/README.md b/server/README.md index ccd4f00..c7ae546 100644 --- a/server/README.md +++ b/server/README.md @@ -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 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 `POST /api/v1/plans` accepts a planning request and a coordinate-free POI whitelist: @@ -12,15 +14,9 @@ This FastAPI service is the server-only AI boundary for the Guangming travel POC "preferences": { "destination": "深圳市光明区", "themes": ["亲子"], - "duration": "half_day", "pace": "moderate", "interests": ["自然风光"], - "adults": 2, - "children": 1, - "childAges": [6], - "transport": "public_transport", - "budgetLevel": "standard", - "extraRequirements": "" + "transport": "public_transport" }, "durationMinutes": 240, "selectedPoiIds": ["poi_a", "poi_b"], diff --git a/server/app/knowledge.py b/server/app/knowledge.py index d490a5d..b882537 100644 --- a/server/app/knowledge.py +++ b/server/app/knowledge.py @@ -28,8 +28,6 @@ def _score(candidate: PoiCandidate, request: PlanRecommendationRequest) -> int: for interest in request.preferences.interests: desired_tags.update(INTEREST_TAGS.get(interest, set())) score += len(desired_tags.intersection(candidate.tag_codes)) * 8 - if request.preferences.children and "family_friendly" in candidate.tag_codes: - score += 8 return score diff --git a/server/app/schemas.py b/server/app/schemas.py index f1d77a4..ec8b66b 100644 --- a/server/app/schemas.py +++ b/server/app/schemas.py @@ -55,24 +55,12 @@ Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", " class TravelPreferences(APIModel): destination: Literal["深圳市光明区"] = "深圳市光明区" themes: list[Theme] = Field(default_factory=list, max_length=5) - duration: Duration pace: Pace interests: list[Interest] = Field(default_factory=list, max_length=5) - adults: int = Field(default=2, ge=0, le=20) - children: int = Field(default=0, ge=0, le=20) - child_ages: list[int] = Field(default_factory=list, max_length=20) transport: Transport = Transport.PUBLIC_TRANSPORT - budget_level: BudgetLevel = BudgetLevel.STANDARD - extra_requirements: str = Field(default="", max_length=500) @model_validator(mode="after") def validate_group(self) -> "TravelPreferences": - if self.adults + self.children < 1: - raise ValueError("出行总人数至少为 1") - if self.child_ages and len(self.child_ages) != self.children: - raise ValueError("儿童年龄数量必须与儿童人数一致") - if any(age < 0 or age > 17 for age in self.child_ages): - raise ValueError("儿童年龄须在 0 至 17 岁之间") if len(set(self.themes)) != len(self.themes): raise ValueError("出行主题不能重复") if len(set(self.interests)) != len(self.interests): diff --git a/server/tests/test_api.py b/server/tests/test_api.py index 8cbe596..bb44599 100644 --- a/server/tests/test_api.py +++ b/server/tests/test_api.py @@ -16,15 +16,9 @@ def preferences(): return { "destination": "深圳市光明区", "themes": ["亲子"], - "duration": "half_day", "pace": "moderate", "interests": ["自然风光"], - "adults": 2, - "children": 1, - "childAges": [6], "transport": "public_transport", - "budgetLevel": "standard", - "extraRequirements": "", } diff --git a/server/tests/test_planner.py b/server/tests/test_planner.py index 2731b24..86c16bb 100644 --- a/server/tests/test_planner.py +++ b/server/tests/test_planner.py @@ -14,15 +14,9 @@ def custom_request() -> PlanRecommendationRequest: "preferences": { "destination": "深圳市光明区", "themes": ["朋友"], - "duration": "half_day", "pace": "moderate", "interests": ["自然风光"], - "adults": 2, - "children": 0, - "childAges": [], "transport": "public_transport", - "budgetLevel": "standard", - "extraRequirements": "", }, "durationMinutes": 120, "selectedPoiIds": ["poi_a", "poi_b", "poi_c"], diff --git a/src/components/map/MapFilterHeader.vue b/src/components/map/MapFilterHeader.vue index d50ec91..9d2ba9b 100644 --- a/src/components/map/MapFilterHeader.vue +++ b/src/components/map/MapFilterHeader.vue @@ -10,6 +10,7 @@ defineProps<{ }>() const emit = defineEmits<{ + checkIns: [] select: [categoryCode: string | null] }>() @@ -21,7 +22,10 @@ const emit = defineEmits<{ 光明区文旅资源 {{ coverageLabel }} - 共 {{ resultCount }} 个点位 + + 共 {{ resultCount }} 个点位 + + @@ -91,11 +95,36 @@ const emit = defineEmits<{ } .map-filter-header__count { - flex: none; font-weight: 600; color: #167a5b; } +.map-filter-header__actions { + display: flex; + flex: none; + flex-direction: column; + gap: 4rpx; + align-items: flex-end; +} + +.map-filter-header__check-ins { + width: auto; + height: 42rpx; + padding: 0 15rpx; + margin: 0; + font-size: 20rpx; + font-weight: 700; + line-height: 42rpx; + color: #0e6247; + background: #e8f4ee; + border: 0; + border-radius: 21rpx; +} + +.map-filter-header__check-ins::after { + border: 0; +} + .map-filter-header__scroll { width: 100%; height: 80rpx; diff --git a/src/data/check-in/index.ts b/src/data/check-in/index.ts new file mode 100644 index 0000000..3fe1316 --- /dev/null +++ b/src/data/check-in/index.ts @@ -0,0 +1 @@ +export * from './tasks' diff --git a/src/data/check-in/tasks.ts b/src/data/check-in/tasks.ts new file mode 100644 index 0000000..2f2248d --- /dev/null +++ b/src/data/check-in/tasks.ts @@ -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)) +} diff --git a/src/domain/check-in/check-in.ts b/src/domain/check-in/check-in.ts new file mode 100644 index 0000000..8dcee91 --- /dev/null +++ b/src/domain/check-in/check-in.ts @@ -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(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 })) } +} diff --git a/src/domain/check-in/index.ts b/src/domain/check-in/index.ts new file mode 100644 index 0000000..2756e86 --- /dev/null +++ b/src/domain/check-in/index.ts @@ -0,0 +1,3 @@ +export * from './check-in' +export * from './types' +export * from './validation' diff --git a/src/domain/check-in/types.ts b/src/domain/check-in/types.ts new file mode 100644 index 0000000..f66b0b7 --- /dev/null +++ b/src/domain/check-in/types.ts @@ -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 +} diff --git a/src/domain/check-in/validation.ts b/src/domain/check-in/validation.ts new file mode 100644 index 0000000..300ce8f --- /dev/null +++ b/src/domain/check-in/validation.ts @@ -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(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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasOnlyKeys(value: Record, allowedKeys: ReadonlySet): 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 }) +} diff --git a/src/domain/travel/local-planner.ts b/src/domain/travel/local-planner.ts index db0ced6..fbc65ef 100644 --- a/src/domain/travel/local-planner.ts +++ b/src/domain/travel/local-planner.ts @@ -228,6 +228,31 @@ function rankedCandidates( }) } +function canCompleteMinimumRoute( + candidates: readonly PoiResolved[], + preferences: TravelPreferences, + cursor: PlanningOrigin | PoiResolved | null, + remainingMinutes: number, + stopsNeeded: number, +): boolean { + if (stopsNeeded <= 0) + return true + + return candidates.some((poi, index) => { + const requiredMinutes = (cursor ? transferMinutes(cursor, poi, preferences) : 0) + + activityMinutes(poi, preferences.pace) + if (requiredMinutes > remainingMinutes) + return false + return canCompleteMinimumRoute( + [...candidates.slice(0, index), ...candidates.slice(index + 1)], + preferences, + poi, + remainingMinutes - requiredMinutes, + stopsNeeded - 1, + ) + }) +} + function selectQuickPois( preferences: TravelPreferences, repository: PoiRepository, @@ -238,13 +263,28 @@ function selectQuickPois( const selected: PoiResolved[] = [] let usedMinutes = 0 let cursor: PlanningOrigin | PoiResolved | null = origin + const minimumTargetCount = Math.min(remaining.length, requestedMinutes >= 240 ? 3 : 1) while (remaining.length > 0 && selected.length < 8) { const ranked = rankedCandidates(remaining, preferences, selected, cursor) - const next = ranked.find((poi) => { + const fitting = ranked.filter((poi) => { const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0 return usedMinutes + transfer + activityMinutes(poi, preferences.pace) <= requestedMinutes }) + const next = fitting.find((poi) => { + const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0 + const requiredMinutes = transfer + activityMinutes(poi, preferences.pace) + const stopsNeeded = minimumTargetCount - selected.length - 1 + if (stopsNeeded <= 0) + return true + return canCompleteMinimumRoute( + remaining.filter(candidate => candidate.id !== poi.id), + preferences, + poi, + requestedMinutes - usedMinutes - requiredMinutes, + stopsNeeded, + ) + }) ?? fitting[0] if (!next) break const transfer = cursor ? transferMinutes(cursor, next, preferences) : 0 @@ -254,9 +294,6 @@ function selectQuickPois( cursor = next } - if (selected.length === 0 && remaining.length > 0) - selected.push(rankedCandidates(remaining, preferences, [], origin)[0]) - return selected } diff --git a/src/manifest.json b/src/manifest.json index 9d7a7ea..ffb7ccf 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,9 +1,9 @@ { "name": "光明文旅地图", "appid": "", - "description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC", - "versionName": "0.2.0", - "versionCode": "20", + "description": "深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC", + "versionName": "0.3.0", + "versionCode": "30", "transformPx": false, "app-plus": { "usingComponents": true, @@ -51,9 +51,10 @@ "urlCheck": true }, "usingComponents": true, + "lazyCodeLoading": "requiredComponents", "permission": { "scope.userLocation": { - "desc": "用于在全域地图显示实时位置,并优化文旅行程顺序" + "desc": "用于地图定位、行程优化和到点打卡判断" } }, "requiredPrivateInfos": [ @@ -61,7 +62,7 @@ "startLocationUpdate", "onLocationChange" ], - "projectname": "【prod】光明文旅地图" + "projectname": "【dev】光明文旅地图" }, "mp-alipay": { "usingComponents": true diff --git a/src/pages.json b/src/pages.json index f755e1f..725ab0c 100644 --- a/src/pages.json +++ b/src/pages.json @@ -1,7 +1,7 @@ { "permission": { "scope.userLocation": { - "desc": "用于在全域地图显示实时位置,并优化文旅行程顺序" + "desc": "用于地图定位、行程优化和到点打卡判断" } }, "requiredPrivateInfos": [ @@ -9,6 +9,7 @@ "startLocationUpdate", "onLocationChange" ], + "lazyCodeLoading": "requiredComponents", "easycom": { "custom": { "^u--(.*)": "uview-plus/components/u-$1/u-$1.vue", @@ -33,7 +34,29 @@ "type": "page", "layout": "map", "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", "navigationBarTextStyle": "black", "backgroundColor": "#f4f8f6" @@ -96,7 +119,7 @@ }, { "pagePath": "pages/assistant/index", - "text": "AI 助手", + "text": "文旅助手", "iconPath": "static/tabbar/assistant.png", "selectedIconPath": "static/tabbar/assistant-selected.png" }, diff --git a/src/pages/assistant/index.vue b/src/pages/assistant/index.vue index ea13dd8..3c22d92 100644 --- a/src/pages/assistant/index.vue +++ b/src/pages/assistant/index.vue @@ -90,8 +90,8 @@ onShow(loadAssistantHome) - GUANGMING AI GUIDE - 光明文旅 AI 助手 + GUANGMING LOCAL GUIDE + 光明文旅助手 @@ -99,7 +99,7 @@ onShow(loadAssistantHome) - + @@ -108,7 +108,7 @@ onShow(loadAssistantHome) - AI + POC @@ -166,7 +166,7 @@ onShow(loadAssistantHome) POC 能力边界 - 未配置在线 AI 服务时,助手会使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。 + 本期不调用 AI,助手使用本地 POI 和确定性规则生成路线;当前路线为推荐游览顺序,不提供实时路况或逐段导航。 @@ -175,7 +175,7 @@ onShow(loadAssistantHome) layout: map style: - navigationBarTitleText: AI 文旅助手 + navigationBarTitleText: 文旅助手 navigationBarBackgroundColor: '#ffffff' navigationBarTextStyle: black backgroundColor: '#f4f8f6' diff --git a/src/pages/check-in/index.vue b/src/pages/check-in/index.vue new file mode 100644 index 0000000..2a1945b --- /dev/null +++ b/src/pages/check-in/index.vue @@ -0,0 +1,593 @@ + + + + + +layout: map +style: + navigationBarTitleText: 点位打卡 + navigationBarBackgroundColor: '#ffffff' + navigationBarTextStyle: black + backgroundColor: '#f4f8f6' + + + diff --git a/src/pages/check-in/records.vue b/src/pages/check-in/records.vue new file mode 100644 index 0000000..b3ffb68 --- /dev/null +++ b/src/pages/check-in/records.vue @@ -0,0 +1,574 @@ + + + + + +layout: map +style: + navigationBarTitleText: 我的打卡 + navigationBarBackgroundColor: '#ffffff' + navigationBarTextStyle: black + backgroundColor: '#f4f8f6' + + + diff --git a/src/pages/itinerary/index.vue b/src/pages/itinerary/index.vue index ee7b583..895928e 100644 --- a/src/pages/itinerary/index.vue +++ b/src/pages/itinerary/index.vue @@ -1,9 +1,9 @@