Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21d19b91f1 |
@@ -1,8 +1 @@
|
|||||||
# 在开发时进行加载
|
# 在开发时进行加载
|
||||||
|
|
||||||
# Deployed travel recommender (FastAPI, real GLM) on the gml box, exposed via
|
|
||||||
# Tailscale Funnel. The /travel/ prefix is stripped by Tailscale before reaching
|
|
||||||
# the app, so the client calls .../travel/api/v1/plans. Set to empty (or stop the
|
|
||||||
# service) to use the deterministic local planner; on any remote error the client
|
|
||||||
# silently falls back to local so the demo never breaks.
|
|
||||||
VITE_TRAVEL_ASSISTANT_API_BASE_URL=https://desktop-gml.tailc2083d.ts.net/travel
|
|
||||||
|
|||||||
@@ -43,7 +43,3 @@ server/**/*.pyc
|
|||||||
# cache
|
# cache
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.stylelintcache
|
.stylelintcache
|
||||||
|
|
||||||
# POI 照片原图投放目录:只作为 scripts/process-poi-photos.mjs 的输入,
|
|
||||||
# 产出的两层图片(分包大图 + 主包缩略图)才提交仓库。
|
|
||||||
src/photo-intake/
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# 光明文旅地图 POC
|
# 光明文旅地图 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 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`。
|
生产验收时可直接导入 `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
|
```text
|
||||||
src/
|
src/
|
||||||
├── pages/map/index.vue # 小程序首页:全域地图
|
├── pages/map/index.vue # 小程序首页:全域地图
|
||||||
├── pages/assistant/index.vue # 文旅助手:偏好快捷入口
|
├── pages/assistant/index.vue # AI 文旅助手:偏好快捷入口
|
||||||
├── pages/planner/index.vue # 行程偏好表单
|
├── pages/planner/index.vue # 行程偏好表单
|
||||||
├── pages/itinerary/index.vue # 推荐路线与地图展示
|
├── pages/itinerary/index.vue # 推荐路线与地图展示
|
||||||
├── pages/poi/detail.vue # POI 详情
|
├── pages/poi/detail.vue # POI 详情
|
||||||
├── pages/check-in/ # 真实定位打卡、积分、徽章与足迹
|
|
||||||
├── components/map/ # 地图筛选和摘要组件
|
├── components/map/ # 地图筛选和摘要组件
|
||||||
├── components/poi/ # POI 图片、标签、状态和图集组件
|
├── components/poi/ # POI 图片、标签、状态和图集组件
|
||||||
├── components/travel/ # 行程点位展示组件
|
├── components/travel/ # 行程点位展示组件
|
||||||
@@ -59,21 +60,11 @@ src/
|
|||||||
└── static/ # 随包静态资源
|
└── 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 均不得进入客户端环境变量或构建产物。
|
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。
|
||||||
|
|
||||||
实时位置仅由用户主动触发,并只在地图页处于前台时持续更新。“当前位置快速规划”要求使用 5 分钟内的位置快照;用户不授权时仍可使用不依赖定位的自选点位规划。每次打卡会重新请求一次 GCJ-02 位置,不复用地图或规划历史位置;只有在 200 米范围内且定位精度满足要求时才记录成功。精确坐标只用于本次内存计算,不写入匿名偏好、路线或打卡缓存,也不会发送给远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口、完善《用户隐私保护指引》,并现场校验 POC 点位坐标和打卡半径。
|
|
||||||
|
|
||||||
## 打卡能力说明
|
|
||||||
|
|
||||||
`files/打卡` 仅作为功能参考。本项目复用了“积分摘要、打卡状态、徽章墙和本机记录”的交互概念,没有迁移其杭州模拟点位、CommonJS 页面代码、全量清缓存逻辑或橙黄色视觉。实际实现使用现有光明区已发布 POI,并统一采用项目的绿色文旅视觉体系。
|
|
||||||
|
|
||||||
打卡为匿名本机 POC:`src/data/check-in/tasks.ts` 会为当前 POI 仓库中的全部已发布点位生成打卡任务;点位下线后会自动停止新打卡,重新发布后仍保留本机历史记录以防重复领奖。所有点位的游客入口坐标与安全到达范围仍需 iOS 现场复核后才能用于正式验收。每个开放点位首次成功打卡增加 10 积分,累计完成 1、2、3 个开放点位时依次解锁徽章。积分、徽章与足迹使用独立版本化存储键;v1 旧格式首次升级时只迁移当前开放点位记录,进入 v2 后则保留合法历史足迹。清除操作只删除打卡数据,不影响路线和偏好。打卡记录不保存经纬度、定位精度或轨迹。微信客户端无法可靠识别系统级缓存位置或虚拟定位,因此本期只承诺每次点击重新调用定位接口并校验微信返回的精度,不提供虚拟定位测试入口或生产级反作弊承诺。
|
|
||||||
|
|
||||||
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。仓库中的 `src/manifest.json` 以开发模式快照为准,`【dev】` / `【prod】` 项目名前缀由构建模式自动生成;提交时不应单独保留由最后一次构建造成的前缀漂移。
|
|
||||||
|
|
||||||
## POC 数据声明
|
## POC 数据声明
|
||||||
|
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/* prettier-ignore */
|
|
||||||
// @ts-nocheck
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
// Generated by unplugin-auto-import
|
|
||||||
// biome-ignore lint: disable
|
|
||||||
export {}
|
|
||||||
declare global {
|
|
||||||
const DEFAULT_GUANGMING_VIEWPORT: typeof import('./src/stores/modules/map')['DEFAULT_GUANGMING_VIEWPORT']
|
|
||||||
const EffectScope: typeof import('vue')['EffectScope']
|
|
||||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
|
||||||
const computed: typeof import('vue')['computed']
|
|
||||||
const createApp: typeof import('vue')['createApp']
|
|
||||||
const createPinia: typeof import('pinia')['createPinia']
|
|
||||||
const customRef: typeof import('vue')['customRef']
|
|
||||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
|
||||||
const defineComponent: typeof import('vue')['defineComponent']
|
|
||||||
const defineStore: typeof import('pinia')['defineStore']
|
|
||||||
const effectScope: typeof import('vue')['effectScope']
|
|
||||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
|
||||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
|
||||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
|
||||||
const h: typeof import('vue')['h']
|
|
||||||
const inject: typeof import('vue')['inject']
|
|
||||||
const isProxy: typeof import('vue')['isProxy']
|
|
||||||
const isReactive: typeof import('vue')['isReactive']
|
|
||||||
const isReadonly: typeof import('vue')['isReadonly']
|
|
||||||
const isRef: typeof import('vue')['isRef']
|
|
||||||
const mapActions: typeof import('pinia')['mapActions']
|
|
||||||
const mapGetters: typeof import('pinia')['mapGetters']
|
|
||||||
const mapState: typeof import('pinia')['mapState']
|
|
||||||
const mapStores: typeof import('pinia')['mapStores']
|
|
||||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
|
||||||
const markRaw: typeof import('vue')['markRaw']
|
|
||||||
const nextTick: typeof import('vue')['nextTick']
|
|
||||||
const onActivated: typeof import('vue')['onActivated']
|
|
||||||
const onAddToFavorites: typeof import('@dcloudio/uni-app')['onAddToFavorites']
|
|
||||||
const onBackPress: typeof import('@dcloudio/uni-app')['onBackPress']
|
|
||||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
|
||||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
|
||||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
|
||||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
|
||||||
const onError: typeof import('@dcloudio/uni-app')['onError']
|
|
||||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
|
||||||
const onHide: typeof import('@dcloudio/uni-app')['onHide']
|
|
||||||
const onLaunch: typeof import('@dcloudio/uni-app')['onLaunch']
|
|
||||||
const onLoad: typeof import('@dcloudio/uni-app')['onLoad']
|
|
||||||
const onMounted: typeof import('vue')['onMounted']
|
|
||||||
const onNavigationBarButtonTap: typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']
|
|
||||||
const onNavigationBarSearchInputChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']
|
|
||||||
const onNavigationBarSearchInputClicked: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']
|
|
||||||
const onNavigationBarSearchInputConfirmed: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']
|
|
||||||
const onNavigationBarSearchInputFocusChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']
|
|
||||||
const onPageNotFound: typeof import('@dcloudio/uni-app')['onPageNotFound']
|
|
||||||
const onPageScroll: typeof import('@dcloudio/uni-app')['onPageScroll']
|
|
||||||
const onPullDownRefresh: typeof import('@dcloudio/uni-app')['onPullDownRefresh']
|
|
||||||
const onReachBottom: typeof import('@dcloudio/uni-app')['onReachBottom']
|
|
||||||
const onReady: typeof import('@dcloudio/uni-app')['onReady']
|
|
||||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
|
||||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
|
||||||
const onResize: typeof import('@dcloudio/uni-app')['onResize']
|
|
||||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
|
||||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
|
||||||
const onShareAppMessage: typeof import('@dcloudio/uni-app')['onShareAppMessage']
|
|
||||||
const onShareTimeline: typeof import('@dcloudio/uni-app')['onShareTimeline']
|
|
||||||
const onShow: typeof import('@dcloudio/uni-app')['onShow']
|
|
||||||
const onTabItemTap: typeof import('@dcloudio/uni-app')['onTabItemTap']
|
|
||||||
const onThemeChange: typeof import('@dcloudio/uni-app')['onThemeChange']
|
|
||||||
const onUnhandledRejection: typeof import('@dcloudio/uni-app')['onUnhandledRejection']
|
|
||||||
const onUnload: typeof import('@dcloudio/uni-app')['onUnload']
|
|
||||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
|
||||||
const onUpdated: typeof import('vue')['onUpdated']
|
|
||||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
|
||||||
const pinia: typeof import('./src/stores/index')['pinia']
|
|
||||||
const provide: typeof import('vue')['provide']
|
|
||||||
const reactive: typeof import('vue')['reactive']
|
|
||||||
const readonly: typeof import('vue')['readonly']
|
|
||||||
const ref: typeof import('vue')['ref']
|
|
||||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
|
||||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
|
||||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
|
||||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
|
||||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
|
||||||
const shallowRef: typeof import('vue')['shallowRef']
|
|
||||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
|
||||||
const toRaw: typeof import('vue')['toRaw']
|
|
||||||
const toRef: typeof import('vue')['toRef']
|
|
||||||
const toRefs: typeof import('vue')['toRefs']
|
|
||||||
const toValue: typeof import('vue')['toValue']
|
|
||||||
const triggerRef: typeof import('vue')['triggerRef']
|
|
||||||
const unref: typeof import('vue')['unref']
|
|
||||||
const useAttrs: typeof import('vue')['useAttrs']
|
|
||||||
const useCountStore: typeof import('./src/stores/modules/count')['useCountStore']
|
|
||||||
const useCssModule: typeof import('vue')['useCssModule']
|
|
||||||
const useCssVars: typeof import('vue')['useCssVars']
|
|
||||||
const useId: typeof import('vue')['useId']
|
|
||||||
const useLocationStore: typeof import('./src/stores/modules/location')['useLocationStore']
|
|
||||||
const useMapStore: typeof import('./src/stores/modules/map')['useMapStore']
|
|
||||||
const useModel: typeof import('vue')['useModel']
|
|
||||||
const useNavigation: typeof import('./src/composables/useNavigation')['useNavigation']
|
|
||||||
const useSlots: typeof import('vue')['useSlots']
|
|
||||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
|
||||||
const watch: typeof import('vue')['watch']
|
|
||||||
const watchEffect: typeof import('vue')['watchEffect']
|
|
||||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
|
||||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
|
||||||
}
|
|
||||||
// for type re-export
|
|
||||||
declare global {
|
|
||||||
// @ts-ignore
|
|
||||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
|
||||||
import('vue')
|
|
||||||
}
|
|
||||||
|
|
||||||
// for vue template auto import
|
|
||||||
import { UnwrapRef } from 'vue'
|
|
||||||
declare module 'vue' {
|
|
||||||
interface GlobalComponents {}
|
|
||||||
interface ComponentCustomProperties {
|
|
||||||
readonly DEFAULT_GUANGMING_VIEWPORT: UnwrapRef<typeof import('./src/stores/modules/map')['DEFAULT_GUANGMING_VIEWPORT']>
|
|
||||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
|
||||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
|
||||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
|
||||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
|
||||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
|
||||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
|
||||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
|
||||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
|
||||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
|
||||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
|
||||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
|
||||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
|
||||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
|
||||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
|
||||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
|
||||||
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
|
||||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
|
||||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
|
||||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
|
||||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
|
||||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
|
||||||
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
|
|
||||||
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
|
|
||||||
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
|
|
||||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
|
||||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
|
||||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
|
||||||
readonly onAddToFavorites: UnwrapRef<typeof import('@dcloudio/uni-app')['onAddToFavorites']>
|
|
||||||
readonly onBackPress: UnwrapRef<typeof import('@dcloudio/uni-app')['onBackPress']>
|
|
||||||
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
|
||||||
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
|
||||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
|
||||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
|
||||||
readonly onError: UnwrapRef<typeof import('@dcloudio/uni-app')['onError']>
|
|
||||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
|
||||||
readonly onHide: UnwrapRef<typeof import('@dcloudio/uni-app')['onHide']>
|
|
||||||
readonly onLaunch: UnwrapRef<typeof import('@dcloudio/uni-app')['onLaunch']>
|
|
||||||
readonly onLoad: UnwrapRef<typeof import('@dcloudio/uni-app')['onLoad']>
|
|
||||||
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
|
||||||
readonly onNavigationBarButtonTap: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']>
|
|
||||||
readonly onNavigationBarSearchInputChanged: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']>
|
|
||||||
readonly onNavigationBarSearchInputClicked: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']>
|
|
||||||
readonly onNavigationBarSearchInputConfirmed: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']>
|
|
||||||
readonly onNavigationBarSearchInputFocusChanged: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']>
|
|
||||||
readonly onPageNotFound: UnwrapRef<typeof import('@dcloudio/uni-app')['onPageNotFound']>
|
|
||||||
readonly onPageScroll: UnwrapRef<typeof import('@dcloudio/uni-app')['onPageScroll']>
|
|
||||||
readonly onPullDownRefresh: UnwrapRef<typeof import('@dcloudio/uni-app')['onPullDownRefresh']>
|
|
||||||
readonly onReachBottom: UnwrapRef<typeof import('@dcloudio/uni-app')['onReachBottom']>
|
|
||||||
readonly onReady: UnwrapRef<typeof import('@dcloudio/uni-app')['onReady']>
|
|
||||||
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
|
||||||
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
|
||||||
readonly onResize: UnwrapRef<typeof import('@dcloudio/uni-app')['onResize']>
|
|
||||||
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
|
||||||
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
|
||||||
readonly onShareAppMessage: UnwrapRef<typeof import('@dcloudio/uni-app')['onShareAppMessage']>
|
|
||||||
readonly onShareTimeline: UnwrapRef<typeof import('@dcloudio/uni-app')['onShareTimeline']>
|
|
||||||
readonly onShow: UnwrapRef<typeof import('@dcloudio/uni-app')['onShow']>
|
|
||||||
readonly onTabItemTap: UnwrapRef<typeof import('@dcloudio/uni-app')['onTabItemTap']>
|
|
||||||
readonly onThemeChange: UnwrapRef<typeof import('@dcloudio/uni-app')['onThemeChange']>
|
|
||||||
readonly onUnhandledRejection: UnwrapRef<typeof import('@dcloudio/uni-app')['onUnhandledRejection']>
|
|
||||||
readonly onUnload: UnwrapRef<typeof import('@dcloudio/uni-app')['onUnload']>
|
|
||||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
|
||||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
|
||||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
|
||||||
readonly pinia: UnwrapRef<typeof import('./src/stores/index')['pinia']>
|
|
||||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
|
||||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
|
||||||
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
|
||||||
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
|
||||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
|
||||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
|
||||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
|
||||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
|
||||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
|
||||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
|
||||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
|
||||||
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
|
||||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
|
||||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
|
||||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
|
||||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
|
||||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
|
||||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
|
||||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
|
||||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
|
||||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
|
||||||
readonly useLocationStore: UnwrapRef<typeof import('./src/stores/modules/location')['useLocationStore']>
|
|
||||||
readonly useMapStore: UnwrapRef<typeof import('./src/stores/modules/map')['useMapStore']>
|
|
||||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
|
||||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
|
||||||
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
|
||||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
|
||||||
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
|
||||||
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
|
||||||
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
## 1. 当前交付边界
|
## 1. 当前交付边界
|
||||||
|
|
||||||
- 本项目是光明区文旅全域地图、POI 数字化展示、本地行程规划与真实定位打卡 POC;AI 模型接入留待后续版本。
|
- 本项目是光明区文旅全域地图、POI 数字化展示、AI 文旅助手与行程规划 POC。
|
||||||
- 目标端为微信小程序,当前只要求在一台实际 iOS 设备验证。
|
- 目标端为微信小程序,当前只要求在一台实际 iOS 设备验证。
|
||||||
- 用户主动授权后,地图可显示前台实时位置;当前位置快速规划要求使用 5 分钟内的位置快照优化首站和游览顺序。
|
- 用户主动授权后,地图可显示前台实时位置;行程会用本次位置快照优化首站和游览顺序。
|
||||||
- 行程由随包 POI 和确定性规则生成,用于验证推荐链路,不提供道路级实时路况、路径导航或到达承诺。
|
- 行程由随包 POI 和确定性规则生成,用于验证推荐链路,不提供道路级实时路况、路径导航或到达承诺。
|
||||||
- 不包含登录或云端个人中心;匿名偏好、最近行程、打卡积分、徽章与足迹保存在本机,精确位置只用于内存中的本次判定。
|
- 不包含打卡、登录或个人中心;匿名偏好和最近行程保存在本机,精确位置只保留在内存会话中。
|
||||||
- 当前随包数据明确标注为 `POC 审核样本`,不能宣称已覆盖光明区全部文旅资源。
|
- 当前随包数据明确标注为 `POC 审核样本`,不能宣称已覆盖光明区全部文旅资源。
|
||||||
|
|
||||||
## 2. 地图和高德能力说明
|
## 2. 地图和高德能力说明
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
|
|
||||||
1. 复制 `.env.example` 为 `.env.local`。
|
1. 复制 `.env.example` 为 `.env.local`。
|
||||||
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。
|
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。
|
||||||
3. 真机预览或上传前,确认根目录 `project.config.json` 的 `appid` 与相同的真实 AppID 一致。
|
3. 真机预览或上传前,将根目录 `project.config.json` 的 `appid` 从 `touristappid` 同步为相同的真实 AppID。
|
||||||
4. 本期 `createPlan` 已固定使用本地 POC 规划器,`server/` 及远程适配层仅作为后续 AI 接入预留,不纳入本期验收。
|
4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。服务端必须设置 `LLM_MODE=real` 才会作为真实 AI 展示;mock 仅用于联调演示。
|
||||||
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
|
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
|
||||||
|
|
||||||
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
|
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
|
||||||
@@ -48,9 +48,7 @@ corepack pnpm build:mp-weixin
|
|||||||
|
|
||||||
项目的微信开发与生产脚本默认对文件监听使用轮询,用于规避部分 macOS 会话中 `EMFILE: too many open files, watch` 问题。这不改变构建产物,但开发模式的本机资源占用会略有增加。
|
项目的微信开发与生产脚本默认对文件监听使用轮询,用于规避部分 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` 指向该目录。先运行开发编译,再用微信开发者工具直接导入项目根目录。
|
开发模式产物位于 `dist/dev/mp-weixin`;根目录 `project.config.json` 已将微信开发者工具的 `miniprogramRoot` 和 `srcMiniprogramRoot` 指向该目录。先运行开发编译,再用微信开发者工具直接导入项目根目录。
|
||||||
|
|
||||||
@@ -80,12 +78,9 @@ macOS 可在另一个终端执行 `corepack pnpm ide:mp-weixin` 调起已安装
|
|||||||
5. 返回地图后恢复分类、中心、缩放和选中点位。
|
5. 返回地图后恢复分类、中心、缩放和选中点位。
|
||||||
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
|
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
|
||||||
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
|
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
|
||||||
8. 从“文旅助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。
|
8. 从“AI 助手”和“路线规划”进入偏好表单,分别验证当前位置快速规划和自选 2–8 个点位规划;拖动 2–10 小时时长,核对完整点位、顺序、时间、转场提示和来源说明。
|
||||||
9. 关闭网络后验证本地规划仍可生成;确认当前业务入口不调用远程 AI,客户端产物不含模型凭证。
|
9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。
|
||||||
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
|
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
|
||||||
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储不包含精确坐标,且调试面板没有远程规划请求。
|
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。
|
||||||
12. 确认全部已发布 POI 的详情页均展示打卡入口,未发布或已下线 POI 不开放新打卡;从详情进入打卡页后,仅在点击按钮时重新请求一次位置,分别验证隐私授权、200 米范围内成功、范围外、精度不足、边界不确定和重复打卡。
|
|
||||||
13. 检查首次打卡增加 10 积分并解锁首枚徽章,重启后记录仍存在;“清除本机打卡数据”不得删除路线或偏好。
|
|
||||||
14. 扫描本机打卡存储,确认不包含经纬度、定位精度或轨迹;开发者工具模拟位置仅用于流程测试,正式成功验收必须使用 iOS 真机现场定位。
|
|
||||||
|
|
||||||
在项目方尚未提供正式 AppID、真实清权图片和冻结基准清单时,只能判定“样本链路通过”,不能判定正式内容或全域覆盖通过。
|
在项目方尚未提供正式 AppID、真实清权图片和冻结基准清单时,只能判定“样本链路通过”,不能判定正式内容或全域覆盖通过。
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"miniprogramRoot": "dist/dev/mp-weixin/",
|
|
||||||
"projectname": "光明文旅地图",
|
|
||||||
"description": "深圳市光明区全域文旅地图、POI 数字化展示与本地行程规划 POC",
|
|
||||||
"setting": {
|
"setting": {
|
||||||
"es6": true,
|
"es6": true,
|
||||||
"postcss": true,
|
"postcss": true,
|
||||||
@@ -18,13 +15,11 @@
|
|||||||
"minifyWXML": true
|
"minifyWXML": true
|
||||||
},
|
},
|
||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"srcMiniprogramRoot": "dist/dev/mp-weixin/",
|
|
||||||
"simulatorPluginLibVersion": {},
|
"simulatorPluginLibVersion": {},
|
||||||
"packOptions": {
|
"packOptions": {
|
||||||
"ignore": [],
|
"ignore": [],
|
||||||
"include": []
|
"include": []
|
||||||
},
|
},
|
||||||
"appid": "wxda4fb5048b5dc9a1",
|
"appid": "wx2a49d023ec320d0b",
|
||||||
"editorSetting": {},
|
"editorSetting": {}
|
||||||
"condition": {}
|
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createSSRApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App);
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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">0–17 岁</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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
declare module "vue" {
|
||||||
|
type Hooks = App.AppInstance & Page.PageInstance;
|
||||||
|
interface ComponentCustomOptions extends Hooks {}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Guangming travel assistant POC."""
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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 "作为光明区同路线备选,便于控制整体节奏"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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": ["动态信息需出行前确认"]
|
||||||
|
}"""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
app
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// app.js
|
||||||
|
App({
|
||||||
|
onLaunch() {
|
||||||
|
// 可以在此做全局初始化,如上报启动日志等
|
||||||
|
},
|
||||||
|
globalData: {}
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
"pages/poi/poi",
|
||||||
|
"pages/badges/badges"
|
||||||
|
],
|
||||||
|
"window": {
|
||||||
|
"navigationBarBackgroundColor": "#fff8ec",
|
||||||
|
"navigationBarTitleText": "打卡小程序",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#fff8ec"
|
||||||
|
},
|
||||||
|
"style": "v2",
|
||||||
|
"sitemapLocation": "sitemap.json"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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())}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "我的徽章",
|
||||||
|
"navigationBarBackgroundColor": "#fff8ec",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#fff8ec"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "景点打卡",
|
||||||
|
"navigationBarBackgroundColor": "#fff8ec",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#fff8ec"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"action": "allow",
|
||||||
|
"page": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -10,9 +10,9 @@ const weixinAppId = manifestEnv.UNI_MP_WEIXIN_APPID || 'touristappid'
|
|||||||
export default defineManifestConfig({
|
export default defineManifestConfig({
|
||||||
'name': '光明文旅地图',
|
'name': '光明文旅地图',
|
||||||
'appid': '',
|
'appid': '',
|
||||||
'description': '深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC',
|
'description': '深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC',
|
||||||
'versionName': '0.3.0',
|
'versionName': '0.2.0',
|
||||||
'versionCode': '30',
|
'versionCode': '20',
|
||||||
'transformPx': false,
|
'transformPx': false,
|
||||||
'uniStatistics': {
|
'uniStatistics': {
|
||||||
enable: false,
|
enable: false,
|
||||||
@@ -69,10 +69,9 @@ export default defineManifestConfig({
|
|||||||
'mp-weixin': {
|
'mp-weixin': {
|
||||||
// Configure locally with UNI_MP_WEIXIN_APPID; do not commit project credentials.
|
// Configure locally with UNI_MP_WEIXIN_APPID; do not commit project credentials.
|
||||||
appid: weixinAppId,
|
appid: weixinAppId,
|
||||||
lazyCodeLoading: 'requiredComponents',
|
|
||||||
permission: {
|
permission: {
|
||||||
'scope.userLocation': {
|
'scope.userLocation': {
|
||||||
desc: '用于地图定位、行程优化和到点打卡判断',
|
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
||||||
|
|||||||
@@ -121,7 +121,6 @@
|
|||||||
"postcss-scss": "^4.0.9",
|
"postcss-scss": "^4.0.9",
|
||||||
"sass": "1.63.2",
|
"sass": "1.63.2",
|
||||||
"sass-loader": "10.4.1",
|
"sass-loader": "10.4.1",
|
||||||
"sharp": "^0.35.3",
|
|
||||||
"simple-git-hooks": "^2.11.1",
|
"simple-git-hooks": "^2.11.1",
|
||||||
"stylelint": "^16.9.0",
|
"stylelint": "^16.9.0",
|
||||||
"stylelint-config-recess-order": "^5.1.1",
|
"stylelint-config-recess-order": "^5.1.1",
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ export default defineUniPages({
|
|||||||
// uni-app compiler drops their manifest equivalents.
|
// uni-app compiler drops their manifest equivalents.
|
||||||
permission: {
|
permission: {
|
||||||
'scope.userLocation': {
|
'scope.userLocation': {
|
||||||
desc: '用于地图定位、行程优化和到点打卡判断',
|
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
||||||
// 微信小程序仅在页面实际使用组件时注入对应代码,减少启动开销。
|
|
||||||
lazyCodeLoading: 'requiredComponents',
|
|
||||||
easycom: {
|
easycom: {
|
||||||
// 注意一定要放在custom里,否则无效,https://ask.dcloud.net.cn/question/131175
|
// 注意一定要放在custom里,否则无效,https://ask.dcloud.net.cn/question/131175
|
||||||
custom: {
|
custom: {
|
||||||
@@ -45,7 +43,7 @@ export default defineUniPages({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
pagePath: 'pages/assistant/index',
|
pagePath: 'pages/assistant/index',
|
||||||
text: '文旅助手',
|
text: 'AI 助手',
|
||||||
iconPath: 'static/tabbar/assistant.png',
|
iconPath: 'static/tabbar/assistant.png',
|
||||||
selectedIconPath: 'static/tabbar/assistant-selected.png',
|
selectedIconPath: 'static/tabbar/assistant-selected.png',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -183,9 +183,6 @@ importers:
|
|||||||
sass-loader:
|
sass-loader:
|
||||||
specifier: 10.4.1
|
specifier: 10.4.1
|
||||||
version: 10.4.1(sass@1.63.2)(webpack@5.99.9)
|
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:
|
simple-git-hooks:
|
||||||
specifier: ^2.11.1
|
specifier: ^2.11.1
|
||||||
version: 2.13.0
|
version: 2.13.0
|
||||||
@@ -1075,9 +1072,6 @@ packages:
|
|||||||
'@emnapi/core@1.4.3':
|
'@emnapi/core@1.4.3':
|
||||||
resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==}
|
resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==}
|
||||||
|
|
||||||
'@emnapi/runtime@1.11.3':
|
|
||||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
|
||||||
|
|
||||||
'@emnapi/runtime@1.4.3':
|
'@emnapi/runtime@1.4.3':
|
||||||
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
|
resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==}
|
||||||
|
|
||||||
@@ -1316,152 +1310,6 @@ packages:
|
|||||||
'@iconify/utils@2.3.0':
|
'@iconify/utils@2.3.0':
|
||||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
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':
|
'@intlify/core-base@9.1.9':
|
||||||
resolution: {integrity: sha512-x5T0p/Ja0S8hs5xs+ImKyYckVkL4CzcEXykVYYV6rcbXxJTe2o58IquSqX9bdncVKbRZP7GlBU1EcRaQEEJ+vw==}
|
resolution: {integrity: sha512-x5T0p/Ja0S8hs5xs+ImKyYckVkL4CzcEXykVYYV6rcbXxJTe2o58IquSqX9bdncVKbRZP7GlBU1EcRaQEEJ+vw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -3348,10 +3196,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
|
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
detect-newline@3.1.0:
|
detect-newline@3.1.0:
|
||||||
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -5659,11 +5503,6 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
semver@7.8.5:
|
|
||||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
|
||||||
engines: {node: '>=10'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
send@0.18.0:
|
send@0.18.0:
|
||||||
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
|
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -5682,15 +5521,6 @@ packages:
|
|||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
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:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -8285,11 +8115,6 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@emnapi/runtime@1.11.3':
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@emnapi/runtime@1.4.3':
|
'@emnapi/runtime@1.4.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -8480,112 +8305,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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':
|
'@intlify/core-base@9.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@intlify/devtools-if': 9.1.9
|
'@intlify/devtools-if': 9.1.9
|
||||||
@@ -10896,8 +10615,6 @@ snapshots:
|
|||||||
|
|
||||||
detect-indent@6.1.0: {}
|
detect-indent@6.1.0: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
|
||||||
|
|
||||||
detect-newline@3.1.0: {}
|
detect-newline@3.1.0: {}
|
||||||
|
|
||||||
devlop@1.1.0:
|
devlop@1.1.0:
|
||||||
@@ -13636,8 +13353,6 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.2: {}
|
semver@7.7.2: {}
|
||||||
|
|
||||||
semver@7.8.5: {}
|
|
||||||
|
|
||||||
send@0.18.0:
|
send@0.18.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 2.6.9
|
debug: 2.6.9
|
||||||
@@ -13689,39 +13404,6 @@ snapshots:
|
|||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
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:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs'
|
|
||||||
/**
|
|
||||||
* POI 照片处理:产出主包缩略图 + 分包大图两层。
|
|
||||||
*
|
|
||||||
* 为什么分两层:微信小程序主包上限 2 MB。30 张 800×600 照片约 2.6 MB,
|
|
||||||
* 单独就超限。所以卡片场景(最大 192rpx ≈ 288 px)用小图放主包,
|
|
||||||
* 详情页 hero(750×422rpx ≈ 1125×633 px)用大图放分包,随详情页按需下载。
|
|
||||||
*
|
|
||||||
* 两个产出目录都是提交进仓库的构建产物:
|
|
||||||
* src/pages-poi/static/photos/<poiId>.jpg 800×600 分包大图(详情页 hero)
|
|
||||||
* src/static/poi/photos/<poiId>.jpg 400×300 主包缩略图(各处卡片)
|
|
||||||
*
|
|
||||||
* 新增一张照片:
|
|
||||||
* 1. 原图放入 src/photo-intake/(该目录不提交),命名为 <poiId>.jpg
|
|
||||||
* 或用下方 NAME_TO_POI_ID 表里的中文名,例如「虹桥公园.jpg」
|
|
||||||
* 2. 跑 `node scripts/process-poi-photos.mjs`
|
|
||||||
* 3. 把 poiId 加入 src/data/poi/photos.ts 的 POI_COVER_PHOTOS_READY
|
|
||||||
*
|
|
||||||
* 不带 intake 直接跑:从分包大图重新生成全部主包缩略图(幂等)。
|
|
||||||
*/
|
|
||||||
import { mkdir, readdir, stat } from 'node:fs/promises'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import process from 'node:process'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import sharp from 'sharp'
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const projectRoot = join(__dirname, '..')
|
|
||||||
|
|
||||||
/** 原图投放目录,不提交仓库 */
|
|
||||||
const intakeDir = join(projectRoot, 'src/photo-intake')
|
|
||||||
/** 分包大图:详情页 hero 使用,随分包按需下载 */
|
|
||||||
const fullDir = join(projectRoot, 'src/pages-poi/static/photos')
|
|
||||||
/** 主包缩略图:地图、行程、打卡等卡片使用 */
|
|
||||||
const thumbDir = join(projectRoot, 'src/static/poi/photos')
|
|
||||||
|
|
||||||
/** 分包大图规格。q66 是在 2 MB 分包上限内能给到 hero 的最高质量 */
|
|
||||||
const FULL = { width: 800, height: 600, quality: 66 }
|
|
||||||
/** 主包缩略图规格。400×300 覆盖最大卡片 192rpx(≈288 px @DPR3) */
|
|
||||||
const THUMB = { width: 400, height: 300, quality: 75 }
|
|
||||||
|
|
||||||
/** 微信包体上限 */
|
|
||||||
const MAIN_PACKAGE_LIMIT_KB = 2048
|
|
||||||
const SUB_PACKAGE_LIMIT_KB = 2048
|
|
||||||
|
|
||||||
// 中文名 → POI ID 映射(30 个景点)
|
|
||||||
const NAME_TO_POI_ID = {
|
|
||||||
// 文化场馆
|
|
||||||
文化艺术中心: 'poi_gm_culture_center',
|
|
||||||
光明区图书馆: 'poi_amap_b0hbtu4bfr',
|
|
||||||
光明区文化馆: 'poi_amap_b0ffk306gx',
|
|
||||||
少年儿童图书馆: 'poi_amap_b02f38meio',
|
|
||||||
深圳国际美术馆: 'poi_amap_b0jun773j3',
|
|
||||||
城市规划馆: 'poi_amap_b0jbbzx4oo',
|
|
||||||
茅洲河图书馆: 'poi_amap_b0k025w32e',
|
|
||||||
// 公园湿地
|
|
||||||
虹桥公园: 'poi_hongqiao_park',
|
|
||||||
红花山公园: 'poi_honghuashan_park',
|
|
||||||
科学公园: 'poi_amap_b0j2jc4n0w',
|
|
||||||
鹅颈水湿地公园: 'poi_amap_b0ffmdhve1',
|
|
||||||
明湖公园: 'poi_amap_b0fffwu1l5',
|
|
||||||
光明新城公园: 'poi_amap_b0ffhfbsje',
|
|
||||||
开明公园: 'poi_amap_b0ffjf1b20',
|
|
||||||
左岸科技公园: 'poi_amap_b0g0kzrt5s',
|
|
||||||
西田公园: 'poi_amap_b02f30abgs',
|
|
||||||
大雁山森林公园: 'poi_amap_b0jgbp18uf',
|
|
||||||
楼村湿地公园: 'poi_amap_b0grac6nzz',
|
|
||||||
大顶岭山林公园: 'poi_amap_b02f309r88',
|
|
||||||
// 田园休闲
|
|
||||||
光明农场大观园: 'poi_farm_grand_view',
|
|
||||||
华侨城光明欢乐田园: 'poi_happy_pastoral',
|
|
||||||
双晖稻田农场: 'poi_amap_b0ffkkmzsc',
|
|
||||||
// 人文地标
|
|
||||||
弘源寺: 'poi_amap_b02f38nj9k',
|
|
||||||
陈仙姑祠: 'poi_amap_b0ffgthbcf',
|
|
||||||
黄氏大宗祠: 'poi_amap_b0fffttiov',
|
|
||||||
玉律醒狮文化馆: 'poi_amap_b0kb4cxaby',
|
|
||||||
// 绿道户外
|
|
||||||
大顶岭绿道: 'poi_dadingling_greenway',
|
|
||||||
光明湖碧道: 'poi_amap_b0ldbsbeea',
|
|
||||||
茅洲河碧道: 'poi_amap_b0kup558j4',
|
|
||||||
五指耙森林公园: 'poi_amap_b0jb2aurvw',
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMAGE_RE = /\.(jpe?g|png|webp)$/i
|
|
||||||
|
|
||||||
/** 把源文件名解析成 poiId:优先当作已经是 poiId,否则查中文名映射 */
|
|
||||||
function resolvePoiId(fileName) {
|
|
||||||
const base = fileName.replace(IMAGE_RE, '')
|
|
||||||
if (base.startsWith('poi_'))
|
|
||||||
return base
|
|
||||||
return NAME_TO_POI_ID[base] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render(inputPath, outputPath, spec) {
|
|
||||||
await sharp(inputPath)
|
|
||||||
.rotate() // 按 EXIF 摆正
|
|
||||||
.resize(spec.width, spec.height, {
|
|
||||||
fit: 'cover',
|
|
||||||
position: 'attention', // 智能裁剪,尽量保留主体
|
|
||||||
})
|
|
||||||
.jpeg({
|
|
||||||
quality: spec.quality,
|
|
||||||
mozjpeg: true,
|
|
||||||
chromaSubsampling: '4:2:0',
|
|
||||||
})
|
|
||||||
.toFile(outputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dirTotalKb(dir) {
|
|
||||||
if (!existsSync(dir))
|
|
||||||
return { count: 0, kb: 0 }
|
|
||||||
const files = (await readdir(dir)).filter(f => f.endsWith('.jpg'))
|
|
||||||
let bytes = 0
|
|
||||||
for (const f of files)
|
|
||||||
bytes += (await stat(join(dir, f))).size
|
|
||||||
return { count: files.length, kb: Math.round(bytes / 1024) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listIntake() {
|
|
||||||
if (!existsSync(intakeDir))
|
|
||||||
return []
|
|
||||||
return (await readdir(intakeDir)).filter(f => IMAGE_RE.test(f))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
await mkdir(fullDir, { recursive: true })
|
|
||||||
await mkdir(thumbDir, { recursive: true })
|
|
||||||
|
|
||||||
const intake = await listIntake()
|
|
||||||
const processed = new Set()
|
|
||||||
|
|
||||||
// 1) 有 intake 原图的:同时生成大图和缩略图
|
|
||||||
for (const file of intake) {
|
|
||||||
const poiId = resolvePoiId(file)
|
|
||||||
if (!poiId) {
|
|
||||||
console.log(`SKIP ${file} — 文件名既不是 poiId 也不在中文名映射表里`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputPath = join(intakeDir, file)
|
|
||||||
try {
|
|
||||||
await render(inputPath, join(fullDir, `${poiId}.jpg`), FULL)
|
|
||||||
await render(inputPath, join(thumbDir, `${poiId}.jpg`), THUMB)
|
|
||||||
processed.add(poiId)
|
|
||||||
console.log(`OK ${file} → ${poiId}.jpg(大图 + 缩略图)`)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(`FAIL ${file}: ${error.message}`)
|
|
||||||
process.exitCode = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 其余已有大图的:从大图重新生成缩略图,保证两层始终同步
|
|
||||||
const fullFiles = (await readdir(fullDir)).filter(f => f.endsWith('.jpg'))
|
|
||||||
for (const file of fullFiles) {
|
|
||||||
const poiId = file.replace(/\.jpg$/, '')
|
|
||||||
if (processed.has(poiId))
|
|
||||||
continue
|
|
||||||
try {
|
|
||||||
await render(join(fullDir, file), join(thumbDir, file), THUMB)
|
|
||||||
console.log(`THUMB ${poiId}.jpg ← 由分包大图重新生成`)
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(`FAIL ${file}: ${error.message}`)
|
|
||||||
process.exitCode = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 报告包体占用,超限直接失败
|
|
||||||
const full = await dirTotalKb(fullDir)
|
|
||||||
const thumb = await dirTotalKb(thumbDir)
|
|
||||||
|
|
||||||
console.log('\n包体占用:')
|
|
||||||
console.log(` 分包大图 ${String(full.count).padStart(2)} 张 ${String(full.kb).padStart(4)} KB (分包上限 ${SUB_PACKAGE_LIMIT_KB} KB)`)
|
|
||||||
console.log(` 主包缩略图 ${String(thumb.count).padStart(2)} 张 ${String(thumb.kb).padStart(4)} KB (主包上限 ${MAIN_PACKAGE_LIMIT_KB} KB,还需容纳代码)`)
|
|
||||||
|
|
||||||
if (full.kb >= SUB_PACKAGE_LIMIT_KB) {
|
|
||||||
console.error(`\n分包大图已达 ${full.kb} KB,超过分包上限。请下调 FULL.quality 后重跑。`)
|
|
||||||
process.exitCode = 1
|
|
||||||
}
|
|
||||||
if (thumb.kb >= MAIN_PACKAGE_LIMIT_KB) {
|
|
||||||
console.error(`\n主包缩略图已达 ${thumb.kb} KB,超过主包上限。请下调 THUMB 规格后重跑。`)
|
|
||||||
process.exitCode = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error)
|
|
||||||
process.exitCode = 1
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
|
|
||||||
@@ -10,19 +10,8 @@ const expectedPages = [
|
|||||||
'pages/assistant/index',
|
'pages/assistant/index',
|
||||||
'pages/itinerary/index',
|
'pages/itinerary/index',
|
||||||
'pages/planner/index',
|
'pages/planner/index',
|
||||||
'pages/check-in/index',
|
|
||||||
'pages/check-in/records',
|
|
||||||
// 旧详情页路由的兼容跳板,只做重定向。缺了它,还攥着旧路径的入口
|
|
||||||
// (模拟器上次停留的路由、预览启动页、已发出的分享卡片)会白屏。
|
|
||||||
'pages/poi/detail',
|
'pages/poi/detail',
|
||||||
]
|
]
|
||||||
// 点位详情页放在 pages-poi 分包里,连带 800×600 大图一起按需下载,
|
|
||||||
// 否则 30 张大图会把主包顶过 2 MB 上限。
|
|
||||||
const expectedSubPackageRoot = 'pages-poi'
|
|
||||||
// 分包页文件名不能是 detail:uni 的 H5 路由表按路径生成组件标识,`pages-poi/detail`
|
|
||||||
// 和上面兼容跳板的 `pages/poi/detail` 会同时归一化成 PagesPoiDetail,重复声明直接编译失败。
|
|
||||||
const expectedSubPackagePages = ['poi-detail']
|
|
||||||
const poiDetailPath = `${expectedSubPackageRoot}/poi-detail`
|
|
||||||
const expectedTabPages = [
|
const expectedTabPages = [
|
||||||
'pages/map/index',
|
'pages/map/index',
|
||||||
'pages/assistant/index',
|
'pages/assistant/index',
|
||||||
@@ -42,9 +31,6 @@ const requiredFiles = [
|
|||||||
'app.wxss',
|
'app.wxss',
|
||||||
'project.config.json',
|
'project.config.json',
|
||||||
...expectedPages.flatMap(page => ['json', 'js', 'wxml', 'wxss'].map(extension => `${page}.${extension}`)),
|
...expectedPages.flatMap(page => ['json', 'js', 'wxml', 'wxss'].map(extension => `${page}.${extension}`)),
|
||||||
...expectedSubPackagePages.flatMap(page => ['json', 'js', 'wxml', 'wxss'].map(
|
|
||||||
extension => `${expectedSubPackageRoot}/${page}.${extension}`,
|
|
||||||
)),
|
|
||||||
...expectedTabIcons,
|
...expectedTabIcons,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -56,29 +42,6 @@ if (missingFiles.length > 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appConfig = JSON.parse(readFileSync(resolve(outputRoot, 'app.json'), 'utf8'))
|
const appConfig = JSON.parse(readFileSync(resolve(outputRoot, 'app.json'), 'utf8'))
|
||||||
if (appConfig.lazyCodeLoading !== 'requiredComponents') {
|
|
||||||
console.error(`组件按需注入配置错误:期望 requiredComponents,实际为 ${appConfig.lazyCodeLoading ?? '未配置'}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
const checkInPageScript = readFileSync(resolve(outputRoot, 'pages/check-in/index.js'), 'utf8')
|
|
||||||
const checkInPageMarkup = readFileSync(resolve(outputRoot, 'pages/check-in/index.wxml'), 'utf8')
|
|
||||||
if (!checkInPageScript.includes('requirePrivacyAuthorize')
|
|
||||||
|| !checkInPageMarkup.includes('open-type="agreePrivacyAuthorization"')) {
|
|
||||||
console.error('打卡页缺少微信位置隐私授权兼容链。')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
const poiDetailMarkup = readFileSync(resolve(outputRoot, `${poiDetailPath}.wxml`), 'utf8')
|
|
||||||
const checkInEntryIndex = poiDetailMarkup.indexOf('check-in-entry')
|
|
||||||
const checkInEntryStart = poiDetailMarkup.lastIndexOf('<view', checkInEntryIndex)
|
|
||||||
const checkInEntryEnd = poiDetailMarkup.indexOf('>', checkInEntryIndex)
|
|
||||||
const checkInEntryTag = checkInEntryIndex >= 0 && checkInEntryStart >= 0 && checkInEntryEnd >= 0
|
|
||||||
? poiDetailMarkup.slice(checkInEntryStart, checkInEntryEnd + 1)
|
|
||||||
: ''
|
|
||||||
const visibilityAttributes = ['wx:if=', 'wx:elif=', 'wx:else', 'hidden=']
|
|
||||||
if (!checkInEntryTag || visibilityAttributes.some(attribute => checkInEntryTag.includes(attribute))) {
|
|
||||||
console.error('景点详情页必须无条件渲染打卡入口。')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
const expectedPrivateInfos = ['getLocation', 'startLocationUpdate', 'onLocationChange']
|
const expectedPrivateInfos = ['getLocation', 'startLocationUpdate', 'onLocationChange']
|
||||||
const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? []
|
const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? []
|
||||||
if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) {
|
if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) {
|
||||||
@@ -92,10 +55,6 @@ if (locationPermissionDescriptionLength < 1 || locationPermissionDescriptionLeng
|
|||||||
console.error('app.json 缺少 scope.userLocation 用途说明。')
|
console.error('app.json 缺少 scope.userLocation 用途说明。')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
if (!locationPermissionDescription.includes('打卡')) {
|
|
||||||
console.error('scope.userLocation 用途说明未覆盖到点打卡。')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const missingPages = expectedPages.filter(page => !appConfig.pages?.includes(page))
|
const missingPages = expectedPages.filter(page => !appConfig.pages?.includes(page))
|
||||||
if (missingPages.length > 0) {
|
if (missingPages.length > 0) {
|
||||||
@@ -179,81 +138,4 @@ if (!ideConfig.appid || !outputIdeConfig.appid || ideConfig.appid !== outputIdeC
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const subPackages = appConfig.subPackages ?? appConfig.subpackages ?? []
|
|
||||||
const poiSubPackage = subPackages.find(item => item.root === expectedSubPackageRoot)
|
|
||||||
if (!poiSubPackage) {
|
|
||||||
console.error(`app.json 未注册 ${expectedSubPackageRoot} 分包,点位详情页与大图会落进主包。`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const missingSubPages = expectedSubPackagePages.filter(page => !poiSubPackage.pages?.includes(page))
|
|
||||||
if (missingSubPages.length > 0) {
|
|
||||||
console.error(`${expectedSubPackageRoot} 分包未注册全部页面:`)
|
|
||||||
missingSubPages.forEach(page => console.error(`- ${page}`))
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分包大图必须落在分包内,否则等于没分包
|
|
||||||
const fullPhotoDir = resolve(outputRoot, expectedSubPackageRoot, 'static/photos')
|
|
||||||
if (!existsSync(fullPhotoDir)) {
|
|
||||||
console.error(`分包缺少大图目录:${expectedSubPackageRoot}/static/photos`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbPhotoDir = resolve(outputRoot, 'static/poi/photos')
|
|
||||||
if (!existsSync(thumbPhotoDir)) {
|
|
||||||
console.error('主包缺少缩略图目录:static/poi/photos')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPhotos = readdirSync(fullPhotoDir).filter(file => file.endsWith('.jpg'))
|
|
||||||
const thumbPhotos = readdirSync(thumbPhotoDir).filter(file => file.endsWith('.jpg'))
|
|
||||||
if (fullPhotos.length !== thumbPhotos.length) {
|
|
||||||
console.error(`两层照片数量不一致:分包大图 ${fullPhotos.length} 张,主包缩略图 ${thumbPhotos.length} 张。请重跑 node scripts/process-poi-photos.mjs`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dirBytes(dir, skipDirs = []) {
|
|
||||||
let total = 0
|
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
if (skipDirs.includes(entry.name))
|
|
||||||
continue
|
|
||||||
total += dirBytes(resolve(dir, entry.name), skipDirs)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
total += statSync(resolve(dir, entry.name)).size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// 微信主包与单个分包各自上限 2 MB。留 64 KB 余量,避免刚好卡线时上传被拒。
|
|
||||||
const PACKAGE_LIMIT_KB = 2048
|
|
||||||
const PACKAGE_WARN_KB = PACKAGE_LIMIT_KB - 64
|
|
||||||
const subPackageRoots = subPackages.map(item => item.root)
|
|
||||||
const mainPackageKb = Math.round(dirBytes(outputRoot, subPackageRoots) / 1024)
|
|
||||||
const subPackageSizes = subPackages.map(item => ({
|
|
||||||
root: item.root,
|
|
||||||
kb: Math.round(dirBytes(resolve(outputRoot, item.root)) / 1024),
|
|
||||||
}))
|
|
||||||
|
|
||||||
console.log(`主包 ${mainPackageKb} KB / ${PACKAGE_LIMIT_KB} KB`)
|
|
||||||
subPackageSizes.forEach(({ root, kb }) => {
|
|
||||||
console.log(`分包 ${root} ${kb} KB / ${PACKAGE_LIMIT_KB} KB`)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (mainPackageKb > PACKAGE_WARN_KB) {
|
|
||||||
console.error(`主包 ${mainPackageKb} KB 已超过 ${PACKAGE_WARN_KB} KB 安全线(微信上限 ${PACKAGE_LIMIT_KB} KB),上传会被拒。`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const oversizedSubPackages = subPackageSizes.filter(({ kb }) => kb > PACKAGE_WARN_KB)
|
|
||||||
if (oversizedSubPackages.length > 0) {
|
|
||||||
oversizedSubPackages.forEach(({ root, kb }) => {
|
|
||||||
console.error(`分包 ${root} ${kb} KB 已超过 ${PACKAGE_WARN_KB} KB 安全线(微信上限 ${PACKAGE_LIMIT_KB} KB)。`)
|
|
||||||
})
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`微信开发者工具产物检查通过:${outputRoot}`)
|
console.log(`微信开发者工具产物检查通过:${outputRoot}`)
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
LLM_MODE=mock
|
LLM_MODE=mock
|
||||||
|
|
||||||
# Configure these on the server only when LLM_MODE=real.
|
# Configure these on the server only when LLM_MODE=real.
|
||||||
# Any OpenAI-compatible endpoint works, e.g. Zhipu GLM:
|
|
||||||
# OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
|
||||||
# OPENAI_MODEL=glm-4-flash # free tier; use glm-4-plus etc. if you have credit
|
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
OPENAI_MODEL=
|
OPENAI_MODEL=
|
||||||
|
|||||||
@@ -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 FastAPI service is the server-only AI boundary for the Guangming travel POC. It defaults to deterministic `mock` mode. Model credentials belong only in `server/.env` or the deployment environment and must never be added to the mini-program build.
|
||||||
|
|
||||||
The mini-program calls this service when `VITE_TRAVEL_ASSISTANT_API_BASE_URL` is set (`.env.development` points it at `http://localhost:8000`). If that variable is empty, or the service is unreachable or returns an error, the client silently falls back to its deterministic local planner — so the demo always works. The model only ever recommends POIs from a coordinate-free client whitelist; route geometry, transfer estimates, and final itinerary time remain client responsibilities. The itinerary page labels the result `真实 AI 推荐` (real), `远端演示规划` (mock), or `本地 POC 规划` (local fallback).
|
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
`POST /api/v1/plans` accepts a planning request and a coordinate-free POI whitelist:
|
`POST /api/v1/plans` accepts a planning request and a coordinate-free POI whitelist:
|
||||||
@@ -14,9 +12,15 @@ The mini-program calls this service when `VITE_TRAVEL_ASSISTANT_API_BASE_URL` is
|
|||||||
"preferences": {
|
"preferences": {
|
||||||
"destination": "深圳市光明区",
|
"destination": "深圳市光明区",
|
||||||
"themes": ["亲子"],
|
"themes": ["亲子"],
|
||||||
|
"duration": "half_day",
|
||||||
"pace": "moderate",
|
"pace": "moderate",
|
||||||
"interests": ["自然风光"],
|
"interests": ["自然风光"],
|
||||||
"transport": "public_transport"
|
"adults": 2,
|
||||||
|
"children": 1,
|
||||||
|
"childAges": [6],
|
||||||
|
"transport": "public_transport",
|
||||||
|
"budgetLevel": "standard",
|
||||||
|
"extraRequirements": ""
|
||||||
},
|
},
|
||||||
"durationMinutes": 240,
|
"durationMinutes": 240,
|
||||||
"selectedPoiIds": ["poi_a", "poi_b"],
|
"selectedPoiIds": ["poi_a", "poi_b"],
|
||||||
@@ -60,16 +64,3 @@ Run tests from the repository root:
|
|||||||
```bash
|
```bash
|
||||||
corepack pnpm server:test
|
corepack pnpm server:test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Real model mode
|
|
||||||
|
|
||||||
Mock mode is deterministic and needs no credentials. To use a real model, set `LLM_MODE=real` in `server/.env` (gitignored) with any OpenAI-compatible endpoint — for example Zhipu GLM:
|
|
||||||
|
|
||||||
```
|
|
||||||
LLM_MODE=real
|
|
||||||
OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
|
||||||
OPENAI_MODEL=glm-4-flash
|
|
||||||
OPENAI_API_KEY=<your key>
|
|
||||||
```
|
|
||||||
|
|
||||||
`GET /api/v1/health` then reports `mode=real, configured=true, ready=true`. With `pnpm server:dev` running, generate a plan from the mini-program (H5 dev server on `:5173` is already in `CORS_ORIGINS`; for `mp-weixin` enable “不校验合法域名” in the devtools). To force the local planner again, unset `VITE_TRAVEL_ASSISTANT_API_BASE_URL` or stop the server.
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ def _score(candidate: PoiCandidate, request: PlanRecommendationRequest) -> int:
|
|||||||
for interest in request.preferences.interests:
|
for interest in request.preferences.interests:
|
||||||
desired_tags.update(INTEREST_TAGS.get(interest, set()))
|
desired_tags.update(INTEREST_TAGS.get(interest, set()))
|
||||||
score += len(desired_tags.intersection(candidate.tag_codes)) * 8
|
score += len(desired_tags.intersection(candidate.tag_codes)) * 8
|
||||||
|
if request.preferences.children and "family_friendly" in candidate.tag_codes:
|
||||||
|
score += 8
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class PlanningMode(str, Enum):
|
|||||||
CUSTOM = "custom"
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class Duration(str, Enum):
|
||||||
|
HALF_DAY = "half_day"
|
||||||
|
FULL_DAY = "full_day"
|
||||||
|
|
||||||
|
|
||||||
class Pace(str, Enum):
|
class Pace(str, Enum):
|
||||||
RELAXED = "relaxed"
|
RELAXED = "relaxed"
|
||||||
MODERATE = "moderate"
|
MODERATE = "moderate"
|
||||||
@@ -37,6 +42,12 @@ class Transport(str, Enum):
|
|||||||
PUBLIC_TRANSPORT = "public_transport"
|
PUBLIC_TRANSPORT = "public_transport"
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetLevel(str, Enum):
|
||||||
|
ECONOMY = "economy"
|
||||||
|
STANDARD = "standard"
|
||||||
|
QUALITY = "quality"
|
||||||
|
|
||||||
|
|
||||||
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
|
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
|
||||||
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
|
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
|
||||||
|
|
||||||
@@ -44,12 +55,24 @@ Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "
|
|||||||
class TravelPreferences(APIModel):
|
class TravelPreferences(APIModel):
|
||||||
destination: Literal["深圳市光明区"] = "深圳市光明区"
|
destination: Literal["深圳市光明区"] = "深圳市光明区"
|
||||||
themes: list[Theme] = Field(default_factory=list, max_length=5)
|
themes: list[Theme] = Field(default_factory=list, max_length=5)
|
||||||
|
duration: Duration
|
||||||
pace: Pace
|
pace: Pace
|
||||||
interests: list[Interest] = Field(default_factory=list, max_length=5)
|
interests: list[Interest] = Field(default_factory=list, max_length=5)
|
||||||
|
adults: int = Field(default=2, ge=0, le=20)
|
||||||
|
children: int = Field(default=0, ge=0, le=20)
|
||||||
|
child_ages: list[int] = Field(default_factory=list, max_length=20)
|
||||||
transport: Transport = Transport.PUBLIC_TRANSPORT
|
transport: Transport = Transport.PUBLIC_TRANSPORT
|
||||||
|
budget_level: BudgetLevel = BudgetLevel.STANDARD
|
||||||
|
extra_requirements: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_group(self) -> "TravelPreferences":
|
def validate_group(self) -> "TravelPreferences":
|
||||||
|
if self.adults + self.children < 1:
|
||||||
|
raise ValueError("出行总人数至少为 1")
|
||||||
|
if self.child_ages and len(self.child_ages) != self.children:
|
||||||
|
raise ValueError("儿童年龄数量必须与儿童人数一致")
|
||||||
|
if any(age < 0 or age > 17 for age in self.child_ages):
|
||||||
|
raise ValueError("儿童年龄须在 0 至 17 岁之间")
|
||||||
if len(set(self.themes)) != len(self.themes):
|
if len(set(self.themes)) != len(self.themes):
|
||||||
raise ValueError("出行主题不能重复")
|
raise ValueError("出行主题不能重复")
|
||||||
if len(set(self.interests)) != len(self.interests):
|
if len(set(self.interests)) != len(self.interests):
|
||||||
|
|||||||
@@ -16,9 +16,15 @@ def preferences():
|
|||||||
return {
|
return {
|
||||||
"destination": "深圳市光明区",
|
"destination": "深圳市光明区",
|
||||||
"themes": ["亲子"],
|
"themes": ["亲子"],
|
||||||
|
"duration": "half_day",
|
||||||
"pace": "moderate",
|
"pace": "moderate",
|
||||||
"interests": ["自然风光"],
|
"interests": ["自然风光"],
|
||||||
|
"adults": 2,
|
||||||
|
"children": 1,
|
||||||
|
"childAges": [6],
|
||||||
"transport": "public_transport",
|
"transport": "public_transport",
|
||||||
|
"budgetLevel": "standard",
|
||||||
|
"extraRequirements": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ def custom_request() -> PlanRecommendationRequest:
|
|||||||
"preferences": {
|
"preferences": {
|
||||||
"destination": "深圳市光明区",
|
"destination": "深圳市光明区",
|
||||||
"themes": ["朋友"],
|
"themes": ["朋友"],
|
||||||
|
"duration": "half_day",
|
||||||
"pace": "moderate",
|
"pace": "moderate",
|
||||||
"interests": ["自然风光"],
|
"interests": ["自然风光"],
|
||||||
|
"adults": 2,
|
||||||
|
"children": 0,
|
||||||
|
"childAges": [],
|
||||||
"transport": "public_transport",
|
"transport": "public_transport",
|
||||||
|
"budgetLevel": "standard",
|
||||||
|
"extraRequirements": "",
|
||||||
},
|
},
|
||||||
"durationMinutes": 120,
|
"durationMinutes": 120,
|
||||||
"selectedPoiIds": ["poi_a", "poi_b", "poi_c"],
|
"selectedPoiIds": ["poi_a", "poi_b", "poi_c"],
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
// Generated by unplugin-vue-components
|
|
||||||
// Read more: https://github.com/vuejs/core/pull/3399
|
|
||||||
export {}
|
|
||||||
|
|
||||||
/* prettier-ignore */
|
|
||||||
declare module 'vue' {
|
|
||||||
export interface GlobalComponents {
|
|
||||||
MapFilterHeader: typeof import('./components/map/MapFilterHeader.vue')['default']
|
|
||||||
PageState: typeof import('./components/poi/PageState.vue')['default']
|
|
||||||
PoiHeroGallery: typeof import('./components/poi/PoiHeroGallery.vue')['default']
|
|
||||||
PoiImage: typeof import('./components/poi/PoiImage.vue')['default']
|
|
||||||
PoiSummaryCard: typeof import('./components/map/PoiSummaryCard.vue')['default']
|
|
||||||
PoiTagList: typeof import('./components/poi/PoiTagList.vue')['default']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
|
||||||
TravelPoiCard: typeof import('./components/travel/TravelPoiCard.vue')['default']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
checkIns: []
|
|
||||||
select: [categoryCode: string | null]
|
select: [categoryCode: string | null]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@@ -22,10 +21,7 @@ const emit = defineEmits<{
|
|||||||
<text class="map-filter-header__title">光明区文旅资源</text>
|
<text class="map-filter-header__title">光明区文旅资源</text>
|
||||||
<text class="map-filter-header__scope">{{ coverageLabel }}</text>
|
<text class="map-filter-header__scope">{{ coverageLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="map-filter-header__actions">
|
|
||||||
<text class="map-filter-header__count">共 {{ resultCount }} 个点位</text>
|
<text class="map-filter-header__count">共 {{ resultCount }} 个点位</text>
|
||||||
<button class="map-filter-header__check-ins" @click="emit('checkIns')">我的打卡</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view class="map-filter-header__scroll" scroll-x :show-scrollbar="false">
|
<scroll-view class="map-filter-header__scroll" scroll-x :show-scrollbar="false">
|
||||||
@@ -95,36 +91,11 @@ const emit = defineEmits<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map-filter-header__count {
|
.map-filter-header__count {
|
||||||
|
flex: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #167a5b;
|
color: #167a5b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-filter-header__actions {
|
|
||||||
display: flex;
|
|
||||||
flex: none;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4rpx;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-filter-header__check-ins {
|
|
||||||
width: auto;
|
|
||||||
height: 42rpx;
|
|
||||||
padding: 0 15rpx;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 42rpx;
|
|
||||||
color: #0e6247;
|
|
||||||
background: #e8f4ee;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 21rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-filter-header__check-ins::after {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-filter-header__scroll {
|
.map-filter-header__scroll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './tasks'
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { Poi, PoiDataset, PoiImageAsset } from '@/domain/poi'
|
import type { Poi, PoiDataset, PoiImageAsset } from '@/domain/poi'
|
||||||
import { coverPhotoUrlFor, hasCoverPhoto } from './photos'
|
|
||||||
|
|
||||||
const SNAPSHOT_CAPTURED_AT = '2026-07-30T01:00:33Z'
|
const SNAPSHOT_CAPTURED_AT = '2026-07-30T01:00:33Z'
|
||||||
const POC_REVIEWED_AT = '2026-07-30T01:30:00Z'
|
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 PLACEHOLDER_URL = '/static/poi/placeholder.png'
|
||||||
const AMAP_DATA_SOURCE = '高德地图 Web Service POI 2.0 离线候选快照(2026-07-30),POC 内容映射待项目方复核'
|
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 {
|
function createPlaceholderImage(seed: PocPoiSeed): PoiImageAsset {
|
||||||
const photoReady = hasCoverPhoto(seed.id)
|
|
||||||
return {
|
return {
|
||||||
id: `img_${seed.id}_cover`,
|
id: `img_${seed.id}_cover`,
|
||||||
url: photoReady ? coverPhotoUrlFor(seed.id) : PLACEHOLDER_URL,
|
url: PLACEHOLDER_URL,
|
||||||
source: photoReady
|
source: 'map-gm-app POC 包内占位素材',
|
||||||
? '项目方提供实景照片(本地静态资源)'
|
sourceReference: 'asset_src_static_poi_placeholder',
|
||||||
: 'map-gm-app POC 包内占位素材',
|
|
||||||
sourceReference: photoReady
|
|
||||||
? `asset_src_static_poi_photos_${seed.id}_jpg`
|
|
||||||
: 'asset_src_static_poi_placeholder',
|
|
||||||
copyrightStatus: 'cleared',
|
copyrightStatus: 'cleared',
|
||||||
licenseEvidence: photoReady
|
licenseEvidence: 'POC_INTERNAL_ASSET_001',
|
||||||
? 'PROJECT_PROVIDED_COVER_PHOTO_2026Q3'
|
reviewedBy: 'poc_content_owner',
|
||||||
: 'POC_INTERNAL_ASSET_001',
|
reviewedAt: POC_REVIEWED_AT,
|
||||||
reviewedBy: photoReady ? 'project_content_owner' : 'poc_content_owner',
|
updatedAt: POC_REVIEWED_AT,
|
||||||
reviewedAt: photoReady ? PHOTOS_REVIEWED_AT : POC_REVIEWED_AT,
|
isPlaceholder: true,
|
||||||
updatedAt: photoReady ? PHOTOS_REVIEWED_AT : POC_REVIEWED_AT,
|
alt: `${seed.name} POC 占位图`,
|
||||||
isPlaceholder: !photoReady,
|
|
||||||
alt: photoReady ? `${seed.name}实景照片` : `${seed.name} POC 占位图`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,13 +484,10 @@ export const poiDataset: PoiDataset = {
|
|||||||
authoritativeBaselineProvided: false,
|
authoritativeBaselineProvided: false,
|
||||||
canClaimFullCoverage: false,
|
canClaimFullCoverage: false,
|
||||||
disclaimer: `本地数据来自 ${SNAPSHOT_CAPTURED_AT.slice(0, 10)} 高德候选快照并经 POC 筛选;简介、开放时间、推荐指数与图片仍需项目方逐点复核,不代表光明区全域权威清单。`,
|
disclaimer: `本地数据来自 ${SNAPSHOT_CAPTURED_AT.slice(0, 10)} 高德候选快照并经 POC 筛选;简介、开放时间、推荐指数与图片仍需项目方逐点复核,不代表光明区全域权威清单。`,
|
||||||
// 仅在运行时算不出聚类视野(点位为空)时兜底。
|
|
||||||
// 值取自 30 个点位的算术中心,与 computeClusterViewport 的结果一致;
|
|
||||||
// 真正生效的初始视野由 computeClusterViewport 按当前点位实时算出。
|
|
||||||
defaultViewport: {
|
defaultViewport: {
|
||||||
longitude: 113.9275,
|
longitude: 113.9275,
|
||||||
latitude: 22.7611,
|
latitude: 22.76,
|
||||||
scale: 12,
|
scale: 11,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
categories: [
|
categories: [
|
||||||
@@ -556,6 +544,6 @@ export const poiDataset: PoiDataset = {
|
|||||||
{ code: 'science_learning', name: '科普体验', sortOrder: 90, status: 'enabled', synonyms: ['科普'] },
|
{ code: 'science_learning', name: '科普体验', sortOrder: 90, status: 'enabled', synonyms: ['科普'] },
|
||||||
{ code: 'walking', name: '漫步休闲', sortOrder: 100, status: 'enabled', synonyms: ['徒步', '散步'] },
|
{ code: 'walking', name: '漫步休闲', sortOrder: 100, status: 'enabled', synonyms: ['徒步', '散步'] },
|
||||||
],
|
],
|
||||||
images: poiSeeds.map(createCoverImage),
|
images: poiSeeds.map(createPlaceholderImage),
|
||||||
pois: poiSeeds.map(createPoi),
|
pois: poiSeeds.map(createPoi),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* POI 封面照片注册表
|
|
||||||
*
|
|
||||||
* 照片分两层存放,因为微信小程序主包上限 2 MB,30 张大图单独就超限:
|
|
||||||
* - 缩略图 400×300 放主包,供地图、行程、打卡等卡片使用(最大 192rpx ≈ 288 px)
|
|
||||||
* - 大图 800×600 放 pages-poi 分包,供详情页 hero 使用,随分包按需下载
|
|
||||||
*
|
|
||||||
* 如何添加一张真实照片:
|
|
||||||
* 1. 原图放入 src/photo-intake/(不提交仓库),命名为 <poiId>.jpg
|
|
||||||
* 2. 跑 `node scripts/process-poi-photos.mjs` 生成两层图片
|
|
||||||
* 3. 将 poiId 加入下方 POI_COVER_PHOTOS_READY 集合
|
|
||||||
*
|
|
||||||
* 未在此集合中的 POI 会继续使用占位图 /static/poi/placeholder.png
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 已放入真实照片的 POI ID 集合 */
|
|
||||||
export const POI_COVER_PHOTOS_READY: ReadonlySet<string> = new Set<string>([
|
|
||||||
// 文化场馆
|
|
||||||
'poi_gm_culture_center', // 光明文化艺术中心
|
|
||||||
'poi_amap_b0hbtu4bfr', // 光明区图书馆
|
|
||||||
'poi_amap_b0ffk306gx', // 光明区文化馆
|
|
||||||
'poi_amap_b02f38meio', // 光明区少年儿童图书馆
|
|
||||||
'poi_amap_b0jun773j3', // 深圳国际美术馆
|
|
||||||
'poi_amap_b0jbbzx4oo', // 光明区城市规划展览馆
|
|
||||||
'poi_amap_b0k025w32e', // 茅洲河图书馆
|
|
||||||
// 公园湿地
|
|
||||||
'poi_hongqiao_park', // 虹桥公园
|
|
||||||
'poi_honghuashan_park', // 红花山公园
|
|
||||||
'poi_amap_b0j2jc4n0w', // 科学公园
|
|
||||||
'poi_amap_b0ffmdhve1', // 鹅颈水湿地公园
|
|
||||||
'poi_amap_b0fffwu1l5', // 明湖公园
|
|
||||||
'poi_amap_b0ffhfbsje', // 光明新城公园(牛山公园)
|
|
||||||
'poi_amap_b0ffjf1b20', // 光明开明公园
|
|
||||||
'poi_amap_b0g0kzrt5s', // 左岸科技公园
|
|
||||||
'poi_amap_b02f30abgs', // 西田公园
|
|
||||||
'poi_amap_b0jgbp18uf', // 大雁山森林公园
|
|
||||||
'poi_amap_b0grac6nzz', // 楼村湿地公园
|
|
||||||
'poi_amap_b02f309r88', // 大顶岭山林公园
|
|
||||||
// 田园休闲
|
|
||||||
'poi_farm_grand_view', // 光明农场大观园
|
|
||||||
'poi_happy_pastoral', // 华侨城光明欢乐田园
|
|
||||||
'poi_amap_b0ffkkmzsc', // 双晖稻田农场
|
|
||||||
// 人文地标
|
|
||||||
'poi_amap_b02f38nj9k', // 弘源寺
|
|
||||||
'poi_amap_b0ffgthbcf', // 陈仙姑祠
|
|
||||||
'poi_amap_b0fffttiov', // 黄氏大宗祠
|
|
||||||
'poi_amap_b0kb4cxaby', // 玉律醒狮文化馆
|
|
||||||
// 绿道户外
|
|
||||||
'poi_dadingling_greenway', // 大顶岭绿道
|
|
||||||
'poi_amap_b0ldbsbeea', // 光明湖碧道
|
|
||||||
'poi_amap_b0kup558j4', // 茅洲河碧道
|
|
||||||
'poi_amap_b0jb2aurvw', // 五指耙森林公园(光明片区)
|
|
||||||
])
|
|
||||||
|
|
||||||
/** 主包缩略图目录(400×300) */
|
|
||||||
const POI_THUMB_BASE_URL = '/static/poi/photos'
|
|
||||||
/** pages-poi 分包大图目录(800×600),仅分包内页面可引用 */
|
|
||||||
const POI_FULL_BASE_URL = '/pages-poi/static/photos'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 卡片用缩略图 URL。主包内任何页面都可用。
|
|
||||||
*/
|
|
||||||
export function coverPhotoUrlFor(poiId: string): string {
|
|
||||||
return `${POI_THUMB_BASE_URL}/${poiId}.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 详情页 hero 用大图 URL。
|
|
||||||
*
|
|
||||||
* 只有 pages-poi 分包内的页面能引用 —— 分包资源要等分包下载后才存在,
|
|
||||||
* 主包页面拿到这个路径会加载失败。
|
|
||||||
*/
|
|
||||||
export function coverPhotoFullUrlFor(poiId: string): string {
|
|
||||||
return `${POI_FULL_BASE_URL}/${poiId}.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasCoverPhoto(poiId: string): boolean {
|
|
||||||
return POI_COVER_PHOTOS_READY.has(poiId)
|
|
||||||
}
|
|
||||||
@@ -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 })) }
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './check-in'
|
|
||||||
export * from './types'
|
|
||||||
export * from './validation'
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -18,8 +18,11 @@ export function formatOpeningHours(openingHours: PoiOpeningHours): string {
|
|||||||
return openingHours.displayText?.trim() || '开放时间待确认'
|
return openingHours.displayText?.trim() || '开放时间待确认'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRecommendationLabel(_source: RecommendationSource, index: number): string {
|
export function formatRecommendationLabel(source: RecommendationSource, index: number): string {
|
||||||
return `推荐指数 ${index}/5`
|
const label = source === 'official_editorial'
|
||||||
|
? '官方推荐指数'
|
||||||
|
: 'POC 推荐指数(待审核)'
|
||||||
|
return `${label} ${index}/5`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterPois(pois: readonly Poi[], filter: PoiFilter = {}): Poi[] {
|
export function filterPois(pois: readonly Poi[], filter: PoiFilter = {}): Poi[] {
|
||||||
|
|||||||
@@ -12,13 +12,7 @@ import type {
|
|||||||
|
|
||||||
// A conservative POC envelope used to catch obviously misplaced coordinates.
|
// A conservative POC envelope used to catch obviously misplaced coordinates.
|
||||||
// Content review remains the authority for whether a point belongs to Guangming District.
|
// Content review remains the authority for whether a point belongs to Guangming District.
|
||||||
/**
|
const GUANGMING_POC_BOUNDS = {
|
||||||
* 光明区 POC 的告警范围,用于提示「这个点位可能不在光明区」。
|
|
||||||
*
|
|
||||||
* 注意:这是内容审核的告警包络,不是运行时的坐标限制。真实用户可能站在范围之外
|
|
||||||
* (比如在市区规划行程),不能拿它去拒绝定位结果 —— 那是 isTrustworthyCoordinate 的活。
|
|
||||||
*/
|
|
||||||
export const GUANGMING_POC_BOUNDS = {
|
|
||||||
minLongitude: 113.78,
|
minLongitude: 113.78,
|
||||||
maxLongitude: 114.02,
|
maxLongitude: 114.02,
|
||||||
minLatitude: 22.70,
|
minLatitude: 22.70,
|
||||||
@@ -34,27 +28,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 运行时坐标可信判断,用于点位数据校验与地图/定位状态写入。
|
|
||||||
*
|
|
||||||
* 除了有限性和全球范围,还必须拒绝经纬度为 0 的坐标:
|
|
||||||
* 地图 SDK 尚未拿到定位、或定位接口降级时会回传 (0, 0),
|
|
||||||
* 它落在几内亚湾(Null Island)。微信 `<map>` 在境外坐标下会切到 HERE 底图,
|
|
||||||
* 视觉上就是「地图跑到非洲的海里」,还会因为境外瓦片加载失败而留下大片空白。
|
|
||||||
*/
|
|
||||||
export function isTrustworthyCoordinate(longitude: unknown, latitude: unknown): boolean {
|
|
||||||
return typeof longitude === 'number'
|
|
||||||
&& typeof latitude === 'number'
|
|
||||||
&& Number.isFinite(longitude)
|
|
||||||
&& Number.isFinite(latitude)
|
|
||||||
&& longitude !== 0
|
|
||||||
&& latitude !== 0
|
|
||||||
&& longitude >= -180
|
|
||||||
&& longitude <= 180
|
|
||||||
&& latitude >= -90
|
|
||||||
&& latitude <= 90
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNonEmptyString(value: unknown): value is string {
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
return typeof value === 'string' && value.trim().length > 0
|
return typeof value === 'string' && value.trim().length > 0
|
||||||
}
|
}
|
||||||
@@ -221,7 +194,14 @@ function validateOpeningHours(openingHours: PoiOpeningHours, poiId: string, issu
|
|||||||
function validateCoordinates(poi: Poi, issues: PoiValidationIssue[]): void {
|
function validateCoordinates(poi: Poi, issues: PoiValidationIssue[]): void {
|
||||||
const { longitude, latitude } = poi
|
const { longitude, latitude } = poi
|
||||||
const id = isNonEmptyString(poi.id) ? poi.id : 'invalid_poi'
|
const id = isNonEmptyString(poi.id) ? poi.id : 'invalid_poi'
|
||||||
if (!isTrustworthyCoordinate(longitude, latitude)) {
|
if (!Number.isFinite(longitude)
|
||||||
|
|| !Number.isFinite(latitude)
|
||||||
|
|| longitude < -180
|
||||||
|
|| longitude > 180
|
||||||
|
|| latitude < -90
|
||||||
|
|| latitude > 90
|
||||||
|
|| longitude === 0
|
||||||
|
|| latitude === 0) {
|
||||||
addIssue(issues, 'COORDINATE_INVALID', `pois.${id}.coordinates`, '经纬度必须为非零的有限合法数值', id)
|
addIssue(issues, 'COORDINATE_INVALID', `pois.${id}.coordinates`, '经纬度必须为非零的有限合法数值', id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -229,23 +209,12 @@ function validateCoordinates(poi: Poi, issues: PoiValidationIssue[]): void {
|
|||||||
if (poi.coordinateSystem !== 'GCJ02')
|
if (poi.coordinateSystem !== 'GCJ02')
|
||||||
addIssue(issues, 'COORDINATE_SYSTEM_INVALID', `pois.${id}.coordinateSystem`, '本期坐标系必须为 GCJ02', id)
|
addIssue(issues, 'COORDINATE_SYSTEM_INVALID', `pois.${id}.coordinateSystem`, '本期坐标系必须为 GCJ02', id)
|
||||||
|
|
||||||
if (!isWithinGuangmingArea(longitude, latitude))
|
if (longitude < GUANGMING_POC_BOUNDS.minLongitude
|
||||||
|
|| longitude > GUANGMING_POC_BOUNDS.maxLongitude
|
||||||
|
|| latitude < GUANGMING_POC_BOUNDS.minLatitude
|
||||||
|
|| latitude > GUANGMING_POC_BOUNDS.maxLatitude) {
|
||||||
addIssue(issues, 'OUTSIDE_TARGET_AREA', `pois.${id}.coordinates`, '坐标超出光明区 POC 粗校验包络,需内容负责人复核', id)
|
addIssue(issues, 'OUTSIDE_TARGET_AREA', `pois.${id}.coordinates`, '坐标超出光明区 POC 粗校验包络,需内容负责人复核', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 坐标是否落在光明区 POC 粗校验包络内。
|
|
||||||
*
|
|
||||||
* 这是一个宽松的矩形包络,不是行政边界,也**不该**用来拒绝真实定位 ——
|
|
||||||
* 用户站在包络外面是完全正常的。它只回答一个问题:把地图移到这个坐标,
|
|
||||||
* 画面里还看得见本期点位吗?用途有两个:数据入库时的内容审核告警,
|
|
||||||
* 以及开屏定位时判断该不该把视野从聚类中心移到用户身上。
|
|
||||||
*/
|
|
||||||
export function isWithinGuangmingArea(longitude: number, latitude: number): boolean {
|
|
||||||
return longitude >= GUANGMING_POC_BOUNDS.minLongitude
|
|
||||||
&& longitude <= GUANGMING_POC_BOUNDS.maxLongitude
|
|
||||||
&& latitude >= GUANGMING_POC_BOUNDS.minLatitude
|
|
||||||
&& latitude <= GUANGMING_POC_BOUNDS.maxLatitude
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateDistanceMeters(left: Poi, right: Poi): number {
|
function calculateDistanceMeters(left: Poi, right: Poi): number {
|
||||||
|
|||||||
@@ -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(
|
function selectQuickPois(
|
||||||
preferences: TravelPreferences,
|
preferences: TravelPreferences,
|
||||||
repository: PoiRepository,
|
repository: PoiRepository,
|
||||||
@@ -263,28 +238,13 @@ function selectQuickPois(
|
|||||||
const selected: PoiResolved[] = []
|
const selected: PoiResolved[] = []
|
||||||
let usedMinutes = 0
|
let usedMinutes = 0
|
||||||
let cursor: PlanningOrigin | PoiResolved | null = origin
|
let cursor: PlanningOrigin | PoiResolved | null = origin
|
||||||
const minimumTargetCount = Math.min(remaining.length, requestedMinutes >= 240 ? 3 : 1)
|
|
||||||
|
|
||||||
while (remaining.length > 0 && selected.length < 8) {
|
while (remaining.length > 0 && selected.length < 8) {
|
||||||
const ranked = rankedCandidates(remaining, preferences, selected, cursor)
|
const ranked = rankedCandidates(remaining, preferences, selected, cursor)
|
||||||
const fitting = ranked.filter((poi) => {
|
const next = ranked.find((poi) => {
|
||||||
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
|
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
|
||||||
return usedMinutes + transfer + activityMinutes(poi, preferences.pace) <= requestedMinutes
|
return usedMinutes + transfer + activityMinutes(poi, preferences.pace) <= requestedMinutes
|
||||||
})
|
})
|
||||||
const next = fitting.find((poi) => {
|
|
||||||
const transfer = cursor ? transferMinutes(cursor, poi, preferences) : 0
|
|
||||||
const requiredMinutes = transfer + activityMinutes(poi, preferences.pace)
|
|
||||||
const stopsNeeded = minimumTargetCount - selected.length - 1
|
|
||||||
if (stopsNeeded <= 0)
|
|
||||||
return true
|
|
||||||
return canCompleteMinimumRoute(
|
|
||||||
remaining.filter(candidate => candidate.id !== poi.id),
|
|
||||||
preferences,
|
|
||||||
poi,
|
|
||||||
requestedMinutes - usedMinutes - requiredMinutes,
|
|
||||||
stopsNeeded,
|
|
||||||
)
|
|
||||||
}) ?? fitting[0]
|
|
||||||
if (!next)
|
if (!next)
|
||||||
break
|
break
|
||||||
const transfer = cursor ? transferMinutes(cursor, next, preferences) : 0
|
const transfer = cursor ? transferMinutes(cursor, next, preferences) : 0
|
||||||
@@ -294,12 +254,8 @@ function selectQuickPois(
|
|||||||
cursor = next
|
cursor = next
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.length === 0 && remaining.length > 0) {
|
if (selected.length === 0 && remaining.length > 0)
|
||||||
throw new TravelValidationError(
|
selected.push(rankedCandidates(remaining, preferences, [], origin)[0])
|
||||||
'当前起点、交通方式与所选时长无法容纳首个点位',
|
|
||||||
'quick_route_unreachable',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import {
|
|||||||
|
|
||||||
const DESTINATION = '深圳市光明区' as const
|
const DESTINATION = '深圳市光明区' as const
|
||||||
|
|
||||||
export type TravelValidationErrorCode = 'quick_route_unreachable'
|
|
||||||
|
|
||||||
export class TravelValidationError extends Error {
|
export class TravelValidationError extends Error {
|
||||||
constructor(message: string, readonly code?: TravelValidationErrorCode) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'TravelValidationError'
|
this.name = 'TravelValidationError'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "光明文旅地图",
|
|
||||||
"appid": "",
|
|
||||||
"description": "深圳市光明区全域文旅地图、POI 数字化展示、本地行程规划与到点打卡 POC",
|
|
||||||
"versionName": "0.3.0",
|
|
||||||
"versionCode": "30",
|
|
||||||
"transformPx": false,
|
|
||||||
"app-plus": {
|
|
||||||
"usingComponents": true,
|
|
||||||
"nvueStyleCompiler": "uni-app",
|
|
||||||
"compilerVersion": 3,
|
|
||||||
"splashscreen": {
|
|
||||||
"alwaysShowBeforeRender": true,
|
|
||||||
"waiting": true,
|
|
||||||
"autoclose": true,
|
|
||||||
"delay": 0
|
|
||||||
},
|
|
||||||
"modules": {},
|
|
||||||
"distribute": {
|
|
||||||
"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": {},
|
|
||||||
"sdkConfigs": {},
|
|
||||||
"icons": {
|
|
||||||
"android": {},
|
|
||||||
"ios": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"quickapp": {},
|
|
||||||
"mp-weixin": {
|
|
||||||
"appid": "wxda4fb5048b5dc9a1",
|
|
||||||
"setting": {
|
|
||||||
"urlCheck": true
|
|
||||||
},
|
|
||||||
"usingComponents": true,
|
|
||||||
"lazyCodeLoading": "requiredComponents",
|
|
||||||
"permission": {
|
|
||||||
"scope.userLocation": {
|
|
||||||
"desc": "用于地图定位、行程优化和到点打卡判断"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"requiredPrivateInfos": [
|
|
||||||
"getLocation",
|
|
||||||
"startLocationUpdate",
|
|
||||||
"onLocationChange"
|
|
||||||
],
|
|
||||||
"projectname": "【dev】光明文旅地图"
|
|
||||||
},
|
|
||||||
"mp-alipay": {
|
|
||||||
"usingComponents": true
|
|
||||||
},
|
|
||||||
"mp-baidu": {
|
|
||||||
"usingComponents": true
|
|
||||||
},
|
|
||||||
"mp-toutiao": {
|
|
||||||
"usingComponents": true
|
|
||||||
},
|
|
||||||
"uniStatistics": {
|
|
||||||
"enable": false
|
|
||||||
},
|
|
||||||
"vueVersion": "3"
|
|
||||||
}
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { PoiImageAsset, PoiResolved } from '@/domain/poi'
|
|
||||||
import PageState from '@/components/poi/PageState.vue'
|
|
||||||
import PoiHeroGallery from '@/components/poi/PoiHeroGallery.vue'
|
|
||||||
import PoiTagList from '@/components/poi/PoiTagList.vue'
|
|
||||||
import { getPoiRepository } from '@/data/poi'
|
|
||||||
import { coverPhotoFullUrlFor } from '@/data/poi/photos'
|
|
||||||
import { formatRecommendationLabel } from '@/domain/poi'
|
|
||||||
import { getCheckInProfile } from '@/services/check-in'
|
|
||||||
|
|
||||||
const state = ref<'loading' | 'ready' | 'invalid' | 'error'>('loading')
|
|
||||||
const poi = ref<PoiResolved | null>(null)
|
|
||||||
const hasCheckedIn = ref(false)
|
|
||||||
const checkInStorageError = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* hero 用分包大图(800×600)替换主包缩略图(400×300)。
|
|
||||||
*
|
|
||||||
* 本页在 pages-poi 分包内,能引用分包 static 资源;主包页面不行。
|
|
||||||
* 占位图仍指向主包,不做替换。
|
|
||||||
*/
|
|
||||||
const heroImages = computed<PoiImageAsset[]>(() => {
|
|
||||||
const current = poi.value
|
|
||||||
if (!current)
|
|
||||||
return []
|
|
||||||
|
|
||||||
return current.images.map(image => (
|
|
||||||
image.id === current.coverImageId && !image.isPlaceholder
|
|
||||||
? { ...image, url: coverPhotoFullUrlFor(current.id) }
|
|
||||||
: image
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
function loadPoi(poiId: string) {
|
|
||||||
state.value = 'loading'
|
|
||||||
poi.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!poiId.trim()) {
|
|
||||||
state.value = 'invalid'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = getPoiRepository().getPoiById(poiId)
|
|
||||||
if (!result) {
|
|
||||||
state.value = 'invalid'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
poi.value = result
|
|
||||||
try {
|
|
||||||
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === result.id)
|
|
||||||
checkInStorageError.value = false
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Failed to load POI check-in state', error)
|
|
||||||
hasCheckedIn.value = false
|
|
||||||
checkInStorageError.value = true
|
|
||||||
}
|
|
||||||
state.value = 'ready'
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Failed to load POI detail', error)
|
|
||||||
state.value = 'error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCheckIn() {
|
|
||||||
if (!poi.value)
|
|
||||||
return
|
|
||||||
uni.navigateTo({ url: `/pages/check-in/index?poiId=${encodeURIComponent(poi.value.id)}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCheckInRecords() {
|
|
||||||
uni.navigateTo({ url: '/pages/check-in/records' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function returnToMap() {
|
|
||||||
uni.navigateBack({
|
|
||||||
fail: () => {
|
|
||||||
uni.reLaunch({ url: '/pages/map/index' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoordinate(value: number): string {
|
|
||||||
return value.toFixed(6)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUpdatedAt(value: string): string {
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime()))
|
|
||||||
return '待确认'
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad((query) => {
|
|
||||||
const rawPoiId = typeof query?.poiId === 'string' ? query.poiId : ''
|
|
||||||
try {
|
|
||||||
loadPoi(decodeURIComponent(rawPoiId))
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
state.value = 'invalid'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onShow(() => {
|
|
||||||
if (!poi.value)
|
|
||||||
return
|
|
||||||
try {
|
|
||||||
hasCheckedIn.value = getCheckInProfile().checkIns.some(record => record.poiId === poi.value?.id)
|
|
||||||
checkInStorageError.value = false
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Failed to refresh POI check-in state', error)
|
|
||||||
hasCheckedIn.value = false
|
|
||||||
checkInStorageError.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<view class="poi-detail-page">
|
|
||||||
<PageState v-if="state === 'loading'" state="loading" title="正在加载点位信息…" />
|
|
||||||
<PageState
|
|
||||||
v-else-if="state === 'invalid'"
|
|
||||||
state="empty"
|
|
||||||
title="点位不存在或已下线"
|
|
||||||
description="该点位可能已更新,请返回地图重新选择"
|
|
||||||
action-label="返回地图"
|
|
||||||
@action="returnToMap"
|
|
||||||
/>
|
|
||||||
<PageState
|
|
||||||
v-else-if="state === 'error'"
|
|
||||||
state="error"
|
|
||||||
title="点位信息加载失败"
|
|
||||||
description="暂时无法读取该点位,请稍后返回重试"
|
|
||||||
action-label="返回地图"
|
|
||||||
@action="returnToMap"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-else-if="poi">
|
|
||||||
<PoiHeroGallery :images="heroImages" :name="poi.name" />
|
|
||||||
|
|
||||||
<view class="poi-detail-page__content">
|
|
||||||
<view class="poi-detail-page__header">
|
|
||||||
<text class="poi-detail-page__category">{{ poi.category.name }}</text>
|
|
||||||
<text class="poi-detail-page__name">{{ poi.name }}</text>
|
|
||||||
<view class="poi-detail-page__recommendation">
|
|
||||||
{{ formatRecommendationLabel(poi.recommendationSource, poi.recommendationIndex) }}
|
|
||||||
</view>
|
|
||||||
<PoiTagList class="poi-detail-page__tags" :tags="poi.tags" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="check-in-entry" :class="{ 'check-in-entry--done': hasCheckedIn }">
|
|
||||||
<view class="check-in-entry__mark">{{ hasCheckedIn ? '✓' : '◎' }}</view>
|
|
||||||
<view class="check-in-entry__copy">
|
|
||||||
<text class="check-in-entry__title">{{ checkInStorageError ? '打卡数据需要处理' : hasCheckedIn ? '已留下光明足迹' : '到点打卡' }}</text>
|
|
||||||
<text>{{ checkInStorageError ? '本机打卡数据异常,请先处理' : hasCheckedIn ? '该点位已在本机完成打卡' : '到达点位附近后,使用本次真实定位打卡' }}</text>
|
|
||||||
</view>
|
|
||||||
<button @click="checkInStorageError ? openCheckInRecords() : openCheckIn()">{{ checkInStorageError ? '去处理' : hasCheckedIn ? '查看' : '去打卡' }}</button>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="info-card">
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-row__label">开放时间</text>
|
|
||||||
<text class="info-row__value">{{ poi.openingHoursText }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-row__label">地址</text>
|
|
||||||
<text class="info-row__value">{{ poi.address }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row info-row--coordinates">
|
|
||||||
<text class="info-row__label">经纬度</text>
|
|
||||||
<view class="info-row__value info-row__coordinates">
|
|
||||||
<text>经度 {{ formatCoordinate(poi.longitude) }}</text>
|
|
||||||
<text>纬度 {{ formatCoordinate(poi.latitude) }}</text>
|
|
||||||
<text class="info-row__coordinate-system">坐标系 {{ poi.coordinateSystem }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-row__label">数据更新</text>
|
|
||||||
<text class="info-row__value">{{ formatUpdatedAt(poi.updatedAt) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="description-section">
|
|
||||||
<text class="description-section__title">点位简介</text>
|
|
||||||
<text class="description-section__body">{{ poi.description }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="poc-notice">
|
|
||||||
<text class="poc-notice__title">POC 数据说明</text>
|
|
||||||
<text class="poc-notice__body">当前内容用于产品和技术可行性验证,正式开放信息请以后续审核版本为准。</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<route lang="yaml">
|
|
||||||
layout: map
|
|
||||||
style:
|
|
||||||
navigationBarTitleText: 点位详情
|
|
||||||
navigationBarBackgroundColor: '#ffffff'
|
|
||||||
navigationBarTextStyle: black
|
|
||||||
backgroundColor: '#f5f7f6'
|
|
||||||
</route>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.poi-detail-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f5f7f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poi-detail-page__content {
|
|
||||||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
.poi-detail-page__header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poi-detail-page__category {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 8rpx 18rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 34rpx;
|
|
||||||
color: #0e6247;
|
|
||||||
background: #e8f4ee;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poi-detail-page__name {
|
|
||||||
margin-top: 16rpx;
|
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 56rpx;
|
|
||||||
color: #18201d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poi-detail-page__recommendation {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 8rpx 16rpx;
|
|
||||||
margin-top: 16rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 38rpx;
|
|
||||||
color: #934b00;
|
|
||||||
background: #fff3e0;
|
|
||||||
border-radius: 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poi-detail-page__tags {
|
|
||||||
margin-top: 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card,
|
|
||||||
.check-in-entry,
|
|
||||||
.description-section,
|
|
||||||
.poc-notice {
|
|
||||||
padding: 28rpx;
|
|
||||||
margin-top: 24rpx;
|
|
||||||
background: #fff;
|
|
||||||
border: 1rpx solid #dde5e1;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-in-entry {
|
|
||||||
display: flex;
|
|
||||||
gap: 18rpx;
|
|
||||||
align-items: center;
|
|
||||||
padding: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-in-entry--done {
|
|
||||||
background: #e8f4ee;
|
|
||||||
border-color: #cfe4d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-in-entry__mark {
|
|
||||||
display: flex;
|
|
||||||
flex: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 62rpx;
|
|
||||||
height: 62rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #fff;
|
|
||||||
background: #167a5b;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-in-entry__copy {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 21rpx;
|
|
||||||
line-height: 32rpx;
|
|
||||||
color: #5e6b65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-in-entry__title {
|
|
||||||
margin-bottom: 3rpx;
|
|
||||||
font-size: 27rpx;
|
|
||||||
font-weight: 750;
|
|
||||||
color: #18201d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-in-entry button {
|
|
||||||
flex: none;
|
|
||||||
width: auto;
|
|
||||||
height: 60rpx;
|
|
||||||
padding: 0 22rpx;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 23rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 60rpx;
|
|
||||||
color: #fff;
|
|
||||||
background: #167a5b;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 30rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-in-entry button::after {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 24rpx;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 20rpx 0;
|
|
||||||
border-bottom: 1rpx solid #edf1ef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:first-child {
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row:last-child {
|
|
||||||
padding-bottom: 0;
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row__label {
|
|
||||||
flex: none;
|
|
||||||
width: 128rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 40rpx;
|
|
||||||
color: #5e6b65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row__value {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 28rpx;
|
|
||||||
line-height: 40rpx;
|
|
||||||
color: #18201d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row__coordinates {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row__coordinate-system {
|
|
||||||
margin-top: 6rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #8a9690;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-section__title,
|
|
||||||
.poc-notice__title {
|
|
||||||
display: block;
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 42rpx;
|
|
||||||
color: #18201d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-section__body,
|
|
||||||
.poc-notice__body {
|
|
||||||
display: block;
|
|
||||||
margin-top: 16rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
line-height: 44rpx;
|
|
||||||
color: #425049;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poc-notice {
|
|
||||||
background: #e8f4ee;
|
|
||||||
border-color: #cfe4d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poc-notice__title {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #0e6247;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poc-notice__body {
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 38rpx;
|
|
||||||
color: #3d5a4d;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 54 KiB |