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

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

@@ -0,0 +1,59 @@
export type Theme = '亲子' | '情侣' | '朋友' | '银发' | '研学'
export type Interest = '自然风光' | '文化场馆' | '生态科普' | '美食' | '摄影'
export type Duration = 'half_day' | 'full_day'
export type Pace = 'relaxed' | 'moderate' | 'compact'
export type Transport = 'walking' | 'driving' | 'public_transport'
export type BudgetLevel = 'economy' | 'standard' | 'quality'
export interface TravelPreferences {
destination: '深圳市光明区'
themes: Theme[]
duration: Duration
pace: Pace
interests: Interest[]
adults: number
children: number
childAges: number[]
transport: Transport
budgetLevel: BudgetLevel
extraRequirements: string
}
export interface ItineraryItem {
startTime: string
endTime: string
placeId: string
placeName: string
activity: string
reason: string
transferFromPreviousMinutes: number | null
tips: string[]
}
export interface ItinerarySource {
placeId: string
sourceName: string
sourceUrl: string
updatedAt: string
}
export interface Itinerary {
title: string
summary: string
totalMinutes: number
estimatedCostText: string
items: ItineraryItem[]
notes: string[]
sources: ItinerarySource[]
}
export interface PlanResponse {
conversationId: string
assistantMessage: string
itinerary: Itinerary
changeSummary?: string | null
}
export interface APIError {
detail?: string
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"lib": ["esnext", "dom"],
"types": ["@dcloudio/types"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import uni from "@dcloudio/vite-plugin-uni";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [uni()],
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
},
},
});
+6
View File
@@ -0,0 +1,6 @@
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=
LLM_TIMEOUT_SECONDS=45
LLM_MODE=mock
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
+7
View File
@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
.pytest_cache/
*.pyc
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
"""Guangming travel assistant POC."""
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Optional
from .schemas import TravelPreferences
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
class KnowledgeBase:
def __init__(self) -> None:
self.places: list[dict[str, Any]] = json.loads(
(DATA_DIR / "places.json").read_text(encoding="utf-8")
)
travel_rows = json.loads(
(DATA_DIR / "travel_times.json").read_text(encoding="utf-8")
)
self.travel_times = {
(row["from"], row["to"]): row for row in travel_rows
}
@property
def count(self) -> int:
return len(self.places)
def by_id(self, place_id: str) -> Optional[dict[str, Any]]:
return next((place for place in self.places if place["id"] == place_id), None)
def retrieve(self, preferences: TravelPreferences, limit: int = 8) -> list[dict[str, Any]]:
requirements = preferences.extra_requirements.lower()
indoor_required = any(
token in requirements for token in ("室内", "下雨", "雨天", "避雨")
)
avoid_climbing = any(
token in requirements
for token in ("不爬山", "不要爬山", "不登山", "体力不好", "婴儿车")
)
youngest = min(preferences.child_ages) if preferences.child_ages else None
scored: list[tuple[int, str, dict[str, Any]]] = []
for place in self.places:
if place["city"] != "深圳市" or place["district"] != "光明区":
continue
if youngest is not None and youngest < place.get("minAge", 0):
continue
if avoid_climbing and place.get("intensity") == "high":
continue
if indoor_required and not (
place.get("indoor") or place.get("rainyDaySuitable")
):
continue
score = 0
score += 3 * len(set(preferences.themes) & set(place.get("themes", [])))
score += 3 * len(
set(preferences.interests) & set(place.get("interests", []))
)
if preferences.children and "儿童" in place.get("suitableFor", []):
score += 2
elif not preferences.children and "成人" in place.get("suitableFor", []):
score += 2
if preferences.pace.value in place.get("pace", []):
score += 1
if preferences.budget_level.value == place.get("budgetLevel"):
score += 1
if indoor_required and (
place.get("indoor") or place.get("rainyDaySuitable")
):
score += 2
scored.append((score, place["name"], place))
scored.sort(key=lambda item: (-item[0], item[1]))
return [place for _, _, place in scored[:limit]]
def travel_minutes(
self, from_id: str, to_id: str, transport: str
) -> Optional[int]:
row = self.travel_times.get((from_id, to_id)) or self.travel_times.get(
(to_id, from_id)
)
if not row:
return None
value = row.get(transport)
return int(value) if value is not None else None
def travel_context(
self, candidates: list[dict[str, Any]], transport: str
) -> list[dict[str, Any]]:
ids = {place["id"] for place in candidates}
result = []
for row in self.travel_times.values():
if row["from"] in ids and row["to"] in ids:
result.append(
{
"from": row["from"],
"to": row["to"],
"minutes": row.get(transport),
"note": row.get("note", "POC估算,请以实际导航为准"),
}
)
return result
+203
View File
@@ -0,0 +1,203 @@
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from typing import Any, Optional
from pydantic import ValidationError
from .knowledge import KnowledgeBase
from .prompts import SYSTEM_PROMPT
from .schemas import GeneratedItinerary, Pace, TravelPreferences
class LLMError(RuntimeError):
pass
class LLMTimeout(LLMError):
pass
class LLMService:
def __init__(self, knowledge: KnowledgeBase) -> None:
self.knowledge = knowledge
self.mode = os.getenv("LLM_MODE", "mock").lower()
self.timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "45"))
@property
def configured(self) -> bool:
if self.mode == "mock":
return True
return bool(os.getenv("OPENAI_API_KEY") and os.getenv("OPENAI_MODEL"))
async def generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary] = None,
user_message: Optional[str] = None,
) -> GeneratedItinerary:
if self.mode == "mock":
return self._mock_generate(preferences, candidates, previous, user_message)
return await self._real_generate(preferences, candidates, previous, user_message)
async def _real_generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary],
user_message: Optional[str],
) -> GeneratedItinerary:
if not self.configured:
raise LLMError("真实模型尚未配置")
try:
from openai import APITimeoutError, AsyncOpenAI
except ImportError as exc:
raise LLMError("缺少 openai 依赖") from exc
client = AsyncOpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.getenv("OPENAI_BASE_URL") or None,
timeout=self.timeout,
)
payload = {
"preferences": preferences.model_dump(by_alias=True),
"candidates": candidates,
"travelTimes": self.knowledge.travel_context(
candidates, preferences.transport.value
),
"previousItinerary": (
previous.model_dump(by_alias=True) if previous else None
),
"adjustmentRequest": user_message,
}
feedback = ""
allowed_ids = {place["id"] for place in candidates}
for attempt in range(2):
try:
response = await client.chat.completions.create(
model=os.environ["OPENAI_MODEL"],
temperature=0.2,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": json.dumps(payload, ensure_ascii=False)
+ feedback,
},
],
)
except APITimeoutError as exc:
raise LLMTimeout("模型调用超时") from exc
except Exception as exc:
raise LLMError(f"模型调用失败:{exc}") from exc
try:
content = response.choices[0].message.content or "{}"
itinerary = GeneratedItinerary.model_validate_json(content)
unknown = {
item.place_id for item in itinerary.items
} - allowed_ids
if unknown:
raise ValueError(f"包含未知地点ID{sorted(unknown)}")
return itinerary
except (ValidationError, ValueError, json.JSONDecodeError) as exc:
if attempt == 1:
raise LLMError(f"模型输出无法通过结构校验:{exc}") from exc
feedback = f"\n上次输出校验失败:{exc}。请重新输出完整合法 JSON。"
raise LLMError("模型未返回有效结果")
def _mock_generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary],
user_message: Optional[str],
) -> GeneratedItinerary:
if not candidates:
raise LLMError("没有符合条件的候选地点")
max_items = {
Pace.RELAXED: 3,
Pace.MODERATE: 4,
Pace.COMPACT: 5,
}[preferences.pace]
target = 240 if preferences.duration.value == "half_day" else 480
start = datetime(2026, 1, 1, 9, 0)
elapsed = 0
items = []
previous_place: Optional[dict[str, Any]] = None
for place in candidates:
if len(items) >= max_items:
break
transfer_value: Optional[int] = 0
if previous_place:
transfer_value = self.knowledge.travel_minutes(
previous_place["id"],
place["id"],
preferences.transport.value,
)
transfer_for_math = transfer_value or 0
duration = int(place.get("recommendedMinutes", 75))
if elapsed + transfer_for_math + duration > target + 30 and items:
continue
item_start = start + timedelta(minutes=transfer_for_math)
end = item_start + timedelta(minutes=duration)
items.append(
{
"startTime": item_start.strftime("%H:%M"),
"endTime": end.strftime("%H:%M"),
"placeId": place["id"],
"placeName": place["name"],
"activity": place["summary"],
"reason": self._reason(preferences, place),
"transferFromPreviousMinutes": (
transfer_value if previous_place else 0
),
"tips": place.get("tips", [])[:2],
}
)
elapsed += transfer_for_math + duration
start = end
previous_place = place
themes = "".join(preferences.themes or preferences.interests[:1])
adjustment = f";已响应“{user_message}" if user_message else ""
pace_label = {
Pace.RELAXED: "轻松",
Pace.MODERATE: "适中",
Pace.COMPACT: "紧凑",
}[preferences.pace]
return GeneratedItinerary.model_validate(
{
"title": f"光明区{themes or '精选'}{'半日' if preferences.duration.value == 'half_day' else '一日'}",
"summary": f"以科学、人文与都市自然为线索,按{pace_label}节奏安排{adjustment}",
"totalMinutes": max(elapsed, 1),
"estimatedCostText": "费用以场馆、景区及实际交通信息为准",
"items": items,
"notes": [
"开放时间、预约和票价请在出行前通过官方渠道再次确认。",
"交通耗时为POC估算,请以出发时的实际导航为准。",
],
}
)
@staticmethod
def _reason(
preferences: TravelPreferences, place: dict[str, Any]
) -> str:
matches = list(
(set(preferences.themes) & set(place.get("themes", [])))
| (set(preferences.interests) & set(place.get("interests", [])))
)
return (
f"符合你的{''.join(sorted(matches))}偏好"
if matches
else "作为光明区同路线备选,便于控制整体节奏"
)
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import os
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from .knowledge import KnowledgeBase
from .llm import LLMError, LLMService, LLMTimeout
from .planner import PlannerService, SessionStore
from .schemas import HealthResponse, MessageRequest, PlanResponse, TravelPreferences
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# Mock mode can run without python-dotenv; production installs project deps.
pass
knowledge = KnowledgeBase()
llm = LLMService(knowledge)
planner = PlannerService(knowledge, llm, SessionStore())
app = FastAPI(
title="光明区文旅行程助手 API",
version="0.1.0",
description="POC:本地知识检索 + OpenAI 兼容模型 + 内存会话",
)
origins = [
origin.strip()
for origin in os.getenv(
"CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
).split(",")
if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
@app.get("/api/v1/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(
status="ok",
llm_mode=llm.mode,
model_configured=llm.configured,
knowledge_count=knowledge.count,
)
@app.post("/api/v1/plans", response_model=PlanResponse)
async def create_plan(preferences: TravelPreferences) -> PlanResponse:
try:
return await planner.create(preferences)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LLMTimeout as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except (LLMError, ValueError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.post(
"/api/v1/conversations/{conversation_id}/messages",
response_model=PlanResponse,
)
async def adjust_plan(
conversation_id: str, request: MessageRequest
) -> PlanResponse:
try:
return await planner.adjust(conversation_id, request.message)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LLMTimeout as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except (LLMError, ValueError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import time
import uuid
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Optional
from .knowledge import KnowledgeBase
from .llm import LLMService
from .schemas import (
GeneratedItinerary,
Itinerary,
PlanResponse,
Source,
TravelPreferences,
)
@dataclass
class Session:
preferences: TravelPreferences
itinerary: GeneratedItinerary
created_at: float
history: list[str] = field(default_factory=list)
class SessionStore:
def __init__(self, max_size: int = 100, ttl_seconds: int = 7200) -> None:
self.max_size = max_size
self.ttl_seconds = ttl_seconds
self.sessions: OrderedDict[str, Session] = OrderedDict()
def put(self, session: Session) -> str:
self.cleanup()
while len(self.sessions) >= self.max_size:
self.sessions.popitem(last=False)
session_id = str(uuid.uuid4())
self.sessions[session_id] = session
return session_id
def get(self, session_id: str) -> Optional[Session]:
self.cleanup()
session = self.sessions.get(session_id)
if session:
self.sessions.move_to_end(session_id)
return session
def cleanup(self) -> None:
deadline = time.time() - self.ttl_seconds
expired = [
key for key, session in self.sessions.items()
if session.created_at < deadline
]
for key in expired:
self.sessions.pop(key, None)
class PlannerService:
def __init__(
self,
knowledge: KnowledgeBase,
llm: LLMService,
store: SessionStore,
) -> None:
self.knowledge = knowledge
self.llm = llm
self.store = store
async def create(self, preferences: TravelPreferences) -> PlanResponse:
candidates = self.knowledge.retrieve(preferences)
if not candidates:
raise LookupError("没有找到符合当前条件的光明区地点")
generated = await self.llm.generate(preferences, candidates)
generated = self._canonicalize(generated, candidates)
session_id = self.store.put(
Session(preferences=preferences, itinerary=generated, created_at=time.time())
)
return PlanResponse(
conversation_id=session_id,
assistant_message="已根据你的偏好生成光明区行程。",
itinerary=self._with_sources(generated),
)
async def adjust(self, session_id: str, message: str) -> PlanResponse:
session = self.store.get(session_id)
if not session:
raise LookupError("会话不存在或已过期,请重新生成行程")
updated_preferences = session.preferences.model_copy(
update={
"extra_requirements": (
session.preferences.extra_requirements + "" + message
).strip("")
}
)
candidates = self.knowledge.retrieve(updated_preferences)
if not candidates:
raise LookupError("没有找到符合调整条件的光明区地点")
generated = await self.llm.generate(
updated_preferences, candidates, session.itinerary, message
)
generated = self._canonicalize(generated, candidates)
session.preferences = updated_preferences
session.itinerary = generated
session.history.append(message)
return PlanResponse(
conversation_id=session_id,
assistant_message="已按你的补充要求调整行程。",
change_summary=f"已根据“{message}”更新相关安排。",
itinerary=self._with_sources(generated),
)
def _canonicalize(
self,
itinerary: GeneratedItinerary,
candidates: list[dict],
) -> GeneratedItinerary:
candidate_map = {place["id"]: place for place in candidates}
for item in itinerary.items:
place = candidate_map.get(item.place_id)
if not place:
raise ValueError(f"行程包含知识库外地点:{item.place_id}")
item.place_name = place["name"]
item.tips = [
tip for tip in item.tips if tip in place.get("tips", [])
] or place.get("tips", [])[:2]
return itinerary
def _with_sources(self, generated: GeneratedItinerary) -> Itinerary:
seen: set[str] = set()
sources: list[Source] = []
for item in generated.items:
if item.place_id in seen:
continue
seen.add(item.place_id)
place = self.knowledge.by_id(item.place_id)
if place:
sources.append(
Source(
place_id=place["id"],
source_name=place["sourceName"],
source_url=place["sourceUrl"],
updated_at=place["updatedAt"],
)
)
return Itinerary(
**generated.model_dump(),
sources=sources,
)
+26
View File
@@ -0,0 +1,26 @@
SYSTEM_PROMPT = """你是深圳市光明区文旅行程规划助手。
你只能使用“候选地点”中出现的 placeId,不得创造地点。
不得虚构开放时间、票价、预约、天气或交通耗时;缺少交通数据时必须输出 null。
优先满足年龄、安全、室内外、体力和用户明确排除条件。
半日游控制在约4小时,一日游控制在约8小时;轻松、适中、紧凑最多安排3、4、5个地点。
修改已有行程时,只修改用户要求影响的部分,尽量保留其余安排。
只输出一个 JSON 对象,不要输出 Markdown、代码围栏或解释文字。
JSON 结构:
{
"title": "字符串",
"summary": "字符串",
"totalMinutes": 240,
"estimatedCostText": "费用说明,不编造精确价格",
"items": [{
"startTime": "09:00",
"endTime": "10:30",
"placeId": "候选地点ID",
"placeName": "候选地点名称",
"activity": "活动安排",
"reason": "匹配理由",
"transferFromPreviousMinutes": null,
"tips": ["知识库内的提示"]
}],
"notes": ["动态信息需出行前确认"]
}"""
+115
View File
@@ -0,0 +1,115 @@
from enum import Enum
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
def to_camel(value: str) -> str:
parts = value.split("_")
return parts[0] + "".join(part.capitalize() for part in parts[1:])
class APIModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class Duration(str, Enum):
HALF_DAY = "half_day"
FULL_DAY = "full_day"
class Pace(str, Enum):
RELAXED = "relaxed"
MODERATE = "moderate"
COMPACT = "compact"
class Transport(str, Enum):
WALKING = "walking"
DRIVING = "driving"
PUBLIC_TRANSPORT = "public_transport"
class BudgetLevel(str, Enum):
ECONOMY = "economy"
STANDARD = "standard"
QUALITY = "quality"
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
class TravelPreferences(APIModel):
destination: Literal["深圳市光明区"] = "深圳市光明区"
themes: list[Theme] = Field(default_factory=list)
duration: Duration
pace: Pace
interests: list[Interest] = Field(default_factory=list)
adults: int = Field(default=2, ge=0, le=20)
children: int = Field(default=0, ge=0, le=20)
child_ages: list[int] = Field(default_factory=list)
transport: Transport = Transport.PUBLIC_TRANSPORT
budget_level: BudgetLevel = BudgetLevel.STANDARD
extra_requirements: str = Field(default="", max_length=500)
@model_validator(mode="after")
def validate_group_and_preferences(self) -> "TravelPreferences":
if self.adults + self.children < 1:
raise ValueError("出行总人数至少为1")
if self.child_ages and len(self.child_ages) != self.children:
raise ValueError("儿童年龄数量必须与儿童人数一致")
if any(age < 0 or age > 17 for age in self.child_ages):
raise ValueError("儿童年龄须在0至17岁之间")
if not self.themes and not self.interests:
raise ValueError("主题或特色偏好至少选择一项")
return self
class ItineraryItem(APIModel):
start_time: str
end_time: str
place_id: str
place_name: str
activity: str
reason: str
transfer_from_previous_minutes: Optional[int] = Field(default=None, ge=0)
tips: list[str] = Field(default_factory=list)
class GeneratedItinerary(APIModel):
title: str
summary: str
total_minutes: int = Field(ge=1, le=720)
estimated_cost_text: str
items: list[ItineraryItem] = Field(min_length=1, max_length=5)
notes: list[str] = Field(default_factory=list)
class Source(APIModel):
place_id: str
source_name: str
source_url: str
updated_at: str
class Itinerary(GeneratedItinerary):
sources: list[Source]
class PlanResponse(APIModel):
conversation_id: str
assistant_message: str
itinerary: Itinerary
change_summary: Optional[str] = None
class MessageRequest(APIModel):
message: str = Field(min_length=1, max_length=500)
class HealthResponse(APIModel):
status: str
llm_mode: str
model_configured: bool
knowledge_count: int
+423
View File
@@ -0,0 +1,423 @@
[
{
"id": "guangming-science-museum",
"city": "深圳市",
"district": "光明区",
"name": "深圳科学技术馆",
"type": "museum",
"area": "光明中心区",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 3,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 150,
"budgetLevel": "standard",
"address": "深圳市光明区光辉大道8号",
"latitude": null,
"longitude": null,
"openTimeText": "开放与预约信息请以场馆官方最新公告为准",
"ticketText": "票务信息请以场馆官方最新公告为准",
"summary": "围绕数字文明与通信科技设置互动展项,适合科普参观和亲子研学。",
"tips": ["热门时段建议提前查询预约信息", "场馆较大,亲子家庭可预留充足时间"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12151090.html",
"updatedAt": "2025-04-29"
},
{
"id": "guangming-science-park",
"city": "深圳市",
"district": "光明区",
"name": "科学公园",
"type": "park",
"area": "光明中心区",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 90,
"budgetLevel": "economy",
"address": "深圳市光明区光明中心区",
"latitude": null,
"longitude": null,
"openTimeText": "开放区域请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "以山林、步道和生态体验连接科学城公共空间,适合散步和轻量户外活动。",
"tips": ["户外活动注意防晒和补水", "雨天请留意步道路面状况"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12235727.html",
"updatedAt": "2025-06-19"
},
{
"id": "guangming-happy-farm",
"city": "深圳市",
"district": "光明区",
"name": "光明欢乐田园",
"type": "park",
"area": "新湖街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 120,
"budgetLevel": "economy",
"address": "深圳市光明区新湖街道楼村一号路",
"latitude": null,
"longitude": null,
"openTimeText": "花期、开放区域及活动请以园区最新公告为准",
"ticketText": "费用请以园区最新公告为准",
"summary": "都市田园景观随季节变化,可体验农田、花景与开阔田园空间。",
"tips": ["景观受季节影响,出行前建议查询当期花田信息", "户外区域注意防晒"],
"sourceName": "光明区文化广电旅游体育局",
"sourceUrl": "https://www.szgm.gov.cn/zjgm/lsgm_118488/tsgm_118491/content/post_8758374.html",
"updatedAt": "2025-03-24"
},
{
"id": "guangming-farm-grand-view",
"city": "深圳市",
"district": "光明区",
"name": "光明农场大观园",
"type": "attraction",
"area": "光明街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 2,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 150,
"budgetLevel": "standard",
"address": "深圳市光明区光明街道",
"latitude": null,
"longitude": null,
"openTimeText": "开放时间和活动安排请以景区公告为准",
"ticketText": "购票信息请以景区官方渠道为准",
"summary": "以农场、动物互动和自然科普为特色,适合亲子家庭和研学体验。",
"tips": ["动物互动和主题活动以当日安排为准", "建议为儿童准备替换衣物"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12164420.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-hongqiao-park",
"city": "深圳市",
"district": "光明区",
"name": "虹桥公园",
"type": "park",
"area": "光明街道",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "medium",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 100,
"budgetLevel": "economy",
"address": "深圳市光明区光侨路以东、观光路以北",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "以醒目的红色桥体和山林绿意形成城市生态地标,适合步行和摄影。",
"tips": ["路线较长,可按体力选择折返", "户外步行注意防晒和补水"],
"sourceName": "光明区城市管理和综合执法局",
"sourceUrl": "https://www.szgm.gov.cn/gmcsglj/gkmlpt/content/10/10284/post_10284402.html",
"updatedAt": "2022-12-13"
},
{
"id": "guangming-dadingling-greenway",
"city": "深圳市",
"district": "光明区",
"name": "大顶岭绿道",
"type": "greenway",
"area": "光明街道",
"themes": ["情侣", "朋友", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["成人"],
"minAge": 8,
"pace": ["moderate", "compact"],
"intensity": "high",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 150,
"budgetLevel": "economy",
"address": "深圳市光明区大顶岭片区",
"latitude": null,
"longitude": null,
"openTimeText": "绿道开放情况请以管理方公告为准",
"ticketText": "费用信息请以现场公告为准",
"summary": "以悬桥、探桥和浮桥等节点串联山林生态,是具有运动强度的绿道体验。",
"tips": ["需要较多步行,不适合婴儿车和体力较弱人群", "雨天不建议前往"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163137.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-culture-art-center",
"city": "深圳市",
"district": "光明区",
"name": "光明文化艺术中心",
"type": "cultural_center",
"area": "凤凰街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["文化场馆", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 120,
"budgetLevel": "economy",
"address": "深圳市光明区观光路东北侧",
"latitude": null,
"longitude": null,
"openTimeText": "图书馆、展览和演出开放安排请分别查询官方公告",
"ticketText": "各空间及活动费用请以官方公告为准",
"summary": "集图书馆、演艺、展览与休闲空间于一体的综合文化地标。",
"tips": ["不同场馆开放安排可能不同", "观演活动通常需要单独预约或购票"],
"sourceName": "光明区文化广电旅游体育局",
"sourceUrl": "https://www.szgm.gov.cn/zjgm/lsgm_118488/tsgm_118491/content/post_8774880.html",
"updatedAt": "2025-03-24"
},
{
"id": "guangming-science-city-exhibition",
"city": "深圳市",
"district": "光明区",
"name": "光明科学城展示中心",
"type": "exhibition",
"area": "新湖街道",
"themes": ["朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区新湖街道圳园路南侧",
"latitude": null,
"longitude": null,
"openTimeText": "接待和开放信息请提前向官方渠道确认",
"ticketText": "参观安排请以官方公告为准",
"summary": "以展览展示功能介绍光明科学城规划与建设,适合城市与科学主题研学。",
"tips": ["并非常规全天候开放景点,务必提前确认", "适合与科学公园或欢乐田园组合"],
"sourceName": "光明区科学城开发建设署",
"sourceUrl": "https://www.szgm.gov.cn/gmcsfzcjzx/gkmlpt/content/10/10366/post_10366147.html",
"updatedAt": "2023-01-03"
},
{
"id": "guangming-ebohr-museum",
"city": "深圳市",
"district": "光明区",
"name": "依波钟表文化博物馆",
"type": "museum",
"area": "马田街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区钟表产业基地依波大厦",
"latitude": null,
"longitude": null,
"openTimeText": "工业旅游参观安排请提前向场馆确认",
"ticketText": "参观费用请以场馆最新信息为准",
"summary": "通过古代计时、钟表历史和制表技术展项展示时间文化与工业设计。",
"tips": ["工业旅游场馆建议提前预约确认", "适合学龄儿童及成人"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12165124.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-fiyta-center",
"city": "深圳市",
"district": "光明区",
"name": "飞亚达计时文化中心",
"type": "museum",
"area": "马田街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区钟表产业基地",
"latitude": null,
"longitude": null,
"openTimeText": "工业旅游参观安排请提前向场馆确认",
"ticketText": "参观费用请以场馆最新信息为准",
"summary": "融合钟表文化、工业旅游和科普教育,包含航天主题展示与制表体验。",
"tips": ["体验项目和开放安排需要提前确认", "适合作为雨天研学备选"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12165107.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-honghua-park",
"city": "深圳市",
"district": "光明区",
"name": "红花山公园",
"type": "park",
"area": "公明街道",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 80,
"budgetLevel": "economy",
"address": "深圳市光明区公明街道",
"latitude": null,
"longitude": null,
"openTimeText": "开放区域请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "结合荔枝林、草坪、儿童活动和休闲设施的社区型生态公园。",
"tips": ["亲子活动请留意儿童设施现场状态", "户外活动注意防晒"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12164759.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-rosewood-town",
"city": "深圳市",
"district": "光明区",
"name": "光明红木文化古镇",
"type": "cultural_town",
"area": "公明街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["文化场馆", "美食", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 100,
"budgetLevel": "standard",
"address": "深圳市光明区公明街道北环大道与民生路交会处",
"latitude": null,
"longitude": null,
"openTimeText": "各展馆和商户开放安排请以现场公告为准",
"ticketText": "费用请以现场公告为准",
"summary": "以红木工艺和岭南文化为主题,包含文化展陈、传统街区和特色商业。",
"tips": ["各主题空间开放时间可能不同", "适合安排为文化参观与用餐衔接点"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12164714.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-nanguang-green-zone",
"city": "深圳市",
"district": "光明区",
"name": "南光绿境",
"type": "park",
"area": "茅洲河沿线",
"themes": ["亲子", "情侣", "朋友"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区茅洲河沿线",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园现场公告为准",
"ticketText": "活动项目费用请以运营方公告为准",
"summary": "由旧净水设施及桥下空间更新而成的滨水休闲区,包含运动和儿童活动空间。",
"tips": ["水上活动需另行确认开放与安全要求", "儿童在滨水区域需由成人看护"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163075.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-zuoan-tech-park",
"city": "深圳市",
"district": "光明区",
"name": "左岸科技公园",
"type": "park",
"area": "茅洲河沿线",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区茅洲河沿线",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "沿茅洲河建设的生态修复型公园,适合滨水散步、观察城市生态更新。",
"tips": ["滨水步行注意儿童安全", "雨天请留意步道路面"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163102.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-minghu-park",
"city": "深圳市",
"district": "光明区",
"name": "明湖城市公园",
"type": "park",
"area": "马田街道",
"themes": ["亲子", "情侣", "朋友"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 90,
"budgetLevel": "economy",
"address": "深圳市光明区光明大道、光明国际汽车城旁",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围及活动请以公园公告为准",
"ticketText": "活动项目费用请以现场公告为准",
"summary": "融合湖畔休闲、亲子市集和汽车主题活动的城市公园。",
"tips": ["露营和市集活动并非常设内容", "湖边活动请照看儿童"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163086.html",
"updatedAt": "2025-05-08"
}
]
@@ -0,0 +1,18 @@
[
{"from":"guangming-science-museum","to":"guangming-science-park","walking":12,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-park","to":"guangming-happy-farm","walking":35,"driving":10,"public_transport":24,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-museum","to":"guangming-happy-farm","walking":42,"driving":12,"public_transport":28,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-happy-farm","to":"guangming-farm-grand-view","walking":30,"driving":9,"public_transport":22,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-farm-grand-view","to":"guangming-hongqiao-park","walking":28,"driving":9,"public_transport":20,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-hongqiao-park","to":"guangming-dadingling-greenway","walking":15,"driving":6,"public_transport":18,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-museum","to":"guangming-culture-art-center","walking":55,"driving":14,"public_transport":28,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-park","to":"guangming-science-city-exhibition","walking":22,"driving":7,"public_transport":17,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-city-exhibition","to":"guangming-happy-farm","walking":18,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-ebohr-museum","to":"guangming-fiyta-center","walking":18,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-ebohr-museum","to":"guangming-minghu-park","walking":35,"driving":10,"public_transport":22,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-fiyta-center","to":"guangming-minghu-park","walking":30,"driving":9,"public_transport":20,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-honghua-park","to":"guangming-rosewood-town","walking":25,"driving":8,"public_transport":18,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-rosewood-town","to":"guangming-nanguang-green-zone","walking":45,"driving":12,"public_transport":25,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-nanguang-green-zone","to":"guangming-zuoan-tech-park","walking":22,"driving":7,"public_transport":16,"note":"POC估算,请以实际导航为准"}
]
@@ -0,0 +1,13 @@
Metadata-Version: 2.4
Name: guangming-travel-assistant
Version: 0.1.0
Summary: Shenzhen Guangming travel planning assistant POC
Requires-Python: >=3.9
Requires-Dist: fastapi<1.0.0,>=0.115.0
Requires-Dist: openai<2.0.0,>=1.50.0
Requires-Dist: pydantic<2.13.0,>=2.8.0
Requires-Dist: python-dotenv<2.0.0,>=1.0.0
Requires-Dist: uvicorn[standard]<1.0.0,>=0.30.0
Provides-Extra: test
Requires-Dist: httpx<1.0.0,>=0.27.0; extra == "test"
Requires-Dist: pytest<9.0.0,>=8.0.0; extra == "test"
@@ -0,0 +1,15 @@
pyproject.toml
app/__init__.py
app/knowledge.py
app/llm.py
app/main.py
app/planner.py
app/prompts.py
app/schemas.py
guangming_travel_assistant.egg-info/PKG-INFO
guangming_travel_assistant.egg-info/SOURCES.txt
guangming_travel_assistant.egg-info/dependency_links.txt
guangming_travel_assistant.egg-info/requires.txt
guangming_travel_assistant.egg-info/top_level.txt
tests/test_api.py
tests/test_knowledge.py
@@ -0,0 +1,9 @@
fastapi<1.0.0,>=0.115.0
openai<2.0.0,>=1.50.0
pydantic<2.13.0,>=2.8.0
python-dotenv<2.0.0,>=1.0.0
uvicorn[standard]<1.0.0,>=0.30.0
[test]
httpx<1.0.0,>=0.27.0
pytest<9.0.0,>=8.0.0
+29
View File
@@ -0,0 +1,29 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "guangming-travel-assistant"
version = "0.1.0"
description = "Shenzhen Guangming travel planning assistant POC"
requires-python = ">=3.9"
dependencies = [
"fastapi>=0.115.0,<1.0.0",
"openai>=1.50.0,<2.0.0",
"pydantic>=2.8.0,<2.13.0",
"python-dotenv>=1.0.0,<2.0.0",
"uvicorn[standard]>=0.30.0,<1.0.0",
]
[project.optional-dependencies]
test = [
"httpx>=0.27.0,<1.0.0",
"pytest>=8.0.0,<9.0.0",
]
[tool.setuptools]
packages = ["app"]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+99
View File
@@ -0,0 +1,99 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def preferences(**overrides):
payload = {
"destination": "深圳市光明区",
"themes": ["亲子"],
"duration": "half_day",
"pace": "relaxed",
"interests": ["自然风光", "生态科普"],
"adults": 2,
"children": 1,
"childAges": [6],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": "",
}
payload.update(overrides)
return payload
def test_health_reports_knowledge_and_mock_model():
response = client.get("/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["knowledgeCount"] >= 15
assert data["modelConfigured"] is True
def test_create_plan_only_returns_known_guangming_places():
response = client.post("/api/v1/plans", json=preferences())
assert response.status_code == 200
data = response.json()
assert data["conversationId"]
assert data["itinerary"]["items"]
assert len(data["itinerary"]["items"]) >= 2
assert len(data["itinerary"]["items"]) <= 3
assert all(
item["placeId"].startswith("guangming-")
for item in data["itinerary"]["items"]
)
assert len(data["itinerary"]["sources"]) == len(
{item["placeId"] for item in data["itinerary"]["items"]}
)
def test_indoor_requirement_filters_out_outdoor_places():
response = client.post(
"/api/v1/plans",
json=preferences(
themes=["研学"],
interests=["文化场馆"],
extraRequirements="下午下雨,只安排室内项目",
),
)
assert response.status_code == 200
outdoor_ids = {
"guangming-science-park",
"guangming-happy-farm",
"guangming-hongqiao-park",
"guangming-dadingling-greenway",
}
ids = {item["placeId"] for item in response.json()["itinerary"]["items"]}
assert ids.isdisjoint(outdoor_ids)
def test_adjust_existing_plan():
created = client.post("/api/v1/plans", json=preferences()).json()
response = client.post(
f"/api/v1/conversations/{created['conversationId']}/messages",
json={"message": "改成室内研学路线"},
)
assert response.status_code == 200
data = response.json()
assert data["conversationId"] == created["conversationId"]
assert data["changeSummary"]
assert data["itinerary"]["items"]
def test_missing_preferences_are_rejected():
response = client.post(
"/api/v1/plans",
json=preferences(themes=[], interests=[]),
)
assert response.status_code == 422
def test_expired_or_unknown_session_is_404():
response = client.post(
"/api/v1/conversations/not-found/messages",
json={"message": "慢一点"},
)
assert response.status_code == 404
@@ -0,0 +1,31 @@
from app.knowledge import KnowledgeBase
from app.schemas import TravelPreferences
def make_preferences(extra_requirements: str = "") -> TravelPreferences:
return TravelPreferences(
themes=["亲子"],
duration="half_day",
pace="relaxed",
interests=["自然风光"],
adults=2,
children=1,
child_ages=[3],
transport="public_transport",
budget_level="standard",
extra_requirements=extra_requirements,
)
def test_retrieval_is_deterministic():
knowledge = KnowledgeBase()
first = [place["id"] for place in knowledge.retrieve(make_preferences())]
second = [place["id"] for place in knowledge.retrieve(make_preferences())]
assert first == second
def test_avoid_climbing_excludes_high_intensity_places():
knowledge = KnowledgeBase()
result = knowledge.retrieve(make_preferences("带婴儿车,不爬山"))
assert all(place["intensity"] != "high" for place in result)
+7
View File
@@ -0,0 +1,7 @@
// app.js
App({
onLaunch() {
// 可以在此做全局初始化,如上报启动日志等
},
globalData: {}
});
+14
View File
@@ -0,0 +1,14 @@
{
"pages": [
"pages/poi/poi",
"pages/badges/badges"
],
"window": {
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTitleText": "打卡小程序",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}
+11
View File
@@ -0,0 +1,11 @@
/**app.wxss**/
page {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #333;
background: #fff8ec;
}
/* 通用按钮重置:去掉小程序 button 默认的边框和圆角 */
button::after {
border: none;
}
+52
View File
@@ -0,0 +1,52 @@
// pages/badges/badges.js
const userStore = require('../../userStore.js');
Page({
data: {
points: 0,
badges: []
},
onLoad() {
this.refresh();
},
// 每次显示都刷新一次,保证从其他页面打卡回来后数据是最新的
onShow() {
this.refresh();
},
refresh() {
const data = userStore.getUserData();
// 将徽章数据处理为渲染友好的结构(格式化获得时间、补默认图标)
const badges = (data.badges || []).map(b => ({
name: b.name,
icon: this.getBadgeIcon(b.name),
obtainedAtText: this.formatTime(b.obtainedAt)
}));
this.setData({
points: data.points || 0,
badges
});
},
// 根据徽章名称返回对应的 emoji 图标(也可换成图片路径)
getBadgeIcon(name) {
const map = {
'探索者': '🧭',
'旅行家': '🌍',
'打卡达人': '🏆'
};
return map[name] || '🎖️';
},
// 格式化时间戳为 "YYYY-MM-DD HH:mm"
formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const pad = n => (n < 10 ? '0' + n : '' + n);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
});
+6
View File
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "我的徽章",
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
}
+37
View File
@@ -0,0 +1,37 @@
<!--pages/badges/badges.wxml-->
<view class="page">
<!-- 顶部积分卡片 -->
<view class="header-card">
<view class="header-label">我的总积分</view>
<view class="header-points">
<text class="points-num">{{points}}</text>
<text class="points-unit"> 分</text>
</view>
</view>
<!-- 徽章墙标题 -->
<view class="section-title">
<text>徽章墙</text>
<text class="section-count">共 {{badges.length}} 枚</text>
</view>
<!-- 徽章网格 -->
<block wx:if="{{badges.length > 0}}">
<view class="badge-grid">
<view class="badge-item" wx:for="{{badges}}" wx:key="name">
<view class="badge-icon">{{item.icon}}</view>
<view class="badge-name">{{item.name}}</view>
<view class="badge-time">{{item.obtainedAtText}}</view>
</view>
</view>
</block>
<!-- 空状态 -->
<view class="empty" wx:else>
<view class="empty-icon">🎖️</view>
<view class="empty-text">还没有获得徽章</view>
<view class="empty-tip">去景点打卡,赢取你的第一枚徽章吧~</view>
</view>
</view>
+127
View File
@@ -0,0 +1,127 @@
/* pages/badges/badges.wxss */
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background: #fff8ec;
}
/* 顶部积分卡片 */
.header-card {
background: linear-gradient(135deg, #ffb347 0%, #ff7e5f 100%);
border-radius: 24rpx;
padding: 40rpx 32rpx;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(255, 126, 95, 0.25);
}
.header-label {
font-size: 28rpx;
opacity: 0.9;
}
.header-points {
margin-top: 12rpx;
display: flex;
align-items: baseline;
}
.points-num {
font-size: 72rpx;
font-weight: 700;
line-height: 1;
}
.points-unit {
font-size: 30rpx;
margin-left: 8rpx;
}
/* 分区标题 */
.section-title {
margin: 40rpx 8rpx 20rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-count {
font-size: 24rpx;
font-weight: 400;
color: #999;
}
/* 徽章网格 */
.badge-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.badge-item {
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
transition: transform 0.2s;
}
.badge-item:active {
transform: scale(0.96);
}
.badge-icon {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fff2d6, #ffe0b3);
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
margin-bottom: 16rpx;
}
.badge-name {
font-size: 28rpx;
color: #333;
font-weight: 600;
margin-bottom: 6rpx;
}
.badge-time {
font-size: 20rpx;
color: #999;
text-align: center;
}
/* 空状态 */
.empty {
margin-top: 120rpx;
text-align: center;
color: #999;
}
.empty-icon {
font-size: 120rpx;
opacity: 0.5;
}
.empty-text {
margin-top: 24rpx;
font-size: 30rpx;
color: #666;
}
.empty-tip {
margin-top: 12rpx;
font-size: 24rpx;
color: #aaa;
}
+80
View File
@@ -0,0 +1,80 @@
// pages/poi/poi.js
const userStore = require('../../userStore.js');
// 模拟的景点数据,实际项目中可以来自云端接口
const POI_LIST = [
{ id: 'poi_001', name: '西湖', icon: '🌊', desc: '杭州最著名的湖泊' },
{ id: 'poi_002', name: '灵隐寺', icon: '🛕', desc: '千年古刹,禅意悠远' },
{ id: 'poi_003', name: '雷峰塔', icon: '🗼', desc: '白娘子传说所在地' },
{ id: 'poi_004', name: '西溪湿地', icon: '🌾', desc: '城市中的绿色湿地' },
{ id: 'poi_005', name: '千岛湖', icon: '🏝️', desc: '碧水青山,湖光山色' }
];
Page({
data: {
points: 0,
badgeCount: 0,
pois: []
},
onLoad() {
this.refresh();
},
onShow() {
this.refresh();
},
// 每次刷新:把打卡状态合并到景点列表中,同时更新顶部积分/徽章数
refresh() {
const data = userStore.getUserData();
const checkedSet = new Set(data.checkIns.map(c => c.poiId));
const pois = POI_LIST.map(p => ({
...p,
checked: checkedSet.has(p.id)
}));
this.setData({
points: data.points,
badgeCount: data.badges.length,
pois
});
},
// 点击打卡按钮
onCheckIn(e) {
const { id, name } = e.currentTarget.dataset;
const res = userStore.checkIn(id, name);
wx.showToast({
title: res.message,
icon: res.success ? 'success' : 'none',
duration: 1500
});
if (res.success) {
this.refresh();
}
},
// 跳转到徽章页面
goBadges() {
wx.navigateTo({ url: '/pages/badges/badges' });
},
// 调试用:清空本地数据
onResetData() {
wx.showModal({
title: '确认清空',
content: '将清除所有积分、徽章和打卡记录',
success: (r) => {
if (r.confirm) {
wx.clearStorageSync();
this.refresh();
wx.showToast({ title: '已清空', icon: 'success' });
}
}
});
}
});
+6
View File
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "景点打卡",
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
}
+49
View File
@@ -0,0 +1,49 @@
<!--pages/poi/poi.wxml-->
<view class="page">
<!-- 顶部用户信息卡片 -->
<view class="header-card" bindtap="goBadges">
<view class="header-left">
<view class="header-label">我的积分</view>
<view class="header-points">
<text class="points-num">{{points}}</text>
<text class="points-unit"> 分</text>
</view>
</view>
<view class="header-right">
<view class="badge-count">
<text class="badge-count-num">{{badgeCount}}</text>
<text class="badge-count-label">枚徽章</text>
</view>
<view class="arrow">查看 </view>
</view>
</view>
<!-- 景点列表 -->
<view class="section-title">热门景点</view>
<view class="poi-list">
<view class="poi-item" wx:for="{{pois}}" wx:key="id">
<view class="poi-icon">{{item.icon}}</view>
<view class="poi-info">
<view class="poi-name">{{item.name}}</view>
<view class="poi-desc">{{item.desc}}</view>
</view>
<button
class="poi-btn {{item.checked ? 'poi-btn-done' : ''}}"
disabled="{{item.checked}}"
data-id="{{item.id}}"
data-name="{{item.name}}"
bindtap="onCheckIn"
>
{{item.checked ? '已打卡' : '打卡 +10'}}
</button>
</view>
</view>
<!-- 调试按钮:清空数据 -->
<view class="reset-wrap">
<button class="reset-btn" bindtap="onResetData">清空本地数据(调试)</button>
</view>
</view>
+156
View File
@@ -0,0 +1,156 @@
/* pages/poi/poi.wxss */
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
}
/* 顶部积分卡片 */
.header-card {
background: linear-gradient(135deg, #ffb347 0%, #ff7e5f 100%);
border-radius: 24rpx;
padding: 36rpx 32rpx;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(255, 126, 95, 0.25);
display: flex;
align-items: center;
justify-content: space-between;
}
.header-label {
font-size: 26rpx;
opacity: 0.9;
}
.header-points {
margin-top: 8rpx;
display: flex;
align-items: baseline;
}
.points-num {
font-size: 64rpx;
font-weight: 700;
line-height: 1;
}
.points-unit {
font-size: 28rpx;
margin-left: 6rpx;
}
.header-right {
text-align: right;
}
.badge-count-num {
font-size: 40rpx;
font-weight: 700;
margin-right: 6rpx;
}
.badge-count-label {
font-size: 24rpx;
opacity: 0.9;
}
.arrow {
margin-top: 12rpx;
font-size: 22rpx;
opacity: 0.9;
}
/* 分区标题 */
.section-title {
margin: 40rpx 8rpx 20rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
/* 景点列表 */
.poi-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.poi-item {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.poi-icon {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fff2d6, #ffe0b3);
display: flex;
align-items: center;
justify-content: center;
font-size: 52rpx;
flex-shrink: 0;
}
.poi-info {
flex: 1;
margin: 0 24rpx;
overflow: hidden;
}
.poi-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.poi-desc {
margin-top: 6rpx;
font-size: 24rpx;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 打卡按钮 */
.poi-btn {
min-width: 160rpx;
height: 64rpx;
line-height: 64rpx;
padding: 0 24rpx;
font-size: 26rpx;
color: #fff;
background: linear-gradient(135deg, #ffb347, #ff7e5f);
border-radius: 32rpx;
border: none;
}
.poi-btn-done {
background: #e5e5e5 !important;
color: #999 !important;
}
.poi-btn[disabled] {
background: #e5e5e5 !important;
color: #999 !important;
}
/* 调试按钮 */
.reset-wrap {
margin: 60rpx 0 40rpx;
display: flex;
justify-content: center;
}
.reset-btn {
font-size: 24rpx;
color: #999;
background: transparent;
padding: 12rpx 32rpx;
}
+40
View File
@@ -0,0 +1,40 @@
{
"miniprogramRoot": "./",
"projectname": "打卡",
"appid": "wxa3abd3957b0eb47b",
"description": "景点打卡示例",
"setting": {
"es6": true,
"enhance": true,
"postcss": true,
"minified": true,
"urlCheck": true,
"compileHotReLoad": true,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"libVersion": "3.0.0",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}
+8
View File
@@ -0,0 +1,8 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}
+113
View File
@@ -0,0 +1,113 @@
// userStore.js
// 用户数据管理模块:负责积分、徽章、打卡记录的本地存储与业务逻辑
const STORAGE_KEY = 'USER_DATA';
// 默认的用户数据结构
const defaultUserData = {
points: 0, // 用户积分
badges: [], // 用户徽章列表,元素形如 { name, obtainedAt }
checkIns: [] // 打卡记录,元素形如 { poiId, poiName, time }
};
/**
* 获取完整的用户数据
* 如果本地没有数据,返回默认结构
* @returns {{points:number, badges:Array, checkIns:Array}}
*/
function getUserData() {
try {
const data = wx.getStorageSync(STORAGE_KEY);
if (data && typeof data === 'object') {
// 兼容旧数据:补齐可能缺失的字段
return Object.assign({}, defaultUserData, data);
}
} catch (e) {
console.error('[userStore] 读取用户数据失败:', e);
}
// 深拷贝默认数据,避免外部修改影响默认值
return JSON.parse(JSON.stringify(defaultUserData));
}
/**
* 保存用户数据到本地
* @param {Object} data
*/
function saveUserData(data) {
try {
wx.setStorageSync(STORAGE_KEY, data);
} catch (e) {
console.error('[userStore] 保存用户数据失败:', e);
}
}
/**
* 判断某个景点是否已经打卡过
* @param {string|number} poiId
* @returns {boolean}
*/
function hasCheckedIn(poiId) {
const data = getUserData();
return data.checkIns.some(item => item.poiId === poiId);
}
/**
* 判断是否已经拥有某个徽章
* @param {Object} data 用户数据
* @param {string} badgeName
* @returns {boolean}
*/
function hasBadge(data, badgeName) {
return data.badges.some(b => b.name === badgeName);
}
/**
* 处理打卡逻辑
* 检查是否已打卡;如未打卡则 +10 积分,添加“探索者”徽章(如没有),保存数据
* @param {string|number} poiId 景点 ID
* @param {string} poiName 景点名称
* @returns {{success:boolean, message:string, data?:Object}}
*/
function checkIn(poiId, poiName) {
if (!poiId) {
return { success: false, message: '景点 ID 无效' };
}
const data = getUserData();
// 已打卡则不再重复奖励
if (data.checkIns.some(item => item.poiId === poiId)) {
return { success: false, message: '你已经打卡过该景点', data };
}
// 增加打卡记录
data.checkIns.push({
poiId,
poiName: poiName || '',
time: Date.now()
});
// 增加 10 积分
data.points += 10;
// 添加“探索者”徽章(若尚未拥有)
const BADGE_NAME = '探索者';
if (!hasBadge(data, BADGE_NAME)) {
data.badges.push({
name: BADGE_NAME,
obtainedAt: Date.now()
});
}
// 保存到本地
saveUserData(data);
return { success: true, message: '打卡成功,积分 +10', data };
}
module.exports = {
getUserData,
saveUserData,
hasCheckedIn,
checkIn
};
+4 -5
View File
@@ -10,9 +10,9 @@ const weixinAppId = manifestEnv.UNI_MP_WEIXIN_APPID || 'touristappid'
export default defineManifestConfig({
'name': '光明文旅地图',
'appid': '',
'description': '深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC',
'versionName': '0.3.0',
'versionCode': '30',
'description': '深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC',
'versionName': '0.2.0',
'versionCode': '20',
'transformPx': false,
'uniStatistics': {
enable: false,
@@ -69,10 +69,9 @@ export default defineManifestConfig({
'mp-weixin': {
// Configure locally with UNI_MP_WEIXIN_APPID; do not commit project credentials.
appid: weixinAppId,
lazyCodeLoading: 'requiredComponents',
permission: {
'scope.userLocation': {
desc: '用于地图定位、行程优化和到点打卡判断',
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
},
},
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
-1
View File
@@ -121,7 +121,6 @@
"postcss-scss": "^4.0.9",
"sass": "1.63.2",
"sass-loader": "10.4.1",
"sharp": "^0.35.3",
"simple-git-hooks": "^2.11.1",
"stylelint": "^16.9.0",
"stylelint-config-recess-order": "^5.1.1",
+2 -4
View File
@@ -5,12 +5,10 @@ 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: {
@@ -45,7 +43,7 @@ export default defineUniPages({
},
{
pagePath: 'pages/assistant/index',
text: '文旅助手',
text: 'AI 助手',
iconPath: 'static/tabbar/assistant.png',
selectedIconPath: 'static/tabbar/assistant-selected.png',
},
-318
View File
@@ -183,9 +183,6 @@ importers:
sass-loader:
specifier: 10.4.1
version: 10.4.1(sass@1.63.2)(webpack@5.99.9)
sharp:
specifier: ^0.35.3
version: 0.35.3(@types/node@24.0.3)
simple-git-hooks:
specifier: ^2.11.1
version: 2.13.0
@@ -1075,9 +1072,6 @@ packages:
'@emnapi/core@1.4.3':
resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@emnapi/runtime@1.4.3':
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
@@ -1316,152 +1310,6 @@ packages:
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.3':
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.3':
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.3':
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.2':
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.2':
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.2':
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.3.2':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.3.2':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-riscv64@1.3.2':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.3.2':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.3.2':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.35.3':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.35.3':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.35.3':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-riscv64@0.35.3':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.35.3':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.35.3':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.35.3':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.35.3':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.35.3':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.3':
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.3':
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.3':
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.3':
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
'@intlify/core-base@9.1.9':
resolution: {integrity: sha512-x5T0p/Ja0S8hs5xs+ImKyYckVkL4CzcEXykVYYV6rcbXxJTe2o58IquSqX9bdncVKbRZP7GlBU1EcRaQEEJ+vw==}
engines: {node: '>= 10'}
@@ -3348,10 +3196,6 @@ packages:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
@@ -5659,11 +5503,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
send@0.18.0:
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'}
@@ -5682,15 +5521,6 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.35.3:
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -8285,11 +8115,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.4.3':
dependencies:
tslib: 2.8.1
@@ -8480,112 +8305,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.2
optional: true
'@img/sharp-darwin-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.2
optional: true
'@img/sharp-freebsd-wasm32@0.35.3':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.2':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.2':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.2':
optional: true
'@img/sharp-libvips-linux-arm@1.3.2':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.2':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.2':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.2':
optional: true
'@img/sharp-libvips-linux-x64@1.3.2':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
optional: true
'@img/sharp-linux-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.2
optional: true
'@img/sharp-linux-arm@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.2
optional: true
'@img/sharp-linux-ppc64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.2
optional: true
'@img/sharp-linux-riscv64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.2
optional: true
'@img/sharp-linux-s390x@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.2
optional: true
'@img/sharp-linux-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.2
optional: true
'@img/sharp-linuxmusl-arm64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
optional: true
'@img/sharp-linuxmusl-x64@0.35.3':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
optional: true
'@img/sharp-wasm32@0.35.3':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-webcontainers-wasm32@0.35.3':
dependencies:
'@img/sharp-wasm32': 0.35.3
optional: true
'@img/sharp-win32-arm64@0.35.3':
optional: true
'@img/sharp-win32-ia32@0.35.3':
optional: true
'@img/sharp-win32-x64@0.35.3':
optional: true
'@intlify/core-base@9.1.9':
dependencies:
'@intlify/devtools-if': 9.1.9
@@ -10896,8 +10615,6 @@ snapshots:
detect-indent@6.1.0: {}
detect-libc@2.1.2: {}
detect-newline@3.1.0: {}
devlop@1.1.0:
@@ -13636,8 +13353,6 @@ snapshots:
semver@7.7.2: {}
semver@7.8.5: {}
send@0.18.0:
dependencies:
debug: 2.6.9
@@ -13689,39 +13404,6 @@ snapshots:
setprototypeof@1.2.0: {}
sharp@0.35.3(@types/node@24.0.3):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.3
'@img/sharp-darwin-x64': 0.35.3
'@img/sharp-freebsd-wasm32': 0.35.3
'@img/sharp-libvips-darwin-arm64': 1.3.2
'@img/sharp-libvips-darwin-x64': 1.3.2
'@img/sharp-libvips-linux-arm': 1.3.2
'@img/sharp-libvips-linux-arm64': 1.3.2
'@img/sharp-libvips-linux-ppc64': 1.3.2
'@img/sharp-libvips-linux-riscv64': 1.3.2
'@img/sharp-libvips-linux-s390x': 1.3.2
'@img/sharp-libvips-linux-x64': 1.3.2
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
'@img/sharp-linux-arm': 0.35.3
'@img/sharp-linux-arm64': 0.35.3
'@img/sharp-linux-ppc64': 0.35.3
'@img/sharp-linux-riscv64': 0.35.3
'@img/sharp-linux-s390x': 0.35.3
'@img/sharp-linux-x64': 0.35.3
'@img/sharp-linuxmusl-arm64': 0.35.3
'@img/sharp-linuxmusl-x64': 0.35.3
'@img/sharp-webcontainers-wasm32': 0.35.3
'@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.3
'@types/node': 24.0.3
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
+13 -4
View File
@@ -1,7 +1,7 @@
{
"miniprogramRoot": "dist/dev/mp-weixin/",
"projectname": "光明文旅地图",
"description": "深圳市光明区全域文旅地图、POI 数字化展示与本地行程规划 POC",
"description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC",
"setting": {
"es6": true,
"postcss": true,
@@ -15,7 +15,16 @@
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
"minifyWXML": true,
"compileWorklet": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"minifyWXSS": true,
"localPlugins": false,
"disableUseStrict": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"compileType": "miniprogram",
"srcMiniprogramRoot": "dist/dev/mp-weixin/",
@@ -24,7 +33,7 @@
"ignore": [],
"include": []
},
"appid": "wxda4fb5048b5dc9a1",
"appid": "wxa3abd3957b0eb47b",
"editorSetting": {},
"condition": {}
}
}
-107
View File
@@ -1,107 +0,0 @@
import sharp from 'sharp'
import { readdir, mkdir, unlink } from 'node:fs/promises'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, '..')
const sourceDir = join(projectRoot, 'src/static/poi/photos')
const outputDir = sourceDir // 处理后直接覆盖到同目录(原文件先删除)
// 中文名 → POI ID 映射(全部30个景点)
const NAME_TO_POI_ID = {
// 文化场馆(已处理,保留映射以便确认)
'文化艺术中心': 'poi_gm_culture_center',
'光明区图书馆': 'poi_amap_b0hbtu4bfr',
'光明区文化馆': 'poi_amap_b0ffk306gx',
'少年儿童图书馆': 'poi_amap_b02f38meio',
'深圳国际美术馆': 'poi_amap_b0jun773j3',
'城市规划馆': 'poi_amap_b0jbbzx4oo',
'茅洲河图书馆': 'poi_amap_b0k025w32e',
// 公园湿地
'虹桥公园': 'poi_hongqiao_park',
'红花山公园': 'poi_honghuashan_park',
'科学公园': 'poi_amap_b0j2jc4n0w',
'鹅颈水湿地公园': 'poi_amap_b0ffmdhve1',
'明湖公园': 'poi_amap_b0fffwu1l5',
'光明新城公园': 'poi_amap_b0ffhfbsje',
'开明公园': 'poi_amap_b0ffjf1b20',
'左岸科技公园': 'poi_amap_b0g0kzrt5s',
'西田公园': 'poi_amap_b02f30abgs',
'大雁山森林公园': 'poi_amap_b0jgbp18uf',
'楼村湿地公园': 'poi_amap_b0grac6nzz',
'大顶岭山林公园': 'poi_amap_b02f309r88',
// 田园休闲
'光明农场大观园': 'poi_farm_grand_view',
'华侨城光明欢乐田园': 'poi_happy_pastoral',
'双晖稻田农场': 'poi_amap_b0ffkkmzsc',
// 人文地标
'弘源寺': 'poi_amap_b02f38nj9k',
'陈仙姑祠': 'poi_amap_b0ffgthbcf',
'黄氏大宗祠': 'poi_amap_b0fffttiov',
'玉律醒狮文化馆': 'poi_amap_b0kb4cxaby',
// 绿道户外
'大顶岭绿道': 'poi_dadingling_greenway',
'光明湖碧道': 'poi_amap_b0ldbsbeea',
'茅洲河碧道': 'poi_amap_b0kup558j4',
'五指耙森林公园': 'poi_amap_b0jb2aurvw',
}
const TARGET_WIDTH = 800
const TARGET_HEIGHT = 600
const JPEG_QUALITY = 80
async function process() {
const files = await readdir(sourceDir)
const jpgFiles = files.filter(f => f.endsWith('.jpg') || f.endsWith('.jpeg') || f.endsWith('.png'))
console.log(`Found ${jpgFiles.length} source images\n`)
for (const file of jpgFiles) {
const nameWithoutExt = file.replace(/\.(jpg|jpeg|png)$/i, '')
const poiId = NAME_TO_POI_ID[nameWithoutExt]
if (!poiId) {
console.log(`SKIP: "${file}" — no matching POI ID found`)
continue
}
const inputPath = join(sourceDir, file)
const outputName = `${poiId}.jpg`
const outputPath = join(outputDir, outputName)
console.log(`Processing: ${file}${outputName}`)
try {
await sharp(inputPath)
.rotate() // 根据 EXIF 自动旋转
.resize(TARGET_WIDTH, TARGET_HEIGHT, {
fit: 'cover',
position: 'attention', // 智能裁剪,尽量保留重要区域
})
.jpeg({
quality: JPEG_QUALITY,
mozjpeg: true,
chromaSubsampling: '4:2:0',
})
.toFile(outputPath)
// 删除原始中文名文件
if (file !== outputName) {
await unlink(inputPath)
}
console.log(` ✓ Done`)
} catch (err) {
console.error(` ✗ Failed: ${err.message}`)
}
}
console.log('\nDone! Listing output directory:')
const outputFiles = await readdir(outputDir)
for (const f of outputFiles.filter(f => f.endsWith('.jpg')).sort()) {
console.log(` ${f}`)
}
}
process().catch(console.error)
-29
View File
@@ -11,8 +11,6 @@ const expectedPages = [
'pages/itinerary/index',
'pages/planner/index',
'pages/poi/detail',
'pages/check-in/index',
'pages/check-in/records',
]
const expectedTabPages = [
'pages/map/index',
@@ -44,29 +42,6 @@ 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('<view', checkInEntryIndex)
const checkInEntryEnd = poiDetailMarkup.indexOf('>', checkInEntryIndex)
const checkInEntryTag = checkInEntryIndex >= 0 && checkInEntryStart >= 0 && checkInEntryEnd >= 0
? poiDetailMarkup.slice(checkInEntryStart, checkInEntryEnd + 1)
: ''
const visibilityAttributes = ['wx:if=', 'wx:elif=', 'wx:else', 'hidden=']
if (!checkInEntryTag || visibilityAttributes.some(attribute => checkInEntryTag.includes(attribute))) {
console.error('景点详情页必须无条件渲染打卡入口。')
process.exit(1)
}
const expectedPrivateInfos = ['getLocation', 'startLocationUpdate', 'onLocationChange']
const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? []
if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) {
@@ -80,10 +55,6 @@ 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) {
+7 -3
View File
@@ -2,8 +2,6 @@
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:
@@ -14,9 +12,15 @@ This service is reserved for a later AI integration phase. The current mini-prog
"preferences": {
"destination": "深圳市光明区",
"themes": ["亲子"],
"duration": "half_day",
"pace": "moderate",
"interests": ["自然风光"],
"transport": "public_transport"
"adults": 2,
"children": 1,
"childAges": [6],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": ""
},
"durationMinutes": 240,
"selectedPoiIds": ["poi_a", "poi_b"],
+2
View File
@@ -28,6 +28,8 @@ def _score(candidate: PoiCandidate, request: PlanRecommendationRequest) -> int:
for interest in request.preferences.interests:
desired_tags.update(INTEREST_TAGS.get(interest, set()))
score += len(desired_tags.intersection(candidate.tag_codes)) * 8
if request.preferences.children and "family_friendly" in candidate.tag_codes:
score += 8
return score
+23
View File
@@ -25,6 +25,11 @@ class PlanningMode(str, Enum):
CUSTOM = "custom"
class Duration(str, Enum):
HALF_DAY = "half_day"
FULL_DAY = "full_day"
class Pace(str, Enum):
RELAXED = "relaxed"
MODERATE = "moderate"
@@ -37,6 +42,12 @@ class Transport(str, Enum):
PUBLIC_TRANSPORT = "public_transport"
class BudgetLevel(str, Enum):
ECONOMY = "economy"
STANDARD = "standard"
QUALITY = "quality"
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
@@ -44,12 +55,24 @@ Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "
class TravelPreferences(APIModel):
destination: Literal["深圳市光明区"] = "深圳市光明区"
themes: list[Theme] = Field(default_factory=list, max_length=5)
duration: Duration
pace: Pace
interests: list[Interest] = Field(default_factory=list, max_length=5)
adults: int = Field(default=2, ge=0, le=20)
children: int = Field(default=0, ge=0, le=20)
child_ages: list[int] = Field(default_factory=list, max_length=20)
transport: Transport = Transport.PUBLIC_TRANSPORT
budget_level: BudgetLevel = BudgetLevel.STANDARD
extra_requirements: str = Field(default="", max_length=500)
@model_validator(mode="after")
def validate_group(self) -> "TravelPreferences":
if self.adults + self.children < 1:
raise ValueError("出行总人数至少为 1")
if self.child_ages and len(self.child_ages) != self.children:
raise ValueError("儿童年龄数量必须与儿童人数一致")
if any(age < 0 or age > 17 for age in self.child_ages):
raise ValueError("儿童年龄须在 0 至 17 岁之间")
if len(set(self.themes)) != len(self.themes):
raise ValueError("出行主题不能重复")
if len(set(self.interests)) != len(self.interests):
+6
View File
@@ -16,9 +16,15 @@ def preferences():
return {
"destination": "深圳市光明区",
"themes": ["亲子"],
"duration": "half_day",
"pace": "moderate",
"interests": ["自然风光"],
"adults": 2,
"children": 1,
"childAges": [6],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": "",
}
+6
View File
@@ -14,9 +14,15 @@ 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"],
+2 -31
View File
@@ -10,7 +10,6 @@ defineProps<{
}>()
const emit = defineEmits<{
checkIns: []
select: [categoryCode: string | null]
}>()
</script>
@@ -22,10 +21,7 @@ const emit = defineEmits<{
<text class="map-filter-header__title">光明区文旅资源</text>
<text class="map-filter-header__scope">{{ coverageLabel }}</text>
</view>
<view class="map-filter-header__actions">
<text class="map-filter-header__count"> {{ resultCount }} 个点位</text>
<button class="map-filter-header__check-ins" @click="emit('checkIns')">我的打卡</button>
</view>
<text class="map-filter-header__count"> {{ resultCount }} 个点位</text>
</view>
<scroll-view class="map-filter-header__scroll" scroll-x :show-scrollbar="false">
@@ -95,36 +91,11 @@ const emit = defineEmits<{
}
.map-filter-header__count {
flex: none;
font-weight: 600;
color: #167a5b;
}
.map-filter-header__actions {
display: flex;
flex: none;
flex-direction: column;
gap: 4rpx;
align-items: flex-end;
}
.map-filter-header__check-ins {
width: auto;
height: 42rpx;
padding: 0 15rpx;
margin: 0;
font-size: 20rpx;
font-weight: 700;
line-height: 42rpx;
color: #0e6247;
background: #e8f4ee;
border: 0;
border-radius: 21rpx;
}
.map-filter-header__check-ins::after {
border: 0;
}
.map-filter-header__scroll {
width: 100%;
height: 80rpx;
-1
View File
@@ -1 +0,0 @@
export * from './tasks'
-31
View File
@@ -1,31 +0,0 @@
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))
}
+11 -20
View File
@@ -1,9 +1,7 @@
import type { Poi, PoiDataset, PoiImageAsset } from '@/domain/poi'
import { coverPhotoUrlFor, hasCoverPhoto } from './photos'
const SNAPSHOT_CAPTURED_AT = '2026-07-30T01:00:33Z'
const POC_REVIEWED_AT = '2026-07-30T01:30:00Z'
const PHOTOS_REVIEWED_AT = '2026-07-31T00:00:00Z'
const PLACEHOLDER_URL = '/static/poi/placeholder.png'
const AMAP_DATA_SOURCE = '高德地图 Web Service POI 2.0 离线候选快照(2026-07-30),POC 内容映射待项目方复核'
@@ -418,26 +416,19 @@ const poiSeeds: PocPoiSeed[] = [
},
]
function createCoverImage(seed: PocPoiSeed): PoiImageAsset {
const photoReady = hasCoverPhoto(seed.id)
function createPlaceholderImage(seed: PocPoiSeed): PoiImageAsset {
return {
id: `img_${seed.id}_cover`,
url: photoReady ? coverPhotoUrlFor(seed.id) : PLACEHOLDER_URL,
source: photoReady
? '项目方提供实景照片(本地静态资源)'
: 'map-gm-app POC 包内占位素材',
sourceReference: photoReady
? `asset_src_static_poi_photos_${seed.id}_jpg`
: 'asset_src_static_poi_placeholder',
url: PLACEHOLDER_URL,
source: 'map-gm-app POC 包内占位素材',
sourceReference: 'asset_src_static_poi_placeholder',
copyrightStatus: 'cleared',
licenseEvidence: photoReady
? 'PROJECT_PROVIDED_COVER_PHOTO_2026Q3'
: 'POC_INTERNAL_ASSET_001',
reviewedBy: photoReady ? 'project_content_owner' : 'poc_content_owner',
reviewedAt: photoReady ? PHOTOS_REVIEWED_AT : POC_REVIEWED_AT,
updatedAt: photoReady ? PHOTOS_REVIEWED_AT : POC_REVIEWED_AT,
isPlaceholder: !photoReady,
alt: photoReady ? `${seed.name}实景照片` : `${seed.name} POC 占位图`,
licenseEvidence: 'POC_INTERNAL_ASSET_001',
reviewedBy: 'poc_content_owner',
reviewedAt: POC_REVIEWED_AT,
updatedAt: POC_REVIEWED_AT,
isPlaceholder: true,
alt: `${seed.name} POC 占位图`,
}
}
@@ -553,6 +544,6 @@ export const poiDataset: PoiDataset = {
{ code: 'science_learning', name: '科普体验', sortOrder: 90, status: 'enabled', synonyms: ['科普'] },
{ code: 'walking', name: '漫步休闲', sortOrder: 100, status: 'enabled', synonyms: ['徒步', '散步'] },
],
images: poiSeeds.map(createCoverImage),
images: poiSeeds.map(createPlaceholderImage),
pois: poiSeeds.map(createPoi),
}
-60
View File
@@ -1,60 +0,0 @@
/**
* POI 封面照片注册表
*
* 如何添加一张真实照片:
* 1. 压缩图片:800×600 px4:3 横版),JPEG 质量 75-82,文件 ≤ 120 KB
* 2. 命名为 <poiId>.jpg(例如 poi_gm_culture_center.jpg
* 3. 放入 src/static/poi/photos/ 目录
* 4. 将 poiId 加入下方 POI_COVER_PHOTOS_READY 集合
*
* 未在此集合中的 POI 会继续使用占位图 /static/poi/placeholder.png
*/
/** 已放入真实照片的 POI ID 集合 */
export const POI_COVER_PHOTOS_READY: ReadonlySet<string> = new Set<string>([
// 文化场馆
'poi_gm_culture_center', // 光明文化艺术中心
'poi_amap_b0hbtu4bfr', // 光明区图书馆
'poi_amap_b0ffk306gx', // 光明区文化馆
'poi_amap_b02f38meio', // 光明区少年儿童图书馆
'poi_amap_b0jun773j3', // 深圳国际美术馆
'poi_amap_b0jbbzx4oo', // 光明区城市规划展览馆
'poi_amap_b0k025w32e', // 茅洲河图书馆
// 公园湿地
'poi_hongqiao_park', // 虹桥公园
'poi_honghuashan_park', // 红花山公园
'poi_amap_b0j2jc4n0w', // 科学公园
'poi_amap_b0ffmdhve1', // 鹅颈水湿地公园
'poi_amap_b0fffwu1l5', // 明湖公园
'poi_amap_b0ffhfbsje', // 光明新城公园(牛山公园)
'poi_amap_b0ffjf1b20', // 光明开明公园
'poi_amap_b0g0kzrt5s', // 左岸科技公园
'poi_amap_b02f30abgs', // 西田公园
'poi_amap_b0jgbp18uf', // 大雁山森林公园
'poi_amap_b0grac6nzz', // 楼村湿地公园
'poi_amap_b02f309r88', // 大顶岭山林公园
// 田园休闲
'poi_farm_grand_view', // 光明农场大观园
'poi_happy_pastoral', // 华侨城光明欢乐田园
'poi_amap_b0ffkkmzsc', // 双晖稻田农场
// 人文地标
'poi_amap_b02f38nj9k', // 弘源寺
'poi_amap_b0ffgthbcf', // 陈仙姑祠
'poi_amap_b0fffttiov', // 黄氏大宗祠
'poi_amap_b0kb4cxaby', // 玉律醒狮文化馆
// 绿道户外
'poi_dadingling_greenway', // 大顶岭绿道
'poi_amap_b0ldbsbeea', // 光明湖碧道
'poi_amap_b0kup558j4', // 茅洲河碧道
'poi_amap_b0jb2aurvw', // 五指耙森林公园(光明片区)
])
const POI_PHOTOS_BASE_URL = '/static/poi/photos'
export function coverPhotoUrlFor(poiId: string): string {
return `${POI_PHOTOS_BASE_URL}/${poiId}.jpg`
}
export function hasCoverPhoto(poiId: string): boolean {
return POI_COVER_PHOTOS_READY.has(poiId)
}
-218
View File
@@ -1,218 +0,0 @@
import type {
CheckInBadge,
CheckInBadgeCode,
CheckInBadgeDefinition,
CheckInEligibility,
CheckInLocationSnapshot,
CheckInProfile,
CheckInRecord,
CheckInResult,
} from './types'
import type { GeoPoint } from '@/domain/poi'
export const CHECK_IN_POINTS = 10
export const CHECK_IN_RADIUS_METERS = 200
export const CHECK_IN_MAX_ACCURACY_METERS = 100
export const CHECK_IN_BADGES: readonly CheckInBadgeDefinition[] = [
{
code: 'explorer',
name: '光明探索者',
description: '完成首个真实定位点位打卡',
icon: '01',
requiredCheckIns: 1,
},
{
code: 'traveller',
name: '光明旅行家',
description: '累计打卡 2 个开放点位',
icon: '02',
requiredCheckIns: 2,
},
{
code: 'check_in_master',
name: '全域打卡达人',
description: '累计打卡 3 个开放点位',
icon: '03',
requiredCheckIns: 3,
},
]
export class CheckInError extends Error {
constructor(
message: string,
readonly code: 'already_checked_in' | 'invalid_poi' | 'invalid_location' | 'not_eligible',
) {
super(message)
this.name = 'CheckInError'
}
}
export function createEmptyCheckInProfile(): CheckInProfile {
return { points: 0, badges: [], checkIns: [] }
}
export function cloneCheckInProfile(profile: CheckInProfile): CheckInProfile {
return {
points: profile.points,
badges: profile.badges.map(badge => ({ ...badge })),
checkIns: profile.checkIns.map(record => ({ ...record })),
}
}
function toRadians(value: number): number {
return value * Math.PI / 180
}
function isValidPoint(point: GeoPoint): boolean {
return Number.isFinite(point.longitude)
&& Number.isFinite(point.latitude)
&& point.longitude >= -180
&& point.longitude <= 180
&& point.latitude >= -90
&& point.latitude <= 90
}
export function distanceBetweenMeters(left: GeoPoint, right: GeoPoint): number {
if (!isValidPoint(left) || !isValidPoint(right))
return Number.NaN
const earthRadiusMeters = 6_371_008.8
const latitudeDelta = toRadians(right.latitude - left.latitude)
const longitudeDelta = toRadians(right.longitude - left.longitude)
const leftLatitude = toRadians(left.latitude)
const rightLatitude = toRadians(right.latitude)
const haversine = Math.sin(latitudeDelta / 2) ** 2
+ Math.cos(leftLatitude) * Math.cos(rightLatitude) * Math.sin(longitudeDelta / 2) ** 2
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
}
export function createCheckInRecordId(poiId: string, checkedInAt: string): string {
return `check-in:${poiId}:${checkedInAt}`
}
export function evaluateCheckInEligibility(
location: CheckInLocationSnapshot,
poi: GeoPoint,
allowedDistanceMeters = CHECK_IN_RADIUS_METERS,
): CheckInEligibility {
if (!Number.isFinite(allowedDistanceMeters) || allowedDistanceMeters <= 0) {
return {
eligible: false,
code: 'invalid_location',
distanceMeters: null,
allowedDistanceMeters: CHECK_IN_RADIUS_METERS,
message: '点位打卡范围配置无效,请稍后重试',
}
}
if (location.coordinateSystem !== 'GCJ02' || !isValidPoint(location)) {
return {
eligible: false,
code: 'invalid_location',
distanceMeters: null,
allowedDistanceMeters,
message: '定位结果无效,请重新获取当前位置',
}
}
const accuracy = location.accuracy
if (accuracy === null || !Number.isFinite(accuracy) || accuracy <= 0 || accuracy > CHECK_IN_MAX_ACCURACY_METERS) {
return {
eligible: false,
code: 'low_accuracy',
distanceMeters: null,
allowedDistanceMeters,
message: '当前位置精度不足,请到开阔处重新定位',
}
}
const distanceMeters = distanceBetweenMeters(location, poi)
if (!Number.isFinite(distanceMeters)) {
return {
eligible: false,
code: 'invalid_location',
distanceMeters: null,
allowedDistanceMeters,
message: '定位结果无效,请重新获取当前位置',
}
}
if (distanceMeters - accuracy > allowedDistanceMeters) {
return {
eligible: false,
code: 'too_far',
distanceMeters,
allowedDistanceMeters,
message: `请到点位 ${Math.round(allowedDistanceMeters)} 米范围内再打卡`,
}
}
if (distanceMeters + accuracy > allowedDistanceMeters) {
return {
eligible: false,
code: 'uncertain',
distanceMeters,
allowedDistanceMeters,
message: '当前位置接近打卡边界,请靠近点位后重新定位',
}
}
return {
eligible: true,
code: 'eligible',
distanceMeters,
allowedDistanceMeters,
message: '已进入点位打卡范围',
}
}
export function hasCheckedIn(profile: CheckInProfile, poiId: string): boolean {
const normalizedPoiId = poiId.trim()
return Boolean(normalizedPoiId) && profile.checkIns.some(record => record.poiId === normalizedPoiId)
}
function badgesForCheckInCount(checkInCount: number, obtainedAt: string): CheckInBadge[] {
return CHECK_IN_BADGES
.filter(definition => checkInCount >= definition.requiredCheckIns)
.map(definition => ({ code: definition.code, obtainedAt }))
}
export function applyCheckIn(
profile: CheckInProfile,
poi: { id: string, name: string } & GeoPoint,
location: CheckInLocationSnapshot,
now = Date.now(),
allowedDistanceMeters = CHECK_IN_RADIUS_METERS,
): CheckInResult {
const poiId = poi.id.trim()
const poiName = poi.name.trim()
if (!poiId || !poiName || !isValidPoint(poi))
throw new CheckInError('点位信息无效,请返回地图重新选择', 'invalid_poi')
if (hasCheckedIn(profile, poiId))
throw new CheckInError('该点位已经打卡,本机不会重复计分', 'already_checked_in')
const eligibility = evaluateCheckInEligibility(location, poi, allowedDistanceMeters)
if (!eligibility.eligible || eligibility.distanceMeters === null)
throw new CheckInError(eligibility.message, 'not_eligible')
const checkedInAt = new Date(now).toISOString()
const record: CheckInRecord = {
id: createCheckInRecordId(poiId, checkedInAt),
poiId,
poiName,
checkedInAt,
pointsAwarded: CHECK_IN_POINTS,
}
const previousBadgeCodes = new Set<CheckInBadgeCode>(profile.badges.map(badge => badge.code))
const earnedBadges = badgesForCheckInCount(profile.checkIns.length + 1, checkedInAt)
const newBadges = earnedBadges.filter(badge => !previousBadgeCodes.has(badge.code))
const nextProfile: CheckInProfile = {
points: profile.points + CHECK_IN_POINTS,
badges: [
...profile.badges.map(badge => ({ ...badge })),
...newBadges,
],
checkIns: [record, ...profile.checkIns.map(item => ({ ...item }))],
}
return { profile: nextProfile, record: { ...record }, newBadges: newBadges.map(badge => ({ ...badge })) }
}
-3
View File
@@ -1,3 +0,0 @@
export * from './check-in'
export * from './types'
export * from './validation'
-51
View File
@@ -1,51 +0,0 @@
import type { CoordinateSystem, GeoPoint } from '@/domain/poi'
export type CheckInBadgeCode = 'explorer' | 'traveller' | 'check_in_master'
export type CheckInEligibilityCode = 'eligible' | 'too_far' | 'uncertain' | 'low_accuracy' | 'invalid_location'
export interface CheckInLocationSnapshot extends GeoPoint {
accuracy: number | null
receivedAt: string
coordinateSystem: CoordinateSystem
}
export interface CheckInRecord {
id: string
poiId: string
poiName: string
checkedInAt: string
pointsAwarded: number
}
export interface CheckInBadge {
code: CheckInBadgeCode
obtainedAt: string
}
export interface CheckInProfile {
points: number
badges: CheckInBadge[]
checkIns: CheckInRecord[]
}
export interface CheckInEligibility {
eligible: boolean
code: CheckInEligibilityCode
distanceMeters: number | null
allowedDistanceMeters: number
message: string
}
export interface CheckInResult {
profile: CheckInProfile
record: CheckInRecord
newBadges: CheckInBadge[]
}
export interface CheckInBadgeDefinition {
code: CheckInBadgeCode
name: string
description: string
icon: string
requiredCheckIns: number
}
-105
View File
@@ -1,105 +0,0 @@
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 })
}
+5 -2
View File
@@ -18,8 +18,11 @@ export function formatOpeningHours(openingHours: PoiOpeningHours): string {
return openingHours.displayText?.trim() || '开放时间待确认'
}
export function formatRecommendationLabel(_source: RecommendationSource, index: number): string {
return `推荐指数 ${index}/5`
export function formatRecommendationLabel(source: RecommendationSource, index: number): string {
const label = source === 'official_editorial'
? '官方推荐指数'
: 'POC 推荐指数(待审核)'
return `${label} ${index}/5`
}
export function filterPois(pois: readonly Poi[], filter: PoiFilter = {}): Poi[] {
+3 -47
View File
@@ -228,31 +228,6 @@ 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,
@@ -263,28 +238,13 @@ 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 fitting = ranked.filter((poi) => {
const next = ranked.find((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
@@ -294,12 +254,8 @@ function selectQuickPois(
cursor = next
}
if (selected.length === 0 && remaining.length > 0) {
throw new TravelValidationError(
'当前起点、交通方式与所选时长无法容纳首个点位',
'quick_route_unreachable',
)
}
if (selected.length === 0 && remaining.length > 0)
selected.push(rankedCandidates(remaining, preferences, [], origin)[0])
return selected
}
+1 -3
View File
@@ -16,10 +16,8 @@ import {
const DESTINATION = '深圳市光明区' as const
export type TravelValidationErrorCode = 'quick_route_unreachable'
export class TravelValidationError extends Error {
constructor(message: string, readonly code?: TravelValidationErrorCode) {
constructor(message: string) {
super(message)
this.name = 'TravelValidationError'
}
+5 -6
View File
@@ -1,9 +1,9 @@
{
"name": "光明文旅地图",
"appid": "",
"description": "深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC",
"versionName": "0.3.0",
"versionCode": "30",
"description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC",
"versionName": "0.2.0",
"versionCode": "20",
"transformPx": false,
"app-plus": {
"usingComponents": true,
@@ -46,15 +46,14 @@
},
"quickapp": {},
"mp-weixin": {
"appid": "wxda4fb5048b5dc9a1",
"appid": "touristappid",
"setting": {
"urlCheck": true
},
"usingComponents": true,
"lazyCodeLoading": "requiredComponents",
"permission": {
"scope.userLocation": {
"desc": "用于地图定位、行程优化和到点打卡判断"
"desc": "用于在全域地图显示实时位置,并优化文旅行程顺序"
}
},
"requiredPrivateInfos": [
+3 -26
View File
@@ -1,7 +1,7 @@
{
"permission": {
"scope.userLocation": {
"desc": "用于地图定位、行程优化和到点打卡判断"
"desc": "用于在全域地图显示实时位置,并优化文旅行程顺序"
}
},
"requiredPrivateInfos": [
@@ -9,7 +9,6 @@
"startLocationUpdate",
"onLocationChange"
],
"lazyCodeLoading": "requiredComponents",
"easycom": {
"custom": {
"^u--(.*)": "uview-plus/components/u-$1/u-$1.vue",
@@ -34,29 +33,7 @@
"type": "page",
"layout": "map",
"style": {
"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": "我的打卡",
"navigationBarTitleText": "AI 文旅助手",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f4f8f6"
@@ -119,7 +96,7 @@
},
{
"pagePath": "pages/assistant/index",
"text": "文旅助手",
"text": "AI 助手",
"iconPath": "static/tabbar/assistant.png",
"selectedIconPath": "static/tabbar/assistant-selected.png"
},
+6 -6
View File
@@ -90,8 +90,8 @@ onShow(loadAssistantHome)
<view class="assistant-hero__glow assistant-hero__glow--two" />
<view class="assistant-hero__top">
<view>
<text class="assistant-hero__eyebrow">GUANGMING LOCAL GUIDE</text>
<text class="assistant-hero__title">光明文旅助手</text>
<text class="assistant-hero__eyebrow">GUANGMING AI GUIDE</text>
<text class="assistant-hero__title">光明文旅 AI 助手</text>
</view>
<view class="assistant-hero__status">
<view class="assistant-hero__status-dot" />
@@ -99,7 +99,7 @@ onShow(loadAssistantHome)
</view>
</view>
<view class="assistant-bot" aria-label="文旅助手形象">
<view class="assistant-bot" aria-label="AI 助手形象">
<view class="assistant-bot__antenna" />
<view class="assistant-bot__head">
<view class="assistant-bot__face">
@@ -108,7 +108,7 @@ onShow(loadAssistantHome)
<view class="assistant-bot__eye" />
</view>
</view>
<view class="assistant-bot__body">POC</view>
<view class="assistant-bot__body">AI</view>
</view>
<view class="assistant-hero__speech">
@@ -166,7 +166,7 @@ onShow(loadAssistantHome)
<view class="assistant-notice">
<text class="assistant-notice__title">POC 能力边界</text>
<text>本期不调用 AI助手使用本地 POI 和确定性规则生成路线当前路线为推荐游览顺序不提供实时路况或逐段导航</text>
<text>未配置在线 AI 服务时助手使用本地 POI 和确定性规则生成路线当前路线为推荐游览顺序不提供实时路况或逐段导航</text>
</view>
</view>
</view>
@@ -175,7 +175,7 @@ onShow(loadAssistantHome)
<route lang="yaml">
layout: map
style:
navigationBarTitleText: 文旅助手
navigationBarTitleText: AI 文旅助手
navigationBarBackgroundColor: '#ffffff'
navigationBarTextStyle: black
backgroundColor: '#f4f8f6'
-593
View File
@@ -1,593 +0,0 @@
<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>
-608
View File
@@ -1,608 +0,0 @@
<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 poiDirectoryError = 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) {
let poi: PoiResolved | null = null
try {
poi = getPoiRepository().getPoiById(poiId)
}
catch (error) {
poiDirectoryError.value = error instanceof Error ? error.message : '开放打卡点位暂时无法读取'
uni.showModal({
title: '暂时无法打开点位',
content: '开放点位目录暂时无法读取,请稍后重新加载。',
showCancel: false,
})
return
}
if (!poi) {
uni.showModal({
title: '点位已下线',
content: '该点位暂不在当前开放列表中,本机历史打卡记录仍会保留。',
showCancel: false,
})
return
}
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
}
function loadProfile() {
try {
profile.value = getCheckInProfile()
storageError.value = ''
}
catch (error) {
profile.value = { points: 0, badges: [], checkIns: [] }
storageError.value = error instanceof Error ? error.message : '本机打卡记录暂时无法读取'
}
}
function loadAvailablePois() {
try {
const repository = getPoiRepository()
availablePois.value = getCheckInTasks().flatMap((task) => {
const poi = repository.getPoiById(task.poiId)
return poi ? [poi] : []
})
poiDirectoryError.value = ''
}
catch (error) {
availablePois.value = []
poiDirectoryError.value = error instanceof Error ? error.message : '开放打卡点位暂时无法读取'
}
}
function loadRecords() {
loadProfile()
loadAvailablePois()
}
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(loadRecords)
</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>{{ storageError ? '--' : profile.points }}</text><text>本机积分</text></view>
<view><text>{{ storageError ? '--' : profile.checkIns.length }}</text><text>打卡点位</text></view>
<view><text>{{ storageError ? '--' : 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 v-if="poiDirectoryError" class="records-error">
<text class="records-error__title">开放点位暂时无法读取</text>
<text>{{ poiDirectoryError }}</text>
<button @click="loadAvailablePois">重新加载开放点位</button>
</view>
<view v-if="!storageError" 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 v-if="!poiDirectoryError && !storageError" 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 v-if="!storageError" 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>
+18 -132
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { PoiResolved } from '@/domain/poi'
import type { ItineraryItem, PlanningOrigin, PlanResponse, TravelPreferences } from '@/domain/travel'
import type { ItineraryItem, PlanResponse, TravelPreferences } from '@/domain/travel'
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
import { getPoiRepository } from '@/data/poi'
import { adjustPlan, getSessionPlanningOrigin, loadPlan, loadPreferences, savePlan } from '@/services/travel-assistant'
import { adjustPlan, loadPlan, loadPreferences, savePlan } from '@/services/travel-assistant'
import { useMapStore } from '@/stores'
interface ResolvedItineraryItem {
@@ -31,11 +31,6 @@ interface RouteMarker {
anchor: { x: number, y: number }
}
interface RoutePoint {
latitude: number
longitude: number
}
type DurationFitStatus = PlanResponse['itinerary']['durationFitStatus']
interface DurationFitNotice {
@@ -52,10 +47,7 @@ const adjusting = ref(false)
const mapReady = ref(false)
const mapError = ref(false)
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
const planningOrigin = shallowRef<PlanningOrigin | null>(null)
const quickPrompts = computed(() => plan.value?.itinerary.planningMode === 'custom'
? ['节奏慢一点', '改为步行', '改成半日游']
: ['节奏慢一点', '换成室内场馆', '增加亲子体验'])
const quickPrompts = ['节奏慢一点', '换成室内场馆', '增加亲子体验']
const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
if (!plan.value)
@@ -67,7 +59,7 @@ const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
})
})
const poiRouteMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
id: index + 1,
latitude: poi.latitude,
longitude: poi.longitude,
@@ -76,7 +68,7 @@ const poiRouteMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({
height: uni.upx2px(76),
anchor: { x: 0.5, y: 1 },
callout: {
content: String(index + 1),
content: `${index + 1} · ${poi.name}`,
color: '#18201d',
fontSize: 11,
borderRadius: 7,
@@ -87,47 +79,11 @@ const poiRouteMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({
},
})))
const originMarker = computed<RouteMarker | null>(() => {
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 }) => ({
const routePolyline = computed(() => [{
points: resolvedItems.value.map(({ poi }) => ({
latitude: poi.latitude,
longitude: poi.longitude,
})),
])
const routePolyline = computed(() => [{
points: routePoints.value,
color: '#167a5bcc',
width: 6,
dottedLine: false,
@@ -159,23 +115,13 @@ const planningSourceLabel = computed(() => {
return '真实 AI 推荐'
if (plan.value?.source === 'remote_mock')
return '远端演示规划'
return '本地 POC 规划'
})
const planningBasisLabel = computed(() => {
const location = plan.value?.itinerary.startsFromCurrentLocation
? planningOrigin.value ? '已结合本次当前位置' : '已结合定位(起点坐标未保留)'
: '光明区全域'
const selection = plan.value?.itinerary.planningMode === 'custom'
? '完整保留自选点位并优化顺序'
: '按偏好、时长和推荐指数自动选点'
return `${location} · ${selection}`
return '本地智能规划'
})
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
function loadStoredPlan() {
plan.value = loadPlan()
preferences.value = loadPreferences()
planningOrigin.value = plan.value ? getSessionPlanningOrigin(plan.value.conversationId) : null
}
function formatMinutes(minutes: number): string {
@@ -240,7 +186,7 @@ function formatDate(value: string): string {
}
function fitRoutePoints() {
if (!mapReady.value || !mapContext.value || routePoints.value.length === 0)
if (!mapReady.value || !mapContext.value || resolvedItems.value.length === 0)
return
const context = mapContext.value
@@ -249,7 +195,7 @@ function fitRoutePoints() {
return
try {
context.includePoints({
points: routePoints.value,
points: resolvedItems.value.map(({ poi }) => ({ latitude: poi.latitude, longitude: poi.longitude })),
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(70), uni.upx2px(70)],
fail: handleMapError,
})
@@ -297,11 +243,10 @@ function focusOnMap(poiId: string) {
uni.switchTab({ url: '/pages/map/index' })
}
function openFullRouteOnMap() {
mapStore.setCategory(null)
mapStore.selectPoi(null)
mapStore.showPlannedRoute()
uni.switchTab({ url: '/pages/map/index' })
function focusFirstOnMap() {
const poi = resolvedItems.value[0]?.poi
if (poi)
focusOnMap(poi.id)
}
function saveRoute() {
@@ -328,7 +273,6 @@ async function sendAdjustment(quick?: string) {
const response = await adjustPlan(plan.value.conversationId, content)
savePlan(response)
plan.value = response
planningOrigin.value = getSessionPlanningOrigin(response.conversationId)
preferences.value = loadPreferences()
message.value = ''
await showDurationFitNotice(response.itinerary.durationFitStatus)
@@ -371,11 +315,6 @@ onReady(initializeMap)
<view><text>行程点位</text><text>{{ resolvedItems.length }} </text></view>
</view>
<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 v-if="plan.changeSummary" class="change-notice">
@@ -396,9 +335,7 @@ onReady(initializeMap)
<view class="route-map-card__heading">
<view>
<text class="route-map-card__title">路线点位分布</text>
<text class="route-map-card__description">
{{ originMarker ? '含当前位置起点及全部游览节点' : '展示全部游览节点' }}按规划顺序直线连接
</text>
<text class="route-map-card__description">按推荐顺序直线连接并非实际道路导航</text>
</view>
<button @click="fitRoutePoints">查看全程</button>
</view>
@@ -420,11 +357,7 @@ onReady(initializeMap)
:show-compass="false"
@error="handleMapError"
/>
<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 class="route-map-card__notice">推荐顺序 · 非实时导航</view>
</view>
<view class="route-section">
@@ -468,7 +401,7 @@ onReady(initializeMap)
v-model="message"
:disabled="adjusting"
maxlength="500"
:placeholder="plan.itinerary.planningMode === 'custom' ? '例如:节奏慢一点,或改为步行' : '例如:换成室内场馆'"
placeholder="例如:第二个地点换成室内项目"
placeholder-class="composer__placeholder"
confirm-type="send"
@confirm="sendAdjustment()"
@@ -494,7 +427,7 @@ onReady(initializeMap)
<view class="bottom-actions">
<button class="bottom-actions__save" @click="saveRoute">保存路线</button>
<button class="bottom-actions__map" @click="openFullRouteOnMap">在全域地图查看完整路线</button>
<button class="bottom-actions__map" @click="focusFirstOnMap">在全域地图查看</button>
</view>
</view>
@@ -626,29 +559,6 @@ style:
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 {
padding: 18rpx 28rpx;
font-size: 21rpx;
@@ -766,30 +676,6 @@ style:
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 {
padding: 12rpx 20rpx;
font-size: 18rpx;
+21 -125
View File
@@ -1,13 +1,11 @@
<script setup lang="ts">
import type { PoiCategory, PoiDatasetMeta, PoiSummary } from '@/domain/poi'
import type { PlanResponse } from '@/domain/travel'
import type { PoiMapMarker } from '@/services/map'
import MapFilterHeader from '@/components/map/MapFilterHeader.vue'
import PoiSummaryCard from '@/components/map/PoiSummaryCard.vue'
import PageState from '@/components/poi/PageState.vue'
import { getPoiRepository } from '@/data/poi'
import { buildMarkerIdMap, createPoiMarkers } from '@/services/map'
import { getSessionPlanningOrigin, loadPlan } from '@/services/travel-assistant'
import { useLocationStore, useMapStore } from '@/stores'
const MAP_ID = 'guangming-cultural-map'
@@ -20,7 +18,6 @@ const mapError = ref(false)
const categories = ref<PoiCategory[]>([])
const allPoiSummaries = ref<PoiSummary[]>([])
const datasetMeta = ref<PoiDatasetMeta | null>(null)
const activePlan = shallowRef<PlanResponse | null>(null)
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
const markerIdToPoiId = shallowRef(new Map<number, string>())
const lastMarkerTapAt = ref(0)
@@ -28,8 +25,6 @@ const navigating = ref(false)
let includePointsRequestVersion = 0
let locationListenerAttached = false
let locationUpdatesStarted = false
let locationUpdatesStarting = false
let locationUpdatesRequested = false
let locationRequestVersion = 0
const activeCategoryCode = computed(() => mapStore.categoryCode)
@@ -55,84 +50,16 @@ const selectedPoi = computed(() => {
return null
return allPoiSummaries.value.find(poi => poi.id === selectedPoiId.value) ?? null
})
const markers = computed<PoiMapMarker[]>(() => {
const baseMarkers = createPoiMarkers(
filteredPois.value,
categories.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 markers = computed<PoiMapMarker[]>(() => createPoiMarkers(
filteredPois.value,
categories.value,
markerIdToPoiId.value,
selectedPoiId.value,
))
const mapPoints = computed<Array<{ latitude: number, longitude: number }>>(() => filteredPois.value.map(poi => ({
latitude: poi.latitude,
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() {
pageState.value = 'loading'
@@ -234,8 +161,6 @@ function selectCategory(categoryCode: string | null) {
return
mapStore.setCategory(categoryCode)
mapStore.hidePlannedRoute()
activePlan.value = null
if (selectedPoi.value && categoryCode && selectedPoi.value.categoryCode !== categoryCode)
mapStore.selectPoi(null)
@@ -319,8 +244,6 @@ function updateViewportWithScale(longitude: number, latitude: number, fallbackSc
function resetToAll() {
mapStore.setCategory(null)
mapStore.selectPoi(null)
mapStore.hidePlannedRoute()
activePlan.value = null
const target = datasetMeta.value?.defaultViewport
if (target)
mapStore.resetViewport(target)
@@ -356,24 +279,16 @@ function detachLocationListener() {
}
function startForegroundLocationUpdates() {
if (locationUpdatesStarted || locationUpdatesStarting || !locationUpdatesRequested)
if (locationUpdatesStarted)
return
locationUpdatesStarting = true
attachLocationListener()
uni.startLocationUpdate({
type: 'gcj02',
success: () => {
locationUpdatesStarting = false
if (!locationUpdatesRequested) {
uni.stopLocationUpdate()
detachLocationListener()
return
}
locationUpdatesStarted = true
},
fail: (error) => {
locationUpdatesStarting = false
detachLocationListener()
console.error('Failed to start foreground location updates', error)
},
@@ -381,7 +296,6 @@ function startForegroundLocationUpdates() {
}
function stopForegroundLocationUpdates() {
locationUpdatesRequested = false
locationRequestVersion += 1
detachLocationListener()
if (locationUpdatesStarted) {
@@ -410,7 +324,6 @@ function requestUserLocation() {
uni.openSetting({
success: (settings) => {
if (settings.authSetting['scope.userLocation']) {
locationUpdatesRequested = true
locationStore.startLocating()
requestCurrentLocation()
}
@@ -419,13 +332,11 @@ function requestUserLocation() {
return
}
if (locationReady.value) {
locationUpdatesRequested = true
centerOnUserLocation()
startForegroundLocationUpdates()
return
}
locationUpdatesRequested = true
locationStore.startLocating()
requestCurrentLocation()
}
@@ -484,10 +395,6 @@ function openDetail(poiId: string) {
})
}
function openCheckInRecords() {
uni.navigateTo({ url: '/pages/check-in/records' })
}
function initializeMap() {
includePointsRequestVersion += 1
mapReady.value = false
@@ -497,10 +404,7 @@ function initializeMap() {
try {
mapContext.value = uni.createMapContext(MAP_ID)
mapReady.value = true
if (activePlan.value)
includePlannedRoute()
else
includeFilteredPoints()
includeFilteredPoints()
}
catch (error) {
console.error('Failed to initialize map', error)
@@ -527,10 +431,7 @@ onReady(() => {
})
onShow(() => {
loadActivePlan()
if (activePlan.value)
includePlannedRoute()
if (locationReady.value && locationUpdatesRequested)
if (locationReady.value)
startForegroundLocationUpdates()
})
@@ -546,7 +447,6 @@ onUnload(stopForegroundLocationUpdates)
:result-count="filteredPois.length"
:coverage-label="datasetMeta?.coverageLabel ?? 'POC 示例数据'"
:loading="pageState === 'loading'"
@check-ins="openCheckInRecords"
@select="selectCategory"
/>
@@ -592,7 +492,6 @@ onUnload(stopForegroundLocationUpdates)
:latitude="viewport.latitude"
:scale="viewport.scale"
:markers="markers"
:polyline="plannedRoutePolyline"
:show-location="locationReady"
:enable-rotate="false"
:enable-overlooking="false"
@@ -612,9 +511,6 @@ onUnload(stopForegroundLocationUpdates)
>
{{ locationButtonText }}
</cover-view>
<cover-view v-if="activePlan" class="map-page__route" @tap="includePlannedRoute">
完整路线 · {{ plannedPois.length }}
</cover-view>
</map>
</view>
@@ -624,6 +520,10 @@ onUnload(stopForegroundLocationUpdates)
@close="mapStore.selectPoi(null)"
@detail="openDetail"
/>
<view class="map-page__disclaimer">
{{ datasetMeta?.disclaimer }}
</view>
</template>
</view>
</template>
@@ -728,17 +628,13 @@ style:
background: #167a5b;
}
.map-page__route {
position: absolute;
bottom: 28rpx;
left: 24rpx;
padding: 14rpx 22rpx;
font-size: 24rpx;
font-weight: 700;
line-height: 36rpx;
color: #fff;
background: #167a5b;
border-radius: 28rpx;
box-shadow: 0 8rpx 24rpx rgb(24 32 29 / 16%);
.map-page__disclaimer {
flex: none;
padding: 8rpx 24rpx calc(8rpx + env(safe-area-inset-bottom));
font-size: 20rpx;
line-height: 30rpx;
color: #8a9690;
text-align: center;
background: #f5f7f6;
}
</style>
+14 -86
View File
@@ -2,9 +2,7 @@
import type { PoiSummary } from '@/domain/poi'
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
import { getPoiRepository } from '@/data/poi'
import { TravelValidationError } from '@/domain/travel'
import { isLocationSnapshotFresh } from '@/services/location'
import { createPlan, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
import { useLocationStore } from '@/stores'
interface PlannerPreset {
@@ -19,7 +17,6 @@ const MAX_DURATION_MINUTES = 10 * 60
const DURATION_STEP_MINUTES = 30
const MIN_CUSTOM_POIS = 2
const MAX_CUSTOM_POIS = 8
const LOCATION_MAX_AGE_MS = 5 * 60 * 1000
const themeOptions: Theme[] = ['亲子', '情侣', '朋友', '银发', '研学']
const interestOptions: Interest[] = ['自然风光', '文化场馆', '生态科普', '美食', '摄影']
const paceOptions: Array<{ label: string, description: string, value: Pace }> = [
@@ -56,21 +53,10 @@ const planningMode = ref<PlanningRequest['mode']>('quick')
const durationMinutes = ref(4 * 60)
const selectedPoiIds = ref<string[]>([])
const customStartsFromCurrentLocation = ref(false)
const locationFreshnessCheckedAt = ref(Date.now())
const submitting = ref(false)
let locationFreshnessTimer: ReturnType<typeof setTimeout> | null = null
let pageVisible = false
const locationStore = useLocationStore()
const locationAvailable = computed(() => {
const snapshot = locationStore.snapshot
return locationStore.status === 'ready'
&& Boolean(snapshot)
&& isLocationSnapshotFresh(
snapshot?.capturedAt ?? '',
locationFreshnessCheckedAt.value,
LOCATION_MAX_AGE_MS,
)
})
const remoteEnabled = computed(() => isRemoteTravelAssistantEnabled())
const locationAvailable = computed(() => locationStore.status === 'ready' && Boolean(locationStore.snapshot))
const poiSummaries = shallowRef<PoiSummary[]>(getPoiRepository().getPoiSummaries())
const poiGroups = computed(() => {
const groups = new Map<string, PoiSummary[]>()
@@ -88,28 +74,23 @@ const durationText = computed(() => {
})
const usesCurrentLocation = computed(() => locationAvailable.value
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
const planningEngineLabel = '本地 POC 规划'
const planningEngineLabel = computed(() => remoteEnabled.value
? '在线智能服务'
: '本地智能规划')
const locationActionText = computed(() => {
if (locationStore.status === 'locating')
return '定位中…'
if (locationStore.status === 'denied')
return '开启位置权限'
return locationStore.snapshot ? '刷新当前位置' : '获取当前位置'
return locationAvailable.value ? '刷新当前位置' : '获取当前位置'
})
const locationDescription = computed(() => {
if (locationStore.status === 'locating')
return '正在获取位置,请稍候。'
if (locationStore.status === 'denied') {
return planningMode.value === 'quick'
? '位置权限未开启;请前往设置授权,或改用自选点位规划。'
: '位置权限未开启;自选点位规划仍可继续使用。'
}
if (planningMode.value === 'quick') {
return locationAvailable.value
? '将从当前位置出发并优先安排较近点位;可随时刷新。'
: locationStore.snapshot
? '上次定位已超过 5 分钟,请刷新后再开始快速规划。'
: '快速规划需要先主动获取当前位置。'
: '尚未获取位置,提交后会按光明区全域生成路线。'
}
if (!locationAvailable.value)
return '可选获取位置,用于优化自选点位的起点和顺序。'
@@ -223,32 +204,6 @@ function isPermissionDenied(error: unknown): boolean {
return /auth deny|auth denied|authorize:fail|permission/i.test(message)
}
function clearLocationFreshnessTimer() {
if (locationFreshnessTimer === null)
return
clearTimeout(locationFreshnessTimer)
locationFreshnessTimer = null
}
function refreshLocationFreshness() {
clearLocationFreshnessTimer()
const checkedAt = Date.now()
locationFreshnessCheckedAt.value = checkedAt
const snapshot = locationStore.snapshot
if (locationStore.status !== 'ready' || !snapshot)
return
if (!pageVisible)
return
const capturedAt = Date.parse(snapshot.capturedAt)
if (!Number.isFinite(capturedAt) || capturedAt > checkedAt)
return
const expiresInMs = capturedAt + LOCATION_MAX_AGE_MS - checkedAt
if (expiresInMs < 0)
return
locationFreshnessTimer = setTimeout(refreshLocationFreshness, expiresInMs + 1)
}
function requestCurrentLocation() {
if (locationStore.status === 'locating')
return
@@ -266,7 +221,6 @@ function requestCurrentLocation() {
function locateFromUserAction() {
locationStore.startLocating()
refreshLocationFreshness()
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
@@ -282,7 +236,6 @@ function locateFromUserAction() {
uni.showToast({ title: '定位结果无效', icon: 'none' })
return
}
refreshLocationFreshness()
if (planningMode.value === 'custom')
customStartsFromCurrentLocation.value = true
uni.showToast({ title: '已更新当前位置', icon: 'success' })
@@ -319,7 +272,6 @@ function showDurationFitNotice(status: 'overflow' | 'underfilled'): Promise<void
}
async function submit() {
refreshLocationFreshness()
if (!form.themes.length && !form.interests.length) {
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
return
@@ -333,16 +285,6 @@ async function submit() {
uni.showToast({ title: `请选择 ${MIN_CUSTOM_POIS}-${MAX_CUSTOM_POIS} 个点位`, icon: 'none' })
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
try {
@@ -376,13 +318,9 @@ async function submit() {
uni.navigateTo({ url: '/pages/itinerary/index' })
}
catch (error) {
const quickRouteUnreachable = error instanceof TravelValidationError
&& error.code === 'quick_route_unreachable'
uni.showModal({
title: quickRouteUnreachable ? '当前路线无法完成' : '暂时无法生成路线',
content: quickRouteUnreachable
? '当前起点、交通方式与所选时长无法容纳首个点位。请靠近光明区、延长游玩时长,或改用自选点位规划。'
: error instanceof Error ? error.message : '请检查选择后重试',
title: '暂时无法生成路线',
content: error instanceof Error ? error.message : '请检查选择后重试',
showCancel: false,
})
}
@@ -392,8 +330,6 @@ async function submit() {
}
onShow(() => {
pageVisible = true
refreshLocationFreshness()
const storedRequest = loadPlanningRequest()
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
if (storedPreferences)
@@ -409,14 +345,6 @@ onShow(() => {
}
applyPreset()
})
function deactivatePlannerPage() {
pageVisible = false
clearLocationFreshnessTimer()
}
onHide(deactivatePlannerPage)
onUnload(deactivatePlannerPage)
</script>
<template>
@@ -466,7 +394,9 @@ onUnload(deactivatePlannerPage)
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
</button>
</view>
<text class="planning-mode-tip">本期由本机完成选点顺序交通估算与时长核算</text>
<text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip">
在线 AI 将在你选择的点位内建议顺序本机负责定位交通与时长核算
</text>
</view>
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
@@ -694,9 +624,7 @@ onUnload(deactivatePlannerPage)
</button>
</view>
<text class="planner-footer">
匿名使用 · 本期不调用 AI · 路线与偏好仅保存在本机
</text>
<text class="planner-footer">匿名使用 · 偏好和路线仅保存在本机</text>
</view>
</template>
-108
View File
@@ -5,12 +5,9 @@ import PoiHeroGallery from '@/components/poi/PoiHeroGallery.vue'
import PoiTagList from '@/components/poi/PoiTagList.vue'
import { getPoiRepository } from '@/data/poi'
import { formatRecommendationLabel } from '@/domain/poi'
import { getCheckInProfile } from '@/services/check-in'
const state = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
const poi = ref<PoiResolved | null>(null)
const hasCheckedIn = ref(false)
const checkInStorageError = ref(false)
function loadPoi(poiId: string) {
state.value = 'loading'
@@ -29,15 +26,6 @@ function loadPoi(poiId: string) {
}
poi.value = result
try {
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === result.id)
checkInStorageError.value = false
}
catch (error) {
console.error('Failed to load POI check-in state', error)
hasCheckedIn.value = false
checkInStorageError.value = true
}
state.value = 'ready'
}
catch (error) {
@@ -46,16 +34,6 @@ 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() {
uni.navigateBack({
fail: () => {
@@ -87,20 +65,6 @@ onLoad((query) => {
state.value = 'invalid'
}
})
onShow(() => {
if (!poi.value)
return
try {
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === poi.value?.id)
checkInStorageError.value = false
}
catch (error) {
console.error('Failed to refresh POI check-in state', error)
hasCheckedIn.value = false
checkInStorageError.value = true
}
})
</script>
<template>
@@ -136,15 +100,6 @@ onShow(() => {
<PoiTagList class="poi-detail-page__tags" :tags="poi.tags" />
</view>
<view class="check-in-entry" :class="{ 'check-in-entry--done': hasCheckedIn }">
<view class="check-in-entry__mark">{{ hasCheckedIn ? '✓' : '◎' }}</view>
<view class="check-in-entry__copy">
<text class="check-in-entry__title">{{ checkInStorageError ? '打卡数据需要处理' : hasCheckedIn ? '已留下光明足迹' : '到点打卡' }}</text>
<text>{{ checkInStorageError ? '本机打卡数据异常,请先处理' : hasCheckedIn ? '该点位已在本机完成打卡' : '到达点位附近后,使用本次真实定位打卡' }}</text>
</view>
<button @click="checkInStorageError ? openCheckInRecords() : openCheckIn()">{{ checkInStorageError ? '去处理' : hasCheckedIn ? '查看' : '去打卡' }}</button>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-row__label">开放时间</text>
@@ -242,7 +197,6 @@ style:
}
.info-card,
.check-in-entry,
.description-section,
.poc-notice {
padding: 28rpx;
@@ -252,68 +206,6 @@ style:
border-radius: 20rpx;
}
.check-in-entry {
display: flex;
gap: 18rpx;
align-items: center;
padding: 24rpx;
}
.check-in-entry--done {
background: #e8f4ee;
border-color: #cfe4d9;
}
.check-in-entry__mark {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 62rpx;
height: 62rpx;
font-size: 28rpx;
font-weight: 800;
color: #fff;
background: #167a5b;
border-radius: 50%;
}
.check-in-entry__copy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
font-size: 21rpx;
line-height: 32rpx;
color: #5e6b65;
}
.check-in-entry__title {
margin-bottom: 3rpx;
font-size: 27rpx;
font-weight: 750;
color: #18201d;
}
.check-in-entry button {
flex: none;
width: auto;
height: 60rpx;
padding: 0 22rpx;
margin: 0;
font-size: 23rpx;
font-weight: 700;
line-height: 60rpx;
color: #fff;
background: #167a5b;
border: 0;
border-radius: 30rpx;
}
.check-in-entry button::after {
border: 0;
}
.info-row {
display: flex;
gap: 24rpx;
-8
View File
@@ -1,8 +0,0 @@
export { checkInAtPoi, getCheckInProfile } from './service'
export {
CHECK_IN_STORAGE_KEY,
CheckInStorageError,
clearCheckInProfile,
loadCheckInProfile,
saveCheckInProfile,
} from './storage'
-27
View File
@@ -1,27 +0,0 @@
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()
}
-149
View File
@@ -1,149 +0,0 @@
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}`)
}
}
-91
View File
@@ -1,91 +0,0 @@
import type { CheckInLocationSnapshot } from '@/domain/check-in'
interface CurrentLocationOptions {
highAccuracyExpireTime?: number
timeoutMs?: number
}
export function isLocationSnapshotFresh(
capturedAt: string,
checkedAt = Date.now(),
maxAgeMs = 5 * 60 * 1000,
): boolean {
const capturedTimestamp = Date.parse(capturedAt)
if (!Number.isFinite(capturedTimestamp)
|| !Number.isFinite(checkedAt)
|| !Number.isFinite(maxAgeMs)
|| maxAgeMs < 0) {
return false
}
const ageMs = checkedAt - capturedTimestamp
return ageMs >= 0 && ageMs <= maxAgeMs
}
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)
})
}
-1
View File
@@ -1 +0,0 @@
export * from './current-location'
+2 -1
View File
@@ -1,4 +1,5 @@
export { adjustPlan, createPlan, getSessionPlanningOrigin } from './service'
export { isRemoteTravelAssistantEnabled } from './remote'
export { adjustPlan, createPlan } from './service'
export {
loadPlan,
loadPlanningRequest,
+1 -7
View File
@@ -198,13 +198,7 @@ export async function createRemoteRecommendations(
method: 'POST',
data: {
mode: planningRequest.mode,
preferences: {
destination: planningRequest.preferences.destination,
themes: [...planningRequest.preferences.themes],
pace: planningRequest.preferences.pace,
interests: [...planningRequest.preferences.interests],
transport: planningRequest.preferences.transport,
},
preferences: planningRequest.preferences,
durationMinutes: planningRequest.durationMinutes,
selectedPoiIds: [...planningRequest.selectedPoiIds],
candidates,
+25 -10
View File
@@ -4,6 +4,7 @@ import {
clonePlanResponse,
cloneTravelPreferences,
createLocalPlan,
createRecommendedPlan,
normalizePlanningRequest,
normalizeTravelPreferences,
type PlanningOrigin,
@@ -12,6 +13,7 @@ import {
type TravelPreferences,
TravelValidationError,
} from '@/domain/travel'
import { createRemoteRecommendations, isRemoteTravelAssistantEnabled } from './remote'
import { loadPlan, loadPlanningRequest, loadPreferences, savePlanningRequest, savePreferences } from './storage'
interface LocalSession {
@@ -98,6 +100,28 @@ export async function createPlan(
selectedPoiIds: [],
...(normalizePlanningOrigin(planningOrigin) ? { origin: normalizePlanningOrigin(planningOrigin)! } : {}),
}, 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)
rememberLocalSession(plan, request)
savePlanningRequest(request)
@@ -128,19 +152,10 @@ export async function adjustPlan(conversationId: string, message: string): Promi
)
if (session.plan.source !== 'local_poc') {
adjusted.plan.assistantMessage = '已在本机按你的补充要求重新核算路线;如需 AI 重新选点,请返回规划页重新生成。'
adjusted.plan.source = 'local_poc'
adjusted.plan.source = session.plan.source
}
rememberLocalSession(adjusted.plan, adjusted.request)
savePlanningRequest(adjusted.request)
savePreferences(adjusted.preferences)
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
}
+1 -2
View File
@@ -78,8 +78,7 @@ export function loadPlan(): PlanResponse | null {
if (!envelope || envelope.datasetVersion !== repository.getDatasetMeta().datasetVersion)
return null
try {
const plan = normalizePlanResponse(envelope.payload, repository)
return plan.source === 'local_poc' ? plan : null
return normalizePlanResponse(envelope.payload, repository)
}
catch {
return null
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

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