forked from zhouruizhe/gmTouringMiniApp
Initial commit: gmTouringMiniApp project
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# 当前 POC 使用随包 POI 数据,不配置远程业务 API。
|
||||||
|
# 微信 AppID 请写入被忽略的 .env.local,参考 .env.example。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# 在开发时进行加载
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Actual WeChat Mini Program AppID. Copy to .env.local and keep it uncommitted.
|
||||||
|
UNI_MP_WEIXIN_APPID=
|
||||||
|
|
||||||
|
# The client uses a frozen local GCJ-02 snapshot and never exposes an AMap key.
|
||||||
|
# Pass AMAP_WEB_SERVICE_KEY only to the one-off fetch script's process; do not store it here.
|
||||||
|
|
||||||
|
# Optional controlled backend endpoint for remote AI itinerary generation.
|
||||||
|
# Leave empty to use the deterministic local POC planner. Never put API keys or model credentials here.
|
||||||
|
VITE_TRAVEL_ASSISTANT_API_BASE_URL=
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# 在生产时进行加载
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
0
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
project.private.config.json
|
||||||
|
!dist/
|
||||||
|
dist/*
|
||||||
|
!dist/dev/
|
||||||
|
dist/dev/*
|
||||||
|
!dist/dev/mp-weixin/
|
||||||
|
dist/dev/mp-weixin/*
|
||||||
|
!dist/dev/mp-weixin/README.generated.json
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Travel assistant backend secrets and Python artifacts
|
||||||
|
server/.env
|
||||||
|
server/.venv/
|
||||||
|
server/**/__pycache__/
|
||||||
|
server/.pytest_cache/
|
||||||
|
server/*.egg-info/
|
||||||
|
server/**/*.pyc
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# cache
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"*.{js,ts,vue}": "eslint --cache --fix",
|
||||||
|
"*.{css,scss,vue}": "stylelint --cache --fix"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
strict-peer-dependencies=false
|
||||||
|
auto-install-peers=true
|
||||||
|
shamefully-hoist=true
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: [
|
||||||
|
'stylelint-config-standard-scss',
|
||||||
|
'stylelint-config-standard-vue/scss',
|
||||||
|
'stylelint-config-recess-order',
|
||||||
|
'@stylistic/stylelint-config',
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
'stylelint-scss',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'@stylistic/max-line-length': null,
|
||||||
|
'scss/at-extend-no-missing-placeholder': null,
|
||||||
|
'no-empty-source': null,
|
||||||
|
'selector-type-no-unknown': null,
|
||||||
|
'custom-property-pattern': null,
|
||||||
|
'no-descending-specificity': null,
|
||||||
|
'unit-no-unknown': [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
ignoreUnits: ['rpx'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'declaration-property-value-no-unknown': [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
ignoreProperties: {
|
||||||
|
'/.*/': /-?\d+(?:\.\d+)?rpx(?:\s|$)/,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'selector-pseudo-element-no-unknown': [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
ignorePseudoElements: ['v-deep'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'selector-pseudo-class-no-unknown': [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
ignorePseudoClasses: ['deep'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'scss/no-global-function-names': null,
|
||||||
|
'selector-class-pattern': null,
|
||||||
|
},
|
||||||
|
allowEmptyInput: true,
|
||||||
|
ignoreFiles: [
|
||||||
|
'node_modules/**/*',
|
||||||
|
'**/*.js',
|
||||||
|
'**/*.jsx',
|
||||||
|
'**/*.tsx',
|
||||||
|
'**/*.ts',
|
||||||
|
'**/*.json',
|
||||||
|
'**/*.md',
|
||||||
|
'**/*.yaml',
|
||||||
|
'dist/**/*',
|
||||||
|
'LICENSE',
|
||||||
|
],
|
||||||
|
}
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"antfu.goto-alias",
|
||||||
|
"csstools.postcss",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"stylelint.vscode-stylelint",
|
||||||
|
"vue.volar",
|
||||||
|
"streetsidesoftware.code-spell-checker"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+64
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": ["Vitesse", "Vite", "unocss", "vitest", "vueuse", "pinia", "demi", "antfu", "iconify", "intlify", "vitejs", "unplugin", "pnpm", "composables"],
|
||||||
|
|
||||||
|
// Enable the ESlint flat config support
|
||||||
|
"eslint.experimental.useFlatConfig": true,
|
||||||
|
|
||||||
|
// Disable the default formatter
|
||||||
|
"prettier.enable": false,
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
|
||||||
|
"stylelint.validate": [
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"vue"
|
||||||
|
],
|
||||||
|
// Auto fix
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "never",
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.fixAll.stylelint": "explicit",
|
||||||
|
"source.organizeImports": "never"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||||
|
"eslint.rules.customizations": [
|
||||||
|
{ "rule": "style/*", "severity": "off" },
|
||||||
|
{ "rule": "format/*", "severity": "off" },
|
||||||
|
{ "rule": "*-indent", "severity": "off" },
|
||||||
|
{ "rule": "*-spacing", "severity": "off" },
|
||||||
|
{ "rule": "*-spaces", "severity": "off" },
|
||||||
|
{ "rule": "*-order", "severity": "off" },
|
||||||
|
{ "rule": "*-dangle", "severity": "off" },
|
||||||
|
{ "rule": "*-newline", "severity": "off" },
|
||||||
|
{ "rule": "*quotes", "severity": "off" },
|
||||||
|
{ "rule": "*semi", "severity": "off" }
|
||||||
|
],
|
||||||
|
|
||||||
|
// The following is optional.
|
||||||
|
// It's better to put under project setting `.vscode/settings.json`
|
||||||
|
// to avoid conflicts with working with different eslint configs
|
||||||
|
// that does not support all formats.
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact",
|
||||||
|
"vue",
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"yaml",
|
||||||
|
"toml",
|
||||||
|
"xml",
|
||||||
|
"gql",
|
||||||
|
"graphql",
|
||||||
|
"astro",
|
||||||
|
"css",
|
||||||
|
"less",
|
||||||
|
"scss",
|
||||||
|
"pcss",
|
||||||
|
"postcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Sun P.M.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# 光明文旅地图 POC
|
||||||
|
|
||||||
|
基于 uni-app、Vue 3、TypeScript 和 Pinia 开发的微信小程序 POC,用于验证深圳市光明区文旅资源地图探索、POI 数字化展示、文旅问答式偏好收集和行程规划。
|
||||||
|
|
||||||
|
当前范围包括地图拖动缩放、POI 分类、marker 与摘要联动、用户主动授权后的前台实时定位、POI 详情、AI 文旅助手交互和推荐行程展示。POC 默认使用随包数据与本地确定性规则生成游览顺序;有定位快照时会优先安排较近首站,但不是道路级实时导航,也不包含打卡、登录或个人中心。
|
||||||
|
|
||||||
|
## 微信开发者工具
|
||||||
|
|
||||||
|
本仓库是 uni-app 源码工程,微信开发者工具不能直接编译 `src/**/*.vue`。正确流程是由 Vite/uni-app 持续编译,再由微信开发者工具加载编译产物:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
corepack pnpm install --frozen-lockfile
|
||||||
|
corepack pnpm dev:mp-weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
保持最后一条命令运行,然后在微信开发者工具中选择“导入项目”,目录选择本仓库根目录。根目录的 `project.config.json` 已设置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
miniprogramRoot = dist/dev/mp-weixin/
|
||||||
|
```
|
||||||
|
|
||||||
|
因此 IDE 会识别 `dist/dev/mp-weixin/app.json` 及页面产物。第一次导入前必须等终端显示编译完成;真正生成 `app.json`、WXML 和 WXSS 的是持续运行的 `dev:mp-weixin`。`prepare:mp-weixin` 仅供需要预先创建空目录的工具使用,不能替代编译。
|
||||||
|
|
||||||
|
微信构建脚本已内置 macOS 文件监听兼容处理;如系统会话的监听句柄上限较低,不需要额外手动设置即可避免 `EMFILE` 编译失败。
|
||||||
|
|
||||||
|
macOS 已安装微信开发者工具时,也可在另一个终端执行 `corepack pnpm ide:mp-weixin` 打开根目录项目。
|
||||||
|
|
||||||
|
正式构建使用:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
corepack pnpm build:mp-weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
开发编译后可运行 `corepack pnpm verify:mp-weixin`,检查微信工程配置、启动页、五个业务页面、三个 tabBar 入口、六个图标和页面四件套是否完整。
|
||||||
|
|
||||||
|
生产产物位于 `dist/build/mp-weixin`。微信 AppID 通过未提交的 `.env.local` 中 `UNI_MP_WEIXIN_APPID` 配置;根目录 IDE 配置暂用 `touristappid`,避免误用模板中的其他项目身份。游客身份只能本地编译,真机预览或上传前必须同时把 `project.config.json` 的 `appid` 换成项目方确认的真实 AppID。详细说明见 [微信小程序构建与验收说明](./docs/微信小程序构建与验收说明.md)。
|
||||||
|
|
||||||
|
生产验收时可直接导入 `dist/build/mp-weixin`,macOS 也可运行 `corepack pnpm ide:mp-weixin:build`。根目录始终用于开发模式,不会因执行生产构建而自动切换到 `dist/build/mp-weixin`。
|
||||||
|
|
||||||
|
## 业务目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── pages/map/index.vue # 小程序首页:全域地图
|
||||||
|
├── pages/assistant/index.vue # AI 文旅助手:偏好快捷入口
|
||||||
|
├── pages/planner/index.vue # 行程偏好表单
|
||||||
|
├── pages/itinerary/index.vue # 推荐路线与地图展示
|
||||||
|
├── pages/poi/detail.vue # POI 详情
|
||||||
|
├── components/map/ # 地图筛选和摘要组件
|
||||||
|
├── components/poi/ # POI 图片、标签、状态和图集组件
|
||||||
|
├── components/travel/ # 行程点位展示组件
|
||||||
|
├── data/poi/ # POC 数据包与仓储
|
||||||
|
├── domain/poi/ # POI 类型、校验和选择器
|
||||||
|
├── domain/travel/ # 偏好、行程模型与本地规划规则
|
||||||
|
├── services/map/ # marker 数字 ID 适配
|
||||||
|
├── services/travel-assistant/ # 本地会话存储与可选远程服务适配
|
||||||
|
├── stores/modules/map.ts # 地图会话状态
|
||||||
|
└── static/ # 随包静态资源
|
||||||
|
```
|
||||||
|
|
||||||
|
不配置 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 时,小程序始终使用本地确定性 POC 规划器。若后续接入远程 AI,只允许在 `.env.local` 配置受控后端地址,并在微信公众平台配置合法请求域名;API Key、模型凭证和高德 Web Service Key 均不得进入客户端环境变量或构建产物。
|
||||||
|
|
||||||
|
实时位置仅在用户点击地图中的“定位到我”后启用,并只在地图页处于前台时更新。精确坐标保留在当前内存会话中,不写入匿名偏好或路线缓存,也不会发送给可选远程 AI 服务。iOS 真机使用前,需通过真实 AppID 在微信公众平台开通定位接口并完善《用户隐私保护指引》。
|
||||||
|
|
||||||
|
路由来自页面 SFC 的 `<route>`,由 UniPages 在编译时生成 `src/pages.json`;应用清单由 `manifest.config.ts` 生成 `src/manifest.json`。不要直接修改这两个生成文件,也不要让微信开发者工具读取 `src`;IDE 只读取 `dist/dev/mp-weixin`。
|
||||||
|
|
||||||
|
## POC 数据声明
|
||||||
|
|
||||||
|
当前随包 POI 是从 2026-07-30 高德 Web Service 离线候选快照中精选的 30 个 POC 点位,覆盖文化场馆、公园湿地、田园休闲、人文地标和绿道户外。高德只参与开发期数据发现,微信客户端读取本地冻结数据,不携带或实时调用 Web Service Key。
|
||||||
|
|
||||||
|
这些点位仍不代表光明区全域权威清单。简介和推荐指数为明确标注的 POC 待审核内容,开放时间统一待确认,封面为开发占位素材,均不满足正式内容或图片验收。
|
||||||
|
|
||||||
|
如需重新采集候选,请在本机临时注入 Web Service Key,输出默认写入系统临时目录:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
AMAP_WEB_SERVICE_KEY=... corepack pnpm fetch:amap-pois
|
||||||
|
corepack pnpm test:amap-fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
Key 不得写入源码、环境模板、文档、构建产物或日志;采集结果必须经过光明区过滤、去重和人工内容审核后才能固化。采集统计、字段映射与发布门槛见 [高德 POI 离线采集与数据快照说明](./docs/高德POI离线采集与数据快照说明.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
以下保留原模板的技术说明。
|
||||||
|
|
||||||
|
## 衍生项目
|
||||||
|
由于这个模板的业务场景非常的局限,下面提供了一个精心策划的列表。当然也欢迎你 PR 提供自己的项目!
|
||||||
|
|
||||||
|
###### 官方
|
||||||
|
|
||||||
|
- [unisave-lite](https://github.com/sunpm/unisave-lite) - unisave 的 js 版本
|
||||||
|
|
||||||
|
## 平台兼容性
|
||||||
|
在技术考量上,优先同时支持下列的平台,为兼容多个平台而舍弃一些实用的依赖插件。如发现下列平台环境开发编译出现问题,欢迎提 [issue](https://github.com/sunpm/unisave/issues/new) or [pr](https://github.com/sunpm/unisave/pulls)
|
||||||
|
|
||||||
|
| H5 | IOS | 安卓 | 微信小程序 | 字节小程序 | 快手小程序 | 支付宝小程序 | 百度小程序 |
|
||||||
|
|:--:| :--: | :--: | :--------: | :--------: | :--------: | :----------: | :----------: |
|
||||||
|
| √ | √ | √ | √ | √ | √ | √ | √ |
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
- 🗂 [基于文件的路由](./src/pages)
|
||||||
|
|
||||||
|
- 📦 [组件自动化加载](./src/components)
|
||||||
|
|
||||||
|
- 🍍 [使用 Pinia 的状态管理](https://github.com/vuejs/pinia)
|
||||||
|
|
||||||
|
- 📑 [布局系统](./src/layouts)
|
||||||
|
|
||||||
|
- 🔥 使用 [新的 `<script setup>` 语法](https://github.com/vuejs/rfcs/pull/227)
|
||||||
|
|
||||||
|
- 📥 [API 自动加载](https://github.com/unplugin/unplugin-auto-import) - 直接使用 Composition API 无需引入
|
||||||
|
|
||||||
|
- 🎨 [UnoCSS](https://github.com/unocss/unocss) - 高性能且极具灵活性的即时原子化 CSS 引擎
|
||||||
|
|
||||||
|
- 🦾 TypeScript, 为什么不呢
|
||||||
|
|
||||||
|
- ⚙️ 使用 [Vitest](https://github.com/vitest-dev/vitest) 进行单元测试
|
||||||
|
|
||||||
|
## 预配置
|
||||||
|
|
||||||
|
### UI 框架
|
||||||
|
- [uview-plus](https://github.com/ijry/uview-plus) uview-plus3.0是基于uView2.x修改的vue3版本
|
||||||
|
- [UnoCSS](https://github.com/unocss/unocss) 高性能且极具灵活性的即时原子化 CSS 引擎
|
||||||
|
- [unocss-preset-uni](https://github.com/uni-helper/unocss-preset-uni) 专为 uni-app 打造的 UnoCSS 预设
|
||||||
|
|
||||||
|
### 插件
|
||||||
|
- [Pinia](https://github.com/vuejs/pinia) - 直接的, 类型安全的, 使用 Composition API 的轻便灵活的 Vue 状态管理
|
||||||
|
- [`pinia-plugin-persist-uni`](https://github.com/Allen-1998/pinia-plugin-persist-uni) - pinia 在 uniapp 中数据持久化插件
|
||||||
|
- Router
|
||||||
|
- [`@uni-helper/vite-plugin-uni-pages`](https://github.com/uni-helper/vite-plugin-uni-pages) - 在 Vite 驱动的 uni-app 上使用基于文件的路由系统
|
||||||
|
- [`vite-plugin-vue-layouts`](https://github.com/uni-helper/vite-plugin-uni-layouts) - 页面布局系统
|
||||||
|
- [`@uni-helper/uni-use`](https://github.com/uni-helper/uni-use) - 使用 `useRouter` 封装路由方法 -> `src/composables/useNavigation.ts`
|
||||||
|
- 请求
|
||||||
|
- [`@uni-helper/uni-network`](https://github.com/uni-helper/uni-network) - 为 uni-app 打造的基于 Promise 的 HTTP 客户端
|
||||||
|
- `services` 目录封装通用请求
|
||||||
|
- [`unplugin-vue-components`](https://github.com/antfu/unplugin-vue-components) - 自动加载组件
|
||||||
|
- [`unplugin-auto-import`](https://github.com/antfu/unplugin-auto-import) - 直接使用 Composition API 等,无需导入
|
||||||
|
- [`@uni-helper/vite-plugin-uni-manifest`](https://github.com/uni-helper/vite-plugin-uni-manifest) - 使用 TypeScript 编写 `uni-app` 的 `manifest.json`。
|
||||||
|
|
||||||
|
### 编码风格
|
||||||
|
|
||||||
|
- 使用 Composition API 地 [`<script setup>` SFC 语法](https://cn.vuejs.org/api/sfc-script-setup.html)
|
||||||
|
- [ESLint](https://github.com/eslint/eslint) 配置为 [@antfu/eslint-config](https://github.com/antfu/eslint-config) - 单引号, 无分号...
|
||||||
|
- [@unocss/eslint-config](https://unocss.dev/integrations/eslint) - 用于UnoCSS的ESLint配置
|
||||||
|
- [@uni-helper/eslint-config](https://github.com/uni-helper/eslint-config) - 适用于 uni-app 的 Anthony's ESLint 配置预设
|
||||||
|
|
||||||
|
### 各平台类型定义文件
|
||||||
|
- [x] [uni-app 组件](https://www.npmjs.com/package/@uni-helper/uni-app-types)
|
||||||
|
- [x] [微信小程序](https://www.npmjs.com/package/miniprogram-api-typings)
|
||||||
|
- [x] [支付宝小程序](https://www.npmjs.com/package/@mini-types/alipay)
|
||||||
|
- [x] [字节小程序](https://www.npmjs.com/package/@douyin-microapp/typings)
|
||||||
|
- [x] [快手小程序](https://www.npmjs.com/package/ks-miniprogram-types/global)
|
||||||
|
- [x] [百度小程序](https://www.npmjs.com/package/@types/baidu-app)
|
||||||
|
|
||||||
|
## 环境建议
|
||||||
|
|
||||||
|
**Node >= 18**
|
||||||
|
|
||||||
|
**pnpm >= 8**
|
||||||
|
|
||||||
|
## 使用该模版
|
||||||
|
```sh
|
||||||
|
npx degit sunpm/unisave#main my-unisave
|
||||||
|
cd my-unisave
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
如果你没装过 pnpm, 可以先运行: `npm install -g pnpm`
|
||||||
|
|
||||||
|
## 清单
|
||||||
|
|
||||||
|
使用此模板时,请尝试按照清单正确更新您自己的信息
|
||||||
|
|
||||||
|
- [ ] 在 `LICENSE` 中改变作者名或删除
|
||||||
|
- [ ] 在 `manifest.config.ts` 中修改项目名称,描述,`appid` 等
|
||||||
|
- [ ] 在 `.env.*` 更改环境变量
|
||||||
|
- [ ] 不需要部署到 netlify 请移除 `.netlify.toml` 文件
|
||||||
|
- [ ] 整理 README 并删除演示页面和组件
|
||||||
|
|
||||||
|
紧接着, 享受吧 :)
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
怎么修改了 `pages.json` 没效果?
|
||||||
|
> 模版使用了 [`@uni-helper/vite-plugin-uni-pages`](https://github.com/uni-helper/vite-plugin-uni-pages)插件依赖,需要在`pages.config.ts`配置,编译会生成至`pages.json`,详细[点我看文档](https://github.com/uni-helper/vite-plugin-uni-pages)
|
||||||
|
|
||||||
|
怎么修改了 `manifest.json` 没效果?
|
||||||
|
> 模版使用了[`@uni-helper/vite-plugin-uni-manifest`](https://github.com/uni-helper/vite-plugin-uni-manifest)插件依赖,需要在`manifest.config.ts`配置,编译会生成至`manifest.json`,新增了自动生成项目配置信息的方法,详细[点击查看代码](./manifest.config.ts)
|
||||||
|
|
||||||
|
报错:`Uncaught SyntaxError: The requested module '/node_modules/vue-demi/lib/index.mjs?v=701bef9f' does not provide an export named 'hasInjectionContext'`
|
||||||
|
> pinia v2.1.X 版本要求 vue 3.3 或者 vue-demi latest ,如果 uniapp 的 vue 版本是 ^3.2.45,通过 pinia 降级到 2.0.X 可以运行和使用。
|
||||||
|
|
||||||
|
## 感谢
|
||||||
|
|
||||||
|
- [vitesse](https://github.com/antfu/vitesse)
|
||||||
|
- [uni-helper](https://github.com/uni-helper)
|
||||||
|
- [uni-vitesse](https://github.com/Ares-Chang/uni-vitesse)
|
||||||
Vendored
+217
@@ -0,0 +1,217 @@
|
|||||||
|
/* 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']>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 微信小程序构建与验收说明
|
||||||
|
|
||||||
|
## 1. 当前交付边界
|
||||||
|
|
||||||
|
- 本项目是光明区文旅全域地图、POI 数字化展示、AI 文旅助手与行程规划 POC。
|
||||||
|
- 目标端为微信小程序,当前只要求在一台实际 iOS 设备验证。
|
||||||
|
- 用户主动授权后,地图可显示前台实时位置;行程会用本次位置快照优化首站和游览顺序。
|
||||||
|
- 行程由随包 POI 和确定性规则生成,用于验证推荐链路,不提供道路级实时路况、路径导航或到达承诺。
|
||||||
|
- 不包含打卡、登录或个人中心;匿名偏好和最近行程保存在本机,精确位置只保留在内存会话中。
|
||||||
|
- 当前随包数据明确标注为 `POC 审核样本`,不能宣称已覆盖光明区全部文旅资源。
|
||||||
|
|
||||||
|
## 2. 地图和高德能力说明
|
||||||
|
|
||||||
|
首页使用 uni-app 的微信原生 `map` 组件展示 GCJ-02 POI marker。微信原生地图渲染层不等同于高德底图;高德 Web Service 仅在开发期离线采集 POI 候选,客户端读取审核后冻结的本地数据,因此不会把用户提供的高德凭证打入客户端包,也不会在用户打开小程序时消耗高德配额。
|
||||||
|
|
||||||
|
离线采集规则:
|
||||||
|
|
||||||
|
- 运行 `AMAP_WEB_SERVICE_KEY=... corepack pnpm fetch:amap-pois`,Key 只存在于单次进程环境。
|
||||||
|
- 采集器固定 `region=440311`、`city_limit=true`,并对返回记录再次校验 `adcode=440311`。
|
||||||
|
- 候选结果默认写入系统临时目录;不得直接写入 `src`,需人工剔除企业、内部设施、重复入口和低价值点位后再固化。
|
||||||
|
- Web Service Key 不能写入小程序源码、`VITE_*` 环境变量、文档或构建产物。
|
||||||
|
- 只有高德官方明确允许客户端使用的专用受限 Key,才能在完成平台限制和配额配置后进入客户端。
|
||||||
|
- 日志、错误提示、测试截图和文档均不得出现完整 Key。
|
||||||
|
|
||||||
|
## 3. 本地配置
|
||||||
|
|
||||||
|
1. 复制 `.env.example` 为 `.env.local`。
|
||||||
|
2. 将项目方确认的微信小程序 AppID 写入 `UNI_MP_WEIXIN_APPID`。
|
||||||
|
3. 真机预览或上传前,将根目录 `project.config.json` 的 `appid` 从 `touristappid` 同步为相同的真实 AppID。
|
||||||
|
4. 远程 AI 为可选能力;留空 `VITE_TRAVEL_ASSISTANT_API_BASE_URL` 即使用本地 POC 规划器。
|
||||||
|
5. 不要在任何客户端环境变量中写入高德 Key、AI API Key 或模型凭证,也不要把 `.env.local` 提交或外发。
|
||||||
|
|
||||||
|
`project.config.json` 中已有的 AppID 属于模板现状,未经项目方确认不能视为本期正式身份。构建以 `manifest.config.ts` 读取的本地环境配置为准。
|
||||||
|
|
||||||
|
## 4. 安装和构建
|
||||||
|
|
||||||
|
推荐 Node.js 20 LTS 与 pnpm 9,并严格使用锁文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm install --frozen-lockfile
|
||||||
|
corepack pnpm lint
|
||||||
|
corepack pnpm type-check
|
||||||
|
corepack pnpm exec vitest run
|
||||||
|
corepack pnpm build:mp-weixin
|
||||||
|
```
|
||||||
|
|
||||||
|
运行并保持 `corepack pnpm dev:mp-weixin`,等待首次编译完成;它会生成微信 IDE 真正需要的 `app.json` 和页面四件套。`prepare:mp-weixin` 只创建空目标目录,不是构建命令,通常无需单独运行。
|
||||||
|
|
||||||
|
项目的微信开发与生产脚本默认对文件监听使用轮询,用于规避部分 macOS 会话中 `EMFILE: too many open files, watch` 问题。这不改变构建产物,但开发模式的本机资源占用会略有增加。
|
||||||
|
|
||||||
|
开发模式启动后可运行 `corepack pnpm verify:mp-weixin`,自动检查根目录 IDE 配置、启动页、五个业务页面、三个 tabBar 入口、图标和微信页面四件套是否完整。
|
||||||
|
|
||||||
|
开发模式产物位于 `dist/dev/mp-weixin`;根目录 `project.config.json` 已将微信开发者工具的 `miniprogramRoot` 和 `srcMiniprogramRoot` 指向该目录。先运行开发编译,再用微信开发者工具直接导入项目根目录。
|
||||||
|
|
||||||
|
macOS 可在另一个终端执行 `corepack pnpm ide:mp-weixin` 调起已安装的微信开发者工具。
|
||||||
|
|
||||||
|
生产构建产物位于 `dist/build/mp-weixin`,用于体验版或交付检查;如需直接预览生产包,可在微信开发者工具中单独导入该目录,或在 macOS 运行 `corepack pnpm ide:mp-weixin:build`。根目录配置固定读取开发产物,只执行生产构建不会更新根目录 IDE 当前加载的内容。
|
||||||
|
|
||||||
|
## 5. 内容替换
|
||||||
|
|
||||||
|
当前 `src/data/poi/dataset.ts` 是 30 个点位的高德候选精选 POC 数据包,封面均为开发占位素材。数据只证明样本交互链路,不构成全域权威清单。正式验收前必须由内容负责人提供并复核:
|
||||||
|
|
||||||
|
- 冻结的全域 POI 基准清单。
|
||||||
|
- 每个点位的名称、GCJ-02 坐标、简介、开放时间、官方推荐指数和 1-5 个受控标签;当前 POC 推荐指数不得视为官方配置。
|
||||||
|
- 每张图片的来源、版权状态和授权证据。
|
||||||
|
- 内容审核、发布状态及审核时间。
|
||||||
|
|
||||||
|
占位图只用于运行时和开发降级,不计入正式图片验收。
|
||||||
|
|
||||||
|
## 6. iOS 真机验收
|
||||||
|
|
||||||
|
每次测试记录 iPhone 型号、iOS、微信、基础库、网络和构建版本。至少执行:
|
||||||
|
|
||||||
|
1. 匿名打开首页时不自动触发位置授权;地图可拖动、缩放。
|
||||||
|
2. “全部”及每个分类的 marker 和数量一致。
|
||||||
|
3. 连续点击 marker,摘要卡始终对应最后点击的 `poiId`。
|
||||||
|
4. 摘要进入详情,核对名称、图片、经纬度、简介、开放时间、POC 待审核推荐指数和特色标签。
|
||||||
|
5. 返回地图后恢复分类、中心、缩放和选中点位。
|
||||||
|
6. 验证空分类、数据错误、地图错误、点位失效和图片失败降级。
|
||||||
|
7. 专项检查原生地图层级、点击穿透、安全区和快速操作稳定性。
|
||||||
|
8. 从“AI 助手”和“路线规划”进入偏好表单,生成半日及一日行程,核对点位、顺序、时间、转场提示和来源说明。
|
||||||
|
9. 关闭网络后验证本地规划仍可生成;如启用远程服务,另测合法域名、超时和服务失败提示,确认客户端产物不含服务凭证。
|
||||||
|
10. 点击“定位到我”,验证首次授权、实时蓝点、拒绝后从设置恢复,以及切换 Tab 后停止前台监听。
|
||||||
|
11. 定位成功后生成路线,确认首站按当前位置优化;检查本机存储和远程请求均不包含精确坐标。
|
||||||
|
|
||||||
|
在项目方尚未提供正式 AppID、真实清权图片和冻结基准清单时,只能判定“样本链路通过”,不能判定正式内容或全域覆盖通过。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 高德 POI 离线采集与数据快照说明
|
||||||
|
|
||||||
|
## 1. 本次结果
|
||||||
|
|
||||||
|
- 采集时间:2026-07-30(Asia/Shanghai)。
|
||||||
|
- 接口:高德 Web Service POI 2.0 关键字搜索。
|
||||||
|
- 固定区域:深圳市光明区,`region=440311`、`city_limit=true`。
|
||||||
|
- 查询:4 组文旅类型扫描及 21 个补召回关键词,共 62 次请求。
|
||||||
|
- 原始结果:1185 条;按高德 POI ID 合并 475 条重复后为 710 个候选。
|
||||||
|
- 本地发布:人工剔除企业、餐饮、普通培训机构、内部设施、重复入口和低价值点位后,精选 30 个 POC 点位。
|
||||||
|
|
||||||
|
这 30 个点位用于提升地图探索密度,不是项目方签字确认的权威全域基准清单。正式版本仍需内容负责人逐点确认纳入范围、名称、坐标、分类和来源。
|
||||||
|
|
||||||
|
## 2. 数据流
|
||||||
|
|
||||||
|
```text
|
||||||
|
高德 POI 2.0
|
||||||
|
-> 光明区严格过滤
|
||||||
|
-> 高德 ID 去重
|
||||||
|
-> 文旅价值与主资源人工筛选
|
||||||
|
-> POC 字段映射
|
||||||
|
-> src/data/poi/dataset.ts 本地冻结数据
|
||||||
|
-> 微信小程序随包读取
|
||||||
|
```
|
||||||
|
|
||||||
|
微信客户端不调用高德 Web Service,不携带 Key,也不依赖运行时网络获取 POI。
|
||||||
|
|
||||||
|
## 3. 重新采集
|
||||||
|
|
||||||
|
Key 只通过单次进程环境变量提供;默认输出到系统临时目录,不写入源码:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AMAP_WEB_SERVICE_KEY=... corepack pnpm fetch:amap-pois
|
||||||
|
corepack pnpm test:amap-fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
如指定 `--output`,采集器会拒绝写入 `src`、`scripts`、`test` 或 `tests`。候选 JSON 仍不得直接进入客户端,必须经过人工筛选和数据审核。
|
||||||
|
|
||||||
|
## 4. 字段口径
|
||||||
|
|
||||||
|
| 项目字段 | 当前来源与处理 |
|
||||||
|
|---|---|
|
||||||
|
| 名称 | 高德候选名称,已有业务点保留稳定业务 ID;别名单独保存 |
|
||||||
|
| 经纬度 | 高德返回 GCJ-02 坐标,并通过光明区 POC 包络校验 |
|
||||||
|
| 地址 | 高德候选地址;缺失时使用明确的待核验说明 |
|
||||||
|
| 简介 | POC 原创说明,只描述候选用途,不冒充景点官方介绍 |
|
||||||
|
| 开放时间 | 一律为“待确认”;高德营业时间不直接发布 |
|
||||||
|
| 推荐指数 | POC 默认值,页面标注“待审核”;高德评分不参与换算 |
|
||||||
|
| 特色标签 | 映射到项目受控标签库,每个点位 1-5 个 |
|
||||||
|
| 图片 | 使用包内统一占位图;未使用高德图片 URL |
|
||||||
|
| 来源标识 | 仅保存 `amap:<poiId>`,不保存 Key、请求 URL或带凭证参数 |
|
||||||
|
|
||||||
|
## 5. 正式发布门槛
|
||||||
|
|
||||||
|
- 内容负责人冻结全域基准清单,并记录纳入与排除理由。
|
||||||
|
- 对同名、别名、父子景区和 30-50 米近邻点进行人工去重。
|
||||||
|
- 逐点确认坐标、地址、简介、开放时间、标签及官方推荐指数。
|
||||||
|
- 为每张真实图片补齐来源、授权证据和审核记录。
|
||||||
|
- 在 iOS 微信真机逐个验证全部 marker 可点击且详情不串点。
|
||||||
|
- 发布 POI 超过 50 个或出现明显遮挡时,先实现聚合或视野内渲染。
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const uni = require('@uni-helper/eslint-config')
|
||||||
|
const unocss = require('@unocss/eslint-plugin')
|
||||||
|
|
||||||
|
module.exports = uni(
|
||||||
|
{
|
||||||
|
overrides: {
|
||||||
|
uni: {
|
||||||
|
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ignores: [
|
||||||
|
'pages.json',
|
||||||
|
'manifest.json',
|
||||||
|
'*.nvue',
|
||||||
|
'dist',
|
||||||
|
'files',
|
||||||
|
'.pnpm-store',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
unocss.configs.flat,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'no-debugger': require('node:process').env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
|
'no-console': 'off',
|
||||||
|
'vue/multiline-html-element-content-newline': ['warn', {
|
||||||
|
ignores: ['pre', 'textarea', 'text'],
|
||||||
|
}],
|
||||||
|
'vue/singleline-html-element-content-newline': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'error',
|
||||||
|
'no-unused-vars': 'off', // 未使用的变量显示警告
|
||||||
|
'vue/prop-name-casing': 'off',
|
||||||
|
'no-async-promise-executor': 'off', // 允许 promise 参数回调中使用 async-await
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
Binary file not shown.
@@ -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>
|
||||||
Generated
+13773
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "uni-preset-vue",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev:custom": "uni -p",
|
||||||
|
"dev:h5": "uni",
|
||||||
|
"dev:h5:ssr": "uni --ssr",
|
||||||
|
"dev:mp-alipay": "uni -p mp-alipay",
|
||||||
|
"dev:mp-baidu": "uni -p mp-baidu",
|
||||||
|
"dev:mp-jd": "uni -p mp-jd",
|
||||||
|
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||||
|
"dev:mp-lark": "uni -p mp-lark",
|
||||||
|
"dev:mp-qq": "uni -p mp-qq",
|
||||||
|
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||||
|
"dev:mp-harmony": "uni -p mp-harmony",
|
||||||
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
|
"dev:mp-xhs": "uni -p mp-xhs",
|
||||||
|
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||||
|
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||||
|
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||||
|
"build:custom": "uni build -p",
|
||||||
|
"build:h5": "uni build",
|
||||||
|
"build:h5:ssr": "uni build --ssr",
|
||||||
|
"build:mp-alipay": "uni build -p mp-alipay",
|
||||||
|
"build:mp-baidu": "uni build -p mp-baidu",
|
||||||
|
"build:mp-jd": "uni build -p mp-jd",
|
||||||
|
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||||
|
"build:mp-lark": "uni build -p mp-lark",
|
||||||
|
"build:mp-qq": "uni build -p mp-qq",
|
||||||
|
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||||
|
"build:mp-harmony": "uni build -p mp-harmony",
|
||||||
|
"build:mp-weixin": "uni build -p mp-weixin",
|
||||||
|
"build:mp-xhs": "uni build -p mp-xhs",
|
||||||
|
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||||
|
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||||
|
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||||
|
"type-check": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-app": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-app-harmony": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-app-plus": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-components": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-alipay": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-baidu": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-harmony": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-jd": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-kuaishou": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-lark": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-qq": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-toutiao": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-weixin": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-mp-xhs": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-quickapp-webview": "3.0.0-5010520260709002",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^9.1.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dcloudio/types": "^3.4.8",
|
||||||
|
"@dcloudio/uni-automator": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-cli-shared": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/uni-stacktracey": "3.0.0-5010520260709002",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-5010520260709002",
|
||||||
|
"@vue/tsconfig": "^0.1.3",
|
||||||
|
"@vue/runtime-core": "^3.4.21",
|
||||||
|
"typescript": "^4.9.4",
|
||||||
|
"vite": "5.2.8",
|
||||||
|
"vue-tsc": "^1.0.24"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"setting": {
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"minified": true,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"enhance": true,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"useCompilerPlugins": false,
|
||||||
|
"minifyWXML": true
|
||||||
|
},
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"simulatorPluginLibVersion": {},
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"appid": "wx2a49d023ec320d0b",
|
||||||
|
"editorSetting": {}
|
||||||
|
}
|
||||||
Vendored
+10
@@ -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
|
||||||
|
}
|
||||||
Vendored
+8
@@ -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>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
declare module "vue" {
|
||||||
|
type Hooks = App.AppInstance & Page.PageInstance;
|
||||||
|
interface ComponentCustomOptions extends Hooks {}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
@@ -0,0 +1,59 @@
|
|||||||
|
export type Theme = '亲子' | '情侣' | '朋友' | '银发' | '研学'
|
||||||
|
export type Interest = '自然风光' | '文化场馆' | '生态科普' | '美食' | '摄影'
|
||||||
|
export type Duration = 'half_day' | 'full_day'
|
||||||
|
export type Pace = 'relaxed' | 'moderate' | 'compact'
|
||||||
|
export type Transport = 'walking' | 'driving' | 'public_transport'
|
||||||
|
export type BudgetLevel = 'economy' | 'standard' | 'quality'
|
||||||
|
|
||||||
|
export interface TravelPreferences {
|
||||||
|
destination: '深圳市光明区'
|
||||||
|
themes: Theme[]
|
||||||
|
duration: Duration
|
||||||
|
pace: Pace
|
||||||
|
interests: Interest[]
|
||||||
|
adults: number
|
||||||
|
children: number
|
||||||
|
childAges: number[]
|
||||||
|
transport: Transport
|
||||||
|
budgetLevel: BudgetLevel
|
||||||
|
extraRequirements: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItineraryItem {
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
placeId: string
|
||||||
|
placeName: string
|
||||||
|
activity: string
|
||||||
|
reason: string
|
||||||
|
transferFromPreviousMinutes: number | null
|
||||||
|
tips: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItinerarySource {
|
||||||
|
placeId: string
|
||||||
|
sourceName: string
|
||||||
|
sourceUrl: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Itinerary {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
totalMinutes: number
|
||||||
|
estimatedCostText: string
|
||||||
|
items: ItineraryItem[]
|
||||||
|
notes: string[]
|
||||||
|
sources: ItinerarySource[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanResponse {
|
||||||
|
conversationId: string
|
||||||
|
assistantMessage: string
|
||||||
|
itinerary: Itinerary
|
||||||
|
changeSummary?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIError {
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
Binary file not shown.
Binary file not shown.
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
const 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>
|
||||||
|
<script type="text/javascript" src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||||||
|
<!--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,110 @@
|
|||||||
|
import process from 'node:process'
|
||||||
|
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
|
||||||
|
import { loadEnv } from 'vite'
|
||||||
|
|
||||||
|
// 获取项目名称, 不需要自动生成可将该变量与函数删除
|
||||||
|
const projectName = getProjectName()
|
||||||
|
const manifestEnv = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), '')
|
||||||
|
const weixinAppId = manifestEnv.UNI_MP_WEIXIN_APPID || 'touristappid'
|
||||||
|
|
||||||
|
export default defineManifestConfig({
|
||||||
|
'name': '光明文旅地图',
|
||||||
|
'appid': '',
|
||||||
|
'description': '深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC',
|
||||||
|
'versionName': '0.2.0',
|
||||||
|
'versionCode': '20',
|
||||||
|
'transformPx': false,
|
||||||
|
'uniStatistics': {
|
||||||
|
enable: 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: {},
|
||||||
|
/* 图标配置 */
|
||||||
|
icons: {
|
||||||
|
android: {},
|
||||||
|
ios: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/* 快应用特有相关 */
|
||||||
|
'quickapp': {},
|
||||||
|
/* 小程序特有相关 */
|
||||||
|
'mp-weixin': {
|
||||||
|
// Configure locally with UNI_MP_WEIXIN_APPID; do not commit project credentials.
|
||||||
|
appid: weixinAppId,
|
||||||
|
permission: {
|
||||||
|
'scope.userLocation': {
|
||||||
|
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
||||||
|
setting: {
|
||||||
|
urlCheck: true,
|
||||||
|
},
|
||||||
|
usingComponents: true,
|
||||||
|
projectname: projectName,
|
||||||
|
},
|
||||||
|
'mp-alipay': {
|
||||||
|
usingComponents: true,
|
||||||
|
},
|
||||||
|
'mp-baidu': {
|
||||||
|
usingComponents: true,
|
||||||
|
},
|
||||||
|
'mp-toutiao': {
|
||||||
|
usingComponents: true,
|
||||||
|
},
|
||||||
|
'vueVersion': '3',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动生成项目名称
|
||||||
|
* 当前模拟 return 项目名称,可根据业务需求自行修改函数逻辑
|
||||||
|
*/
|
||||||
|
function getProjectName(name?: string) {
|
||||||
|
// 项目名称前缀,可根据业务需求自行修改环境与前缀
|
||||||
|
const prefix = {
|
||||||
|
development: 'dev',
|
||||||
|
production: 'prod',
|
||||||
|
}
|
||||||
|
// 项目名称
|
||||||
|
const projectname = name ?? '光明文旅地图'
|
||||||
|
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development'
|
||||||
|
return `【${prefix[mode]}】${projectname}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[build]
|
||||||
|
publish = "dist/build/h5"
|
||||||
|
command = "pnpm run build:h5"
|
||||||
|
|
||||||
|
[build.environment]
|
||||||
|
NODE_VERSION = "18"
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/*"
|
||||||
|
to = "/index.html"
|
||||||
|
status = 200
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"name": "map-gm-app",
|
||||||
|
"version": "1.1.1",
|
||||||
|
"packageManager": "pnpm@9.15.9",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18",
|
||||||
|
"pnpm": ">=8"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev:app": "uni -p app",
|
||||||
|
"dev:app-android": "uni -p app-android",
|
||||||
|
"dev:app-ios": "uni -p app-ios",
|
||||||
|
"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-harmony": "uni -p mp-harmony",
|
||||||
|
"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-weixin": "node scripts/run-uni.mjs -p mp-weixin",
|
||||||
|
"prepare:mp-weixin": "node scripts/prepare-weixin-project.mjs",
|
||||||
|
"ide:mp-weixin": "/Applications/wechatwebdevtools.app/Contents/MacOS/cli open --project .",
|
||||||
|
"ide:mp-weixin:build": "/Applications/wechatwebdevtools.app/Contents/MacOS/cli open --project dist/build/mp-weixin",
|
||||||
|
"verify:mp-weixin": "node scripts/verify-weixin-output.mjs",
|
||||||
|
"verify:mp-weixin:build": "node scripts/verify-weixin-output.mjs dist/build/mp-weixin",
|
||||||
|
"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:app": "uni build -p app",
|
||||||
|
"build:app-android": "uni build -p app-android",
|
||||||
|
"build:app-ios": "uni build -p app-ios",
|
||||||
|
"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-harmony": "uni build -p mp-harmony",
|
||||||
|
"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-weixin": "node scripts/run-uni.mjs build -p mp-weixin",
|
||||||
|
"fetch:amap-pois": "node scripts/fetch-amap-pois.mjs",
|
||||||
|
"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",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"styleslint": "stylelint \"src/**/*.{css,scss,vue}\"",
|
||||||
|
"styleslint:fix": "stylelint \"src/**/*.{css,scss,vue}\" --cache --fix",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:amap-fetch": "node --test test/fetch-amap-pois.test.mjs",
|
||||||
|
"server:dev": "server/.venv/bin/python -m uvicorn app.main:app --app-dir server --reload",
|
||||||
|
"server:test": "server/.venv/bin/python -m pytest -c server/pyproject.toml server/tests",
|
||||||
|
"postinstall": "test ! -d .git || simple-git-hooks"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-app": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-app-harmony": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-app-plus": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-components": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-alipay": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-baidu": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-harmony": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-jd": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-kuaishou": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-lark": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-qq": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-toutiao": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-weixin": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
|
||||||
|
"@pmun/utils": "^0.3.9",
|
||||||
|
"@uni-helper/uni-network": "^0.21.5",
|
||||||
|
"@uni-helper/uni-use": "^0.19.14",
|
||||||
|
"@vueuse/core": "9",
|
||||||
|
"clipboard": "^2.0.11",
|
||||||
|
"dayjs": "^1.11.12",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"pinia-plugin-persist-uni": "^1.3.1",
|
||||||
|
"uview-plus": "^3.5.10",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^9.1.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@antfu/eslint-config": "^3.3.2",
|
||||||
|
"@dcloudio/types": "^3.4.19",
|
||||||
|
"@dcloudio/uni-automator": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-cli-shared": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/uni-stacktracey": "3.0.0-4080720251210001",
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001",
|
||||||
|
"@douyin-microapp/typings": "^1.2.3",
|
||||||
|
"@iconify-json/carbon": "^1.2.3",
|
||||||
|
"@mini-types/alipay": "^3.0.14",
|
||||||
|
"@stylistic/stylelint-config": "^2.0.0",
|
||||||
|
"@types/baidu-app": "^2.2.7",
|
||||||
|
"@uni-helper/eslint-config": "^0.2.1",
|
||||||
|
"@uni-helper/uni-app-types": "^1.0.0-alpha.6",
|
||||||
|
"@uni-helper/unocss-preset-uni": "^0.2.11",
|
||||||
|
"@uni-helper/vite-plugin-uni-layouts": "^0.1.11",
|
||||||
|
"@uni-helper/vite-plugin-uni-manifest": "^0.2.8",
|
||||||
|
"@uni-helper/vite-plugin-uni-pages": "^0.3.2",
|
||||||
|
"@unocss/eslint-config": "^0.58.3",
|
||||||
|
"@vue/runtime-core": "^3.4.21",
|
||||||
|
"@vue/test-utils": "^2.4.3",
|
||||||
|
"@vue/tsconfig": "^0.1.3",
|
||||||
|
"eslint": "^9.10.0",
|
||||||
|
"ks-miniprogram-types": "^1.1.0",
|
||||||
|
"lint-staged": "^15.2.10",
|
||||||
|
"miniprogram-api-typings": "^4.0.1",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"postcss-html": "^1.7.0",
|
||||||
|
"postcss-scss": "^4.0.9",
|
||||||
|
"sass": "1.63.2",
|
||||||
|
"sass-loader": "10.4.1",
|
||||||
|
"simple-git-hooks": "^2.11.1",
|
||||||
|
"stylelint": "^16.9.0",
|
||||||
|
"stylelint-config-recess-order": "^5.1.1",
|
||||||
|
"stylelint-config-standard-scss": "^13.1.0",
|
||||||
|
"stylelint-config-standard-vue": "^1.0.0",
|
||||||
|
"stylelint-scss": "^6.7.0",
|
||||||
|
"typescript": "^4.9.4",
|
||||||
|
"unocss": "^0.58.3",
|
||||||
|
"unplugin-auto-import": "^0.18.3",
|
||||||
|
"unplugin-vue-components": "^0.27.4",
|
||||||
|
"vite": "5.2.8",
|
||||||
|
"vite-plugin-vue-devtools": "^7.4.4",
|
||||||
|
"vitest": "^2.0.5",
|
||||||
|
"vue-tsc": "^1.0.24"
|
||||||
|
},
|
||||||
|
"simple-git-hooks": {
|
||||||
|
"pre-commit": "pnpm lint-staged"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
|
||||||
|
|
||||||
|
export default defineUniPages({
|
||||||
|
// UniPages preserves these platform app.json fields while the current
|
||||||
|
// uni-app compiler drops their manifest equivalents.
|
||||||
|
permission: {
|
||||||
|
'scope.userLocation': {
|
||||||
|
desc: '用于在全域地图显示实时位置,并优化文旅行程顺序',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
requiredPrivateInfos: ['getLocation', 'startLocationUpdate', 'onLocationChange'],
|
||||||
|
easycom: {
|
||||||
|
// 注意一定要放在custom里,否则无效,https://ask.dcloud.net.cn/question/131175
|
||||||
|
custom: {
|
||||||
|
'^u--(.*)': 'uview-plus/components/u-$1/u-$1.vue',
|
||||||
|
'^up-(.*)': 'uview-plus/components/u-$1/u-$1.vue',
|
||||||
|
'^u-([^-].*)': 'uview-plus/components/u-$1/u-$1.vue',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 你也可以定义 pages 字段,它具有最高的优先级。
|
||||||
|
pages: [],
|
||||||
|
globalStyle: {
|
||||||
|
'navigationBarBackgroundColor': '#ffffff',
|
||||||
|
'backgroundColor': '#f5f7f6',
|
||||||
|
'navigationBarTextStyle': 'black',
|
||||||
|
'navigationBarTitleText': '光明文旅地图',
|
||||||
|
'app-plus': {
|
||||||
|
titleNView: false, // 移除 H5、APP 顶部导航
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
color: '#718078',
|
||||||
|
selectedColor: '#167a5b',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
// 微信小程序只接受 black 或 white,不支持自定义边框色。
|
||||||
|
borderStyle: 'white',
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
pagePath: 'pages/map/index',
|
||||||
|
text: '全域地图',
|
||||||
|
iconPath: 'static/tabbar/map.png',
|
||||||
|
selectedIconPath: 'static/tabbar/map-selected.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/assistant/index',
|
||||||
|
text: 'AI 助手',
|
||||||
|
iconPath: 'static/tabbar/assistant.png',
|
||||||
|
selectedIconPath: 'static/tabbar/assistant-selected.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pagePath: 'pages/planner/index',
|
||||||
|
text: '路线规划',
|
||||||
|
iconPath: 'static/tabbar/planner.png',
|
||||||
|
selectedIconPath: 'static/tabbar/planner-selected.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+14507
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
# 用户需求决策记录 V1.0
|
||||||
|
|
||||||
|
| 文档版本 | 创建日期 | 文档状态 | 版本简述 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| V1.0 | 2026-07-29 | 已确认 | 记录附件之外由用户补充确认的产品与技术边界 |
|
||||||
|
|
||||||
|
## 决策记录
|
||||||
|
|
||||||
|
| 决策 ID | 决策 | 状态 | 对需求的影响 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| U-01 | 前端采用当前 `map-gm-app` 中的 uni-app 框架 | 已确认 | 以现有 Vue 3、TypeScript、Pinia 工程为研发基线 |
|
||||||
|
| U-02 | 7 天交付为 POC,不是正式版本 | 已确认 | 只验证最小主流程,不承诺生产级账号、后台、风控和多端能力 |
|
||||||
|
| U-03 | 目标端暂定微信小程序 | 已确认 | 本期不验收 H5、App、Android 小程序或其他平台 |
|
||||||
|
| U-04 | 当前只在 iOS 端验证 | 已确认 | 验收结论仅覆盖实际记录的 iOS、微信及基础库组合 |
|
||||||
|
| U-05 | 暂不规定最低微信基础库版本和具体 iPhone 型号 | 暂缓 | 不作为当前阻塞项;每次测试仍需记录实际环境 |
|
||||||
|
| U-06 | 地图能力使用高德地图 API | 已确认 | 高德为本期地图能力供应商;具体 Key 类型与接入方式由技术确认 |
|
||||||
|
| U-07 | 高德凭证已提供 | 已确认 | 文档只记录脱敏尾号 `...554f`,禁止保存或传播完整值 |
|
||||||
|
| U-08 | AI 与路径规划本期暂不处理,已有相关工作不纳入本 PRD | 已确认 | 移出本期页面、主流程、接口、指标、排期和验收 |
|
||||||
|
| U-09 | 打卡必须使用真实定位 | 已确认 | 只有实时取得的定位通过距离校验后才能完成打卡和发放奖励 |
|
||||||
|
| U-10 | 产品内虚拟定位测试能力本期不开发 | 已确认 | 不提供坐标输入、位置切换、调试开关或模拟成功能力 |
|
||||||
|
| U-11 | 用户匿名使用 | 已确认 | 不建设微信登录、手机号登录或正式会员账户 |
|
||||||
|
| U-12 | 个人中心只保存本机数据 | 已确认 | 打卡、积分和徽章不跨设备同步,清缓存或卸载可能丢失 |
|
||||||
|
| U-13 | 原 Q-12 的演示对象、验收签字人、网络和投屏暂不考虑 | 暂缓 | 不作为当前研发基线阻塞项 |
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
|
||||||
|
- 用户补充决策的优先级高于三份附件和产品细化建议。
|
||||||
|
- 本记录不保存高德完整 Key、微信密钥或其他可用凭证。
|
||||||
|
- “暂缓”表示本期不要求关闭,不表示该问题已经永久取消。
|
||||||
|
- 若后续决策与本表冲突,应新增版本并同步更新 PRD、差异分析和验收口径。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 用户需求决策记录 V1.1
|
||||||
|
|
||||||
|
| 文档版本 | 创建日期 | 文档状态 | 版本简述 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| V1.0 | 2026-07-29 | 已归档 | 记录 POC、平台、高德、定位、匿名和本机数据等用户决策 |
|
||||||
|
| V1.1 | 2026-07-29 | 当前版本 | 新增上级细化任务,将当前专项收敛为全域地图探索与 POI 数字化展示 |
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [1. 决策记录](#1-决策记录)
|
||||||
|
- [2. 最新任务覆盖关系](#2-最新任务覆盖关系)
|
||||||
|
- [3. 使用规则](#3-使用规则)
|
||||||
|
|
||||||
|
## 1. 决策记录
|
||||||
|
|
||||||
|
| 决策 ID | 决策 | 状态 | 对需求的影响 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| U-01 | 前端采用当前 `map-gm-app` 中的 uni-app 框架 | 已确认 | 以现有 Vue 3、TypeScript、Pinia 工程为研发基线 |
|
||||||
|
| U-02 | 7 天交付为 POC,不是正式版本 | 已确认 | 只验证最小主流程,不承诺生产后台、多端和长期运营能力 |
|
||||||
|
| U-03 | 目标端暂定微信小程序 | 已确认 | 本期不验收 H5、App、Android 小程序或其他平台 |
|
||||||
|
| U-04 | 当前只在 iOS 端验证 | 已确认 | 验收结论仅覆盖实际记录的 iOS、微信及基础库组合 |
|
||||||
|
| U-05 | 暂不规定最低微信基础库版本和具体 iPhone 型号 | 暂缓 | 不作为当前阻塞项;每次测试仍需记录实际环境 |
|
||||||
|
| U-06 | 地图能力使用高德地图 API | 已确认 | 高德为本期地图供应商;具体 Key 类型与接入方式由技术确认 |
|
||||||
|
| U-07 | 高德凭证已提供 | 已确认 | 文档只记录脱敏尾号 `...554f`,禁止保存或传播完整值 |
|
||||||
|
| U-08 | AI 与路径规划本期暂不处理,已有相关工作不纳入本 PRD | 已确认 | 移出本期页面、主流程、接口、指标、排期和验收 |
|
||||||
|
| U-09 | 若后续建设打卡,必须使用真实定位 | 已确认但不属于当前专项 | 当前地图/POI 专项不建设打卡与位置权限;规则保留给后续独立需求 |
|
||||||
|
| U-10 | 产品内虚拟定位测试能力本期不开发 | 已确认 | 当前专项不建设任何定位能力,也不提供坐标输入或模拟位置入口 |
|
||||||
|
| U-11 | 用户匿名使用 | 已确认 | 当前专项无需登录、会员或用户身份 |
|
||||||
|
| U-12 | 若后续建设个人中心,只保存本机数据 | 已确认但不属于当前专项 | 当前专项不建设个人中心、打卡、积分、徽章或本机用户资产 |
|
||||||
|
| U-13 | 原 Q-12 的演示对象、验收签字人、网络和投屏暂不考虑 | 暂缓 | 不作为当前研发基线阻塞项 |
|
||||||
|
| U-14 | 上级细化任务当前集中在全域地图导览和 POI 点位数字化展示 | 已确认 | 当前 P0 主流程收敛为地图浏览、分类探索、marker、摘要与详情 |
|
||||||
|
| U-15 | 全域地图用于呈现深圳市光明区整体文旅资源分布并支持地图化探索 | 已确认 | 地图默认视野、覆盖口径、分类和 POI 上图成为核心验收项 |
|
||||||
|
| U-16 | POI 至少包含名称、图片、经纬度、简介、开放时间、推荐指数和特色标签 | 已确认 | 七类信息进入 P0 数据合同、详情页和验收矩阵 |
|
||||||
|
|
||||||
|
## 2. 最新任务覆盖关系
|
||||||
|
|
||||||
|
U-14 至 U-16 是本次上级细化任务的最新范围输入,优先级高于旧总 PRD 对当前 P0 的描述。处理规则如下:
|
||||||
|
|
||||||
|
| 历史能力 | 当前专项处理 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 全域地图、分类、marker、POI 摘要和详情 | 保留为 P0 | 直接支撑 U-14 至 U-16 |
|
||||||
|
| 名称、图片、经纬度、简介、开放时间、推荐指数、特色标签 | 保留并升为 P0 必看字段 | 用户最新明确要求 |
|
||||||
|
| AI 问答、AI 路线和知识库 | 不在本专项 | U-08 已明确暂缓 |
|
||||||
|
| 路径规划、polyline 和实时导航 | 不在本专项 | U-08 已明确暂缓 |
|
||||||
|
| 真实定位、打卡和距离校验 | 移入后续需求池 | U-09 的规则仍有效,但当前上级任务不要求交付 |
|
||||||
|
| 积分、徽章、任务和个人中心 | 移入后续需求池 | 不再进入当前页面、数据、接口、指标、排期和验收 |
|
||||||
|
| 虚拟定位 | 不在本专项 | 不建设产品内测试入口 |
|
||||||
|
| 正式登录和跨设备资产 | 不在本专项 | 当前匿名浏览无需用户资产 |
|
||||||
|
|
||||||
|
历史总 PRD `光明区文旅全域地图及AI导览POC产品需求文档 V1.1` 保留为阶段性记录;当前地图/POI 专项的研发与验收应以 `光明区文旅全域地图及POI数字化展示产品需求文档 V1.0` 为准。
|
||||||
|
|
||||||
|
## 3. 使用规则
|
||||||
|
|
||||||
|
- 用户最新补充和上级细化任务的优先级高于三份附件及历史 PRD。
|
||||||
|
- 本记录不保存高德完整 Key、微信密钥或其他可用凭证。
|
||||||
|
- “当前专项不做”不等于永久取消;后续恢复时必须单独确认范围、指标、接口和排期。
|
||||||
|
- “暂缓”表示本期不要求关闭,不表示已经验证或形成正式兼容承诺。
|
||||||
|
- 若后续决策与本表冲突,应新增版本,并同步更新专项 PRD、范围差异分析和验收口径。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 光明区文旅全域地图及AI导览POC附件索引
|
||||||
|
|
||||||
|
| 文档版本 | 创建日期 | 版本简述 |
|
||||||
|
|---|---|---|
|
||||||
|
| V1.0 | 2026-07-29 | 建立三份原始附件的来源、版本和用途索引 |
|
||||||
|
| V1.1 | 2026-07-29 | 补充用户最新决策的优先级说明,并关联用户需求决策记录 |
|
||||||
|
| V1.2 | 2026-07-29 | 关联上级地图/POI细化任务及当前专项 PRD |
|
||||||
|
|
||||||
|
## 原始附件
|
||||||
|
|
||||||
|
为避免复制约 86 MB 的二进制文件,本项目目录仅保存索引,原始附件继续保存在仓库根目录的 `files/` 目录。
|
||||||
|
|
||||||
|
| 来源编号 | 原始文件 | 文件特征 | 在需求分析中的用途 | SHA-256 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| S1 | `files/光明区文旅全域地图及AI导览POC建设方案.docx` | 2026-07-28 最后修改;4 页;21 个正文段落 | 定义当前 POC 的目标、范围、页面类型、7 天计划和技术候选;作为当前范围的最高优先级来源 | `6e138804fbbaf088a6f2c139594ecfa4690b6d144da4b6604d3c2cb1a073e53e` |
|
||||||
|
| S2 | `files/文旅小程序三期规划方案202607091517.pptx` | 2026-07-09 10:17 最后修改;16 页 | 补充地图、筛选、榜单、AI、路线、打卡、徽章、积分兑换、挑战和优惠券的功能表达;作为功能细化来源 | `6f1a134c4770ec942ad1960cfd286308afb983d987d6f86ff61ac767ec8d0635` |
|
||||||
|
| S3 | `files/文旅小程序三期规划方案20260709.pptx` | 2026-07-09 07:10 文档属性时间;46 页 | 提供问题背景、用户旅程、三期路线、运营方案、预算、实施周期和项目风险;作为整体路线来源 | `ad2f9e88a013b899a2570dcc000fcb03785dd019d426e78287f57ccfddc566cf` |
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
|
||||||
|
三份附件之间发生冲突时采用 `S1 > S2 > S3`。附件之外的用户最新决策记录在 `用户需求决策记录 V1.1.md` 中,其优先级高于三份附件;当前工程事实用于约束技术现状,但不能覆盖用户明确的产品决策。
|
||||||
|
|
||||||
|
S1 未定义的内容可由 S2 或 S3 补充,但不会因此自动纳入 7 天 POC。S1 中的 AI、路径规划和模拟打卡原始表达,已被后续用户决策替换;上级最新任务又将当前专项进一步收敛为“全域地图探索 + POI 数字化展示”。打卡必须真实定位的规则保留给后续到点互动需求,但打卡、奖励和个人中心不属于当前专项。当前研发与验收以 `光明区文旅全域地图及POI数字化展示产品需求文档 V1.0.md` 为准,所有推导内容须标记为产品细化、假设、待确认或暂缓。
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# 上级细化任务范围差异分析 V1.0
|
||||||
|
|
||||||
|
| 文档版本 | 创建日期 | 文档状态 | 版本简述 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| V1.0 | 2026-07-29 | 当前版本 | 分析上级最新地图/POI任务与三份附件、历史总 PRD 及当前工程之间的差异 |
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [1. 分析结论](#1-分析结论)
|
||||||
|
- [2. 最新任务拆解](#2-最新任务拆解)
|
||||||
|
- [3. 与附件和历史 PRD 的差异](#3-与附件和历史-prd-的差异)
|
||||||
|
- [4. 本专项范围](#4-本专项范围)
|
||||||
|
- [5. POI 字段细化](#5-poi-字段细化)
|
||||||
|
- [6. 全域覆盖口径](#6-全域覆盖口径)
|
||||||
|
- [7. 当前工程影响](#7-当前工程影响)
|
||||||
|
- [8. 输入缺口与风险](#8-输入缺口与风险)
|
||||||
|
- [9. 产物关系](#9-产物关系)
|
||||||
|
|
||||||
|
## 1. 分析结论
|
||||||
|
|
||||||
|
上级最新任务不是在历史总 PRD 上增加两个功能,而是对当前研发任务进行范围收敛。本期只保留两项 P0:
|
||||||
|
|
||||||
|
1. 将深圳市光明区本期经确认的文旅资源进行全域地图化展示和探索。
|
||||||
|
2. 将每个 POI 以统一数字档案展示,至少包含名称、图片、经纬度、简介、开放时间、推荐指数和特色标签。
|
||||||
|
|
||||||
|
因此,当前核心流程应为:
|
||||||
|
|
||||||
|
`打开全域地图 -> 查看光明区资源分布 -> 切换主分类 -> 点击 marker -> 查看 POI 摘要 -> 进入 POI 详情 -> 返回继续探索`
|
||||||
|
|
||||||
|
真实定位打卡、积分、徽章和个人中心虽然在上一版总 PRD 中属于 P0,但不直接支撑最新上级任务,已整体移入后续需求池。AI 与路径规划继续排除。“全域地图导览”本期不是路线导航的同义词,只指地图浏览、分类探索、marker 联动和 POI 查看。
|
||||||
|
|
||||||
|
## 2. 最新任务拆解
|
||||||
|
|
||||||
|
| 原始表达 | 可开发定义 | 验收落点 |
|
||||||
|
|---|---|---|
|
||||||
|
| 全域地图导览 | 打开光明区默认视野,浏览、拖动、缩放、分类、选点和查看 POI | 地图核心脚本和 iOS 真机用例 |
|
||||||
|
| 光明区整体文旅资源分布 | 将经确认基准清单中的有效 POI 全部上图 | 基准清单有效上图覆盖率 |
|
||||||
|
| 地图化探索 | 从地图和分类发现 POI,不依赖登录或当前位置 | 分类准确率、marker 联动和返回恢复 |
|
||||||
|
| POI 点位数字化展示 | 统一字段、字典、审核、发布、状态和详情展示 | POI 数据合同和字段验收矩阵 |
|
||||||
|
| 名称、图片、经纬度、简介、开放时间、推荐指数、特色标签 | 七类信息全部进入 P0 | 已发布 POI 必看字段完整率 100% |
|
||||||
|
|
||||||
|
最新任务没有给出 POI 基准清单、总量、分类、推荐指数规则或内容来源。这些不能靠研发自行编造,必须作为 Day 0/Day 1 输入关闭。
|
||||||
|
|
||||||
|
## 3. 与附件和历史 PRD 的差异
|
||||||
|
|
||||||
|
### 3.1 三份附件的可用信息
|
||||||
|
|
||||||
|
| 来源 | 可继续使用 | 仅作背景或移出本专项 |
|
||||||
|
|---|---|---|
|
||||||
|
| S1:POC 建设方案 | 7 天 POC、全域地图、POI、演示导向 | AI、路线、模拟打卡、积分徽章、个人中心 |
|
||||||
|
| S2:16 页功能版规划 | 地图缩放、渐进展示、分类筛选、点位详情 | 路线、真实定位打卡、分享、商城、挑战和优惠券 |
|
||||||
|
| S3:46 页总规划 | 文旅数据汇集、地图发现、长期数据运营背景 | 会员、交易、后台、AI、多语种和三期完整建设 |
|
||||||
|
|
||||||
|
### 3.2 历史总 PRD 到专项 PRD
|
||||||
|
|
||||||
|
| 维度 | 历史总 PRD V1.1 | 地图/POI 专项 PRD V1.0 | 处理原因 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 主流程 | 地图 -> POI -> 定位 -> 打卡 -> 奖励 -> 个人中心 | 地图 -> 分类 -> marker -> 摘要 -> 详情 | 最新上级任务收敛 |
|
||||||
|
| 页面 | 地图、详情、打卡反馈、个人中心 | 地图、详情;摘要为浮层 | 去除非必要页面 |
|
||||||
|
| 用户身份 | 匿名本机用户 | 无需用户身份 | 当前只有只读探索 |
|
||||||
|
| POI 信息 | 通用详情和打卡资格 | 七类必看字段及治理字段 | 上级明确字段要求 |
|
||||||
|
| 定位/打卡 | P0 | 后续需求池 | 不支撑当前两项任务 |
|
||||||
|
| 积分/徽章 | P0 | 后续需求池 | 不支撑当前两项任务 |
|
||||||
|
| AI/路线 | 已排除 | 继续排除 | 用户已确认暂缓 |
|
||||||
|
| 数据模型 | POI + 定位 + 任务 + 奖励 + 本机状态 | POI + 分类 + 标签 + 数据版本 | 删除范围外数据 |
|
||||||
|
| 验收 | 地图、定位、距离、奖励、存储 | 覆盖率、字段、映射、筛选、性能 | 与当前目标一致 |
|
||||||
|
| 7 天计划 | 四类页面及完整到点互动 | 两个业务页面和完整 POI 数据验收 | 降低范围风险,增加数据审核投入 |
|
||||||
|
|
||||||
|
历史总 PRD 不删除、不覆盖,用于保留决策过程;但不能与专项 PRD 混合作为同一期研发合同。
|
||||||
|
|
||||||
|
## 4. 本专项范围
|
||||||
|
|
||||||
|
### 4.1 P0
|
||||||
|
|
||||||
|
- 光明区默认地图视野、拖动和缩放。
|
||||||
|
- 审核通过、已发布且坐标合法的 POI marker。
|
||||||
|
- “全部”与已确认主分类单选。
|
||||||
|
- marker、摘要卡和详情通过同一 `poiId` 联动。
|
||||||
|
- 摘要卡展示核心图文、官方推荐指数和特色标签。
|
||||||
|
- 详情展示名称、图片、GCJ-02 经纬度、简介、开放时间、官方推荐指数和特色标签。
|
||||||
|
- 加载、空、失败、重试、图片占位和 POI 下线状态。
|
||||||
|
- POI 数据、分类、标签、坐标、图片、审核和发布校验。
|
||||||
|
- 基准清单覆盖率或审核样本有效上图率。
|
||||||
|
- 高德能力接入、微信小程序构建和 iOS 真机验证。
|
||||||
|
|
||||||
|
### 4.2 条件 P1
|
||||||
|
|
||||||
|
- POI 名称或别名搜索。
|
||||||
|
- 特色标签组合筛选。
|
||||||
|
- 街道维度筛选。
|
||||||
|
- 光明区行政边界。
|
||||||
|
- POI 聚合或视野分级展示;当完整数据量导致性能或可点击性不达标时升为 P0。
|
||||||
|
- 推荐指数排序、坐标复制或其他已验证外部动作。
|
||||||
|
- 已有稳定业务 API 替代本地审核 JSON。
|
||||||
|
|
||||||
|
### 4.3 不在本专项
|
||||||
|
|
||||||
|
- AI、知识库、AI 路线和模型接口。
|
||||||
|
- 路径规划、polyline、实时或外部导航。
|
||||||
|
- 当前位置、位置权限、真实/虚拟定位、距离和打卡。
|
||||||
|
- 积分、徽章、任务、挑战、排行和个人中心。
|
||||||
|
- 登录、会员、收藏、评论、游记和个性化推荐。
|
||||||
|
- CMS、运营/商户后台、交易、支付、核销和数据大屏。
|
||||||
|
- Android、H5、App、其他小程序和正式设备矩阵。
|
||||||
|
|
||||||
|
## 5. POI 字段细化
|
||||||
|
|
||||||
|
### 5.1 用户明确字段
|
||||||
|
|
||||||
|
| 字段 | 产品规则 | 缺失处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 名称 | 审核后的正式名称 | 不允许发布 |
|
||||||
|
| 图片 | 至少一张合法封面,详情可有图集 | 发布前缺失不允许;运行失败用占位图 |
|
||||||
|
| 经度、纬度 | 有限数值,统一 GCJ-02,并核验目标区域 | 不允许上图 |
|
||||||
|
| 简介 | 摘要 `summary` 与详情 `description` 均非空 | 不允许发布 |
|
||||||
|
| 开放时间 | 支持已知、未知、需预约、临时关闭状态 | 未知显示“开放时间待确认” |
|
||||||
|
| 推荐指数 | 暂按官方人工配置 `1-5` 整数 | 缺失或越界不允许发布 |
|
||||||
|
| 特色标签 | 受控字典,每个 POI `1-5` 个 | 缺失、重复或引用失效标签不允许发布 |
|
||||||
|
|
||||||
|
### 5.2 为可运行必须补充的字段
|
||||||
|
|
||||||
|
| 字段 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `id` | 保证 marker、摘要和详情稳定关联 |
|
||||||
|
| `categoryCode` | 支持主分类探索 |
|
||||||
|
| `coordinateSystem` | 防止不同坐标系直接混用 |
|
||||||
|
| `address` | 提供位置文本,帮助核对 POI |
|
||||||
|
| `publishStatus` | 区分草稿、发布和下线 |
|
||||||
|
| `reviewStatus` | 确保只有审核通过内容可见 |
|
||||||
|
| `dataSource`、`sourceReference` | 追溯信息来源 |
|
||||||
|
| `copyrightStatus` | 控制图片和内容使用权 |
|
||||||
|
| `reviewedBy`、`reviewedAt` | 追溯审核责任 |
|
||||||
|
| `updatedAt` | 展示和判断信息时效 |
|
||||||
|
| `datasetVersion` | 保证地图、摘要和详情不串版 |
|
||||||
|
|
||||||
|
## 6. 全域覆盖口径
|
||||||
|
|
||||||
|
“全域覆盖”不是 marker 数量多或视觉上铺满地图。建议口径:
|
||||||
|
|
||||||
|
`基准清单有效上图覆盖率 = 已发布且可从地图到达完整详情的基准 POI 数 / 双人冻结的基准清单 POI 数 * 100%`
|
||||||
|
|
||||||
|
“成功上图”同时要求:审核通过、发布成功、必填字段合法、GCJ-02 坐标合法、marker 可见、摘要可打开、详情可访问。
|
||||||
|
|
||||||
|
边界规则:
|
||||||
|
|
||||||
|
- 基准清单必须从项目方认可的权威来源总表生成;总表须声明资源类型范围、地理范围、统计截止日期、出具方和版本。
|
||||||
|
- 来源总表或基准清单未确认、数量为 0、缺少双人签字时,不得计算全域覆盖率。
|
||||||
|
- 来源总表中的排除项须建立独立台账,由内容负责人提出、产品负责人复核;字段不全、坐标错误、图片缺失或来不及处理不能作为排除理由。
|
||||||
|
- 缺字段、缺图片、坐标非法或审核未通过的基准 POI 仍计入分母。
|
||||||
|
- 清单冻结后的变更必须记录新旧版本、证据、提出人和复核人,不能为提高覆盖率静默删除失败记录。
|
||||||
|
- 没有官方基准清单时,只能表述“覆盖本期审核样本”,并计算样本有效上图率。
|
||||||
|
|
||||||
|
## 7. 当前工程影响
|
||||||
|
|
||||||
|
当前工程可确认的基线为 uni-app、Vue 3、TypeScript、Pinia、uview-plus、UnoCSS、请求封装、UniPages 路由和微信小程序构建命令。
|
||||||
|
|
||||||
|
当前缺口:
|
||||||
|
|
||||||
|
- 首页仍请求 GitHub 示例接口,未进入文旅地图。
|
||||||
|
- 应用名称仍为 `unisave`,配置中存在测试 AppID 表达。
|
||||||
|
- 未发现地图、marker、POI、高德或数据校验业务代码。
|
||||||
|
- 请求层仍使用模板协议,错误辅助可能输出完整请求配置。
|
||||||
|
- 当前测试只覆盖通用工具,没有 POI 校验、筛选或 marker 映射用例。
|
||||||
|
|
||||||
|
建议 7 天 POC 默认使用本地审核 JSON,减少新建后端、接口协议、合法域名和现场网络风险。地图接入不能把微信原生 `map` 自动等同于高德底图;Day 1 必须确认高德 Key 类型和高德在本期承担的具体能力。
|
||||||
|
|
||||||
|
## 8. 输入缺口与风险
|
||||||
|
|
||||||
|
| 缺口 | 推荐默认项 | 截止 | 未关闭影响 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 权威来源总表、排除台账、双人冻结基准清单、POI/图片总量 | Day 0 内容与产品签字冻结 | Day 0 | 不能宣称全域覆盖,也不能确认 7 天容量 |
|
||||||
|
| 主分类和标签字典 | 一个主分类、`1-5` 个标签 | Day 0 | 阻塞筛选和发布 |
|
||||||
|
| 推荐指数口径和配置人 | 官方人工 `1-5`,只展示 | Day 0 | 阻塞必填字段可信性 |
|
||||||
|
| 图片来源、版权和域名 | 核心图随包或审核 HTTPS CDN | Day 1 | 阻塞图片发布和真机加载 |
|
||||||
|
| POI 数据来源 | 本地审核 JSON | Day 0 | 影响仓储与接口工作量 |
|
||||||
|
| 高德 Key 类型和允许能力 | 官方最小调用验证 | Day 1 | 阻塞地图 P0 |
|
||||||
|
| 高德显示/服务职责 | 按客户要求验证目标官方方案 | Day 1 | 影响架构和供应商验收 |
|
||||||
|
| 实际微信 AppID 和权限 | 项目方确认配置 | Day 1 | 阻塞体验版和真机 |
|
||||||
|
| 完整 POI 数量和重叠情况 | 超过 100 或指标失败启用聚合 | Day 2 | 影响性能和可点击性 |
|
||||||
|
|
||||||
|
主要风险不是页面数量,而是数据输入不足。没有基准清单、坐标、图片版权和推荐责任,即使地图页面完成,也不能形成可信的“光明区全域文旅资源数字化”交付结论。
|
||||||
|
|
||||||
|
7 天排期还必须受容量约束。当前默认假设最多处理并逐点验收 `50` 个 POI、`200` 张图片资产;Day 0 应以实际人力和抽测工时重算。超过可用人时须增加内容/测试人力、明确降级为审核样本或调整交付日期,不能在总量未知时承诺全量完成。
|
||||||
|
|
||||||
|
## 9. 产物关系
|
||||||
|
|
||||||
|
| 文档 | 定位 | 当前使用方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| `附件总结与需求差异分析 V1.1` | 三份附件和上一阶段决策汇总 | 历史过程资料 |
|
||||||
|
| `光明区文旅全域地图及AI导览POC产品需求文档 V1.1` | 上一阶段“地图 + POI + 打卡 + 奖励”总 PRD | 历史基线,不用于当前专项研发 |
|
||||||
|
| `用户需求决策记录 V1.1` | 最新用户和上级任务决策 | 当前决策来源 |
|
||||||
|
| `上级细化任务范围差异分析 V1.0` | 当前专项与历史范围差异 | 评审辅助材料 |
|
||||||
|
| `光明区文旅全域地图及POI数字化展示产品需求文档 V1.0` | 地图/POI 专项研发和验收文档 | 当前交付主文档 |
|
||||||
|
|
||||||
|
当前专项以最新决策记录和地图/POI 专项 PRD 为准。后续若恢复打卡、奖励、AI 或路径规划,应建立独立需求版本,不能直接混入本专项 P0。
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# 附件总结与需求差异分析 V1.0
|
||||||
|
|
||||||
|
| 文档版本 | 创建日期 | 版本简述 |
|
||||||
|
|---|---|---|
|
||||||
|
| V1.0 | 2026-07-29 | 汇总三份附件,识别重叠、差异、冲突与当前 POC 范围 |
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [分析口径](#分析口径)
|
||||||
|
- [三份附件摘要](#三份附件摘要)
|
||||||
|
- [共同需求主线](#共同需求主线)
|
||||||
|
- [附件差异](#附件差异)
|
||||||
|
- [主要冲突与处理建议](#主要冲突与处理建议)
|
||||||
|
- [需求缺口](#需求缺口)
|
||||||
|
- [当前范围建议](#当前范围建议)
|
||||||
|
|
||||||
|
## 分析口径
|
||||||
|
|
||||||
|
### 信息标记
|
||||||
|
|
||||||
|
| 标记 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 已确认 | 用户在对话中明确指定,或附件中存在一致、明确的约束 |
|
||||||
|
| 资料事实 | 附件原文表达,不代表已完成业务或技术验证 |
|
||||||
|
| 产品细化 | 为使需求可开发、可测试而补充的建议规则,需评审确认 |
|
||||||
|
| 🔶 **假设** | 资料未提供,但为了形成初稿而采用的临时判断 |
|
||||||
|
| 🔵 **待确认** | 会影响范围、方案、排期或验收,不能由产品文档代替决策的问题 |
|
||||||
|
|
||||||
|
### 来源优先级
|
||||||
|
|
||||||
|
1. `S1`:POC 建设方案,最后修改时间最晚,直接定义 7 天 POC。
|
||||||
|
2. `S2`:16 页功能版 PPT,补充具体功能形态。
|
||||||
|
3. `S3`:46 页总规划 PPT,描述完整三期和运营方向。
|
||||||
|
|
||||||
|
## 三份附件摘要
|
||||||
|
|
||||||
|
### S1:POC 建设方案
|
||||||
|
|
||||||
|
S1 将当前项目定位为用于快速验证、效果展示和能力证明的智慧文旅全域导览 Demo,而非正式生产系统。建设主线为“地图 + 点位 + 线路 + 互动”,验证全域地图、POI 数字化展示、AI 问答与路线规划、互动打卡四类能力。
|
||||||
|
|
||||||
|
资料定义的核心演示过程为:打开地图 → 查看资源 → 查看 POI → 输入游玩需求 → AI 生成路线 → 地图展示路线 → 模拟打卡获得徽章。明确列出首页地图、POI 详情、AI 助手、路线结果、打卡徽章、个人中心六类页面,同时称“约 10 个页面”,但未说明其余页面或计数方式。
|
||||||
|
|
||||||
|
工期为 7 天,资源为前端 1 人、后端/AI 1 人、UI 设计支持。技术候选为小程序 + 地图 API、Node.js/Python、JSON/SQLite、大模型 API + 文旅知识库。S1 没有量化验收指标、数据样本规模、AI 合同、异常策略或正式交付清单。
|
||||||
|
|
||||||
|
### S2:16 页功能版规划
|
||||||
|
|
||||||
|
S2 聚焦功能演示,覆盖全域地图、分类、筛选、POI 详情、榜单、路线、AI 问答路线规划、打卡分享、游记、积分兑换、徽章、榜单挑战和优惠券。
|
||||||
|
|
||||||
|
它对地图交互的描述最具体:地图多级缩放和拖拽,核心地标默认高亮,POI 随缩放渐进展示;支持“吃、住、行、游、购、娱”分类以及景点、美食、住宿、活动、无障碍等组合筛选;POI 详情含图集、开放时间、简介、电话、票价、评分、标签及收藏、打卡、预约、导航等动作。
|
||||||
|
|
||||||
|
S2 还将 AI 路线输入细化为主题、时长、节奏、特色等偏好,将路线结果定义为一日/多日路线、途经 POI、服务点和地图动线。相较 S1,它明显扩展了正式运营能力,尤其是游记审核与积分、积分商城和二维码核销、优惠券状态及到店核销、榜单挑战和用户画像;这些能力不应默认进入 7 天 POC。
|
||||||
|
|
||||||
|
### S3:46 页三期总规划
|
||||||
|
|
||||||
|
S3 说明了产品问题:文旅信息分散导致信息过载,缺乏本地化个性化规划工具,导航、预订、攻略等体验割裂。产品希望服务游客、商家和运营监管方,并形成探索发现、规划决策、导航体验、分享沉淀的游客旅程。
|
||||||
|
|
||||||
|
总规划采用三期递进表达:一期 L2 建设全域地图、POI、主题路线、打卡积分和基础 CMS;二期 L3 建设营销、订单、支付、分账、商户和数据看板;三期 L4 建设 AI 路线、AI 客服/助手、多语种导游及其他智能能力。规划周期和预算分别为一期 2 个月、29 万至 45 万元,二期 4.5 个月、50 万至 65 万元,三期 2 个月、19 万至 25 万元,总预算称不低于 98 万元,并建议总预算 20% 至 30% 的弹性备用金。
|
||||||
|
|
||||||
|
S3 还提出后续运营方向:基础运营、活动策划、惠民补贴、港澳及入境游客推广;相关能力包括 320+ 点位内容中台、预约、会员、消费券、多语言和跨境支付等。这些是长期产品及运营路线,不是 S1 的 POC 交付承诺。
|
||||||
|
|
||||||
|
## 共同需求主线
|
||||||
|
|
||||||
|
三份资料可以归纳为同一条产品主线:以光明区全域 POI 数据为基础,以地图作为用户发现入口,通过筛选、详情和 AI 路线降低决策成本,再用导航、打卡、徽章和分享完成行中互动及用户沉淀。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[文旅资源数据] --> B[全域地图与POI]
|
||||||
|
B --> C[搜索/分类/筛选]
|
||||||
|
C --> D[POI详情与决策]
|
||||||
|
D --> E[预设或AI路线]
|
||||||
|
E --> F[地图路线展示/导航]
|
||||||
|
F --> G[打卡/徽章/积分]
|
||||||
|
G --> H[分享与行为沉淀]
|
||||||
|
H --> I[内容和运营优化]
|
||||||
|
```
|
||||||
|
|
||||||
|
共同出现且可确认为产品核心的能力包括:
|
||||||
|
|
||||||
|
- 全域地图、POI 定位与详情。
|
||||||
|
- 分类浏览、筛选和地图点位联动。
|
||||||
|
- 用户偏好输入、AI 理解、个性化路线生成和地图结果展示。
|
||||||
|
- 路线关联景点、餐饮、住宿等 POI。
|
||||||
|
- 打卡、徽章或积分反馈以及分享表达。
|
||||||
|
- 行为数据记录,为后续运营和正式用户体系提供基础。
|
||||||
|
|
||||||
|
## 附件差异
|
||||||
|
|
||||||
|
| 维度 | S1:POC 方案 | S2:功能版 PPT | S3:总规划 PPT |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 文档目的 | 7 天 Demo 验证 | 功能展示与细化 | 三期建设和运营提案 |
|
||||||
|
| 当前交付范围 | 约 10 页,六类页面 | 13 个业务功能主题 | 一期至三期、三端及运营服务 |
|
||||||
|
| 地图 | 全域地图验证 | 缩放、渐进 POI、分类、筛选、榜单 | 地图数据底座和商业字段预留 |
|
||||||
|
| AI | 问答及个性化路线 | 偏好理解、路线生成、实时数据描述 | 三期 AI 路线、客服、助手、多语种导游 |
|
||||||
|
| 互动 | 模拟打卡、积分、徽章 | 真实定位表述、游记、分享、商城核销、挑战 | 账户、流水、规则、风控与会员成长 |
|
||||||
|
| 交易 | POC 后扩展 | 优惠券、积分商品核销 | 订单、支付、退款、分账、商户和对账 |
|
||||||
|
| 后台 | POC 未纳入 | 功能隐含运营配置 | CMS、商户后台、数据看板和监管 |
|
||||||
|
| 工期 | 7 天 | 未单列当前功能工期 | 一期 2 个月,二期 4.5 个月,三期 2 个月 |
|
||||||
|
| 指标 | 无量化阈值 | 定性描述增长价值 | 有运营方向,无可信产品基线和目标值 |
|
||||||
|
|
||||||
|
## 主要冲突与处理建议
|
||||||
|
|
||||||
|
| 冲突 | 影响 | 处理建议 |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 为 7 天 POC,S2/S3 包含生产级运营和交易 | 若不切范围,工期与功能严重失配 | 当前 PRD 建议只承诺可演示主流程;交易和正式运营进入后续需求池 |
|
||||||
|
| S1 列出六类页面,却称约 10 页 | 设计、研发和验收计数不一致 | 以六类业务页面为基线,浮层和子流程是否拆页由评审决定;最终页数作为 P0 待确认项 |
|
||||||
|
| “AI 问答”“AI 导览”“AI 路线规划”混用 | AI 交互、接口、知识和验收不同 | POC 必做路线需求理解与生成;开放问答、语音讲解分别列为条件范围或后续范围 |
|
||||||
|
| “模拟打卡”与“到点自动定位校验”并存 | 决定是否申请位置权限、登录和远端存储 | 7 天版默认演示模拟打卡;真实 LBS 打卡仅在账号、隐私和真机条件就绪时替换 |
|
||||||
|
| 路线既称实时导航又称地图展示 | 直线、道路级路径和外部导航的成本差异大 | POC 必做 POI 顺序与地图连线;道路级路线及导航跳转需地图能力确认 |
|
||||||
|
| AI 生成依赖实时路况/客流 | 当前未给出实时数据源 | POC 不承诺实时客流;路线仅使用已审核 POI 和已提供的静态营业信息 |
|
||||||
|
| POC 已有积分徽章,S3 又将完整积分体系放后续 | 容易将本地演示状态误认为正式资产 | POC 仅展示可重置的模拟积分/徽章;正式账户、流水、规则和风控后置 |
|
||||||
|
| “全域”没有样本规模和准确率 | 无法判断内容是否完成 | 开工前锁定 POI 数量、分类、坐标系、必填字段和抽检规则 |
|
||||||
|
|
||||||
|
## 需求缺口
|
||||||
|
|
||||||
|
### 产品和用户
|
||||||
|
|
||||||
|
- 未定义当前演示的决策者、验收人和目标体验设备。
|
||||||
|
- 未定义本地游客、港澳游客、亲子、研学等人群中谁是 POC 首要对象。
|
||||||
|
- 未定义用户是否匿名、是否微信登录、个人中心保存哪些数据。
|
||||||
|
- 未定义底部导航、页面拆分和首页信息架构。
|
||||||
|
|
||||||
|
### 数据和内容
|
||||||
|
|
||||||
|
- 未提供 POC POI 样本数、来源、坐标系、图片版权和内容审核责任。
|
||||||
|
- POI 字段在附件间不一致,缺少必填性、枚举和缺失值规则。
|
||||||
|
- 榜单热度、评分、推荐指数、特色标签没有计算或运营规则。
|
||||||
|
- 知识库资料、版本、更新和引用方式未定义。
|
||||||
|
|
||||||
|
### AI 和路线
|
||||||
|
|
||||||
|
- 未定义一次性路线生成还是多轮问答,是否需要流式输出。
|
||||||
|
- 未定义路线输入的必填项、输出 JSON 结构、POI 映射和失败降级。
|
||||||
|
- 未定义不存在点位、闭馆、重复点位、距离不可行等约束。
|
||||||
|
- 未定义模型供应商、内容安全、数据出境、费用和时延限制。
|
||||||
|
|
||||||
|
### 技术和合规
|
||||||
|
|
||||||
|
- 地图供应商、Key、调用配额、微信域名白名单和坐标口径未定。
|
||||||
|
- 正式微信 AppID、主体权限、位置隐私声明、网络环境未定。
|
||||||
|
- 后端语言、数据存储、部署环境和正式接口协议仍是候选。
|
||||||
|
- 没有性能、错误恢复、日志、监控、隐私、内容安全和无障碍要求。
|
||||||
|
|
||||||
|
### 项目和验收
|
||||||
|
|
||||||
|
- 7 天内的数据、设计、地图/AI 账号等前置材料未明确由谁、何时提供。
|
||||||
|
- 没有源码、设计稿、数据包、接口文档、测试报告、部署说明等交付清单。
|
||||||
|
- 没有可量化成功指标,也没有演示失败时的预置路线和静态数据兜底。
|
||||||
|
|
||||||
|
## 当前范围建议
|
||||||
|
|
||||||
|
🔶 **假设**:以下范围以 S1 为当前交付基线,并用 S2、S3 细化体验;需由项目决策人在范围评审中确认。
|
||||||
|
|
||||||
|
### POC 必做
|
||||||
|
|
||||||
|
- 微信小程序方向的全域地图首页、样本 POI 点位和分类筛选。
|
||||||
|
- POI 摘要卡与详情展示。
|
||||||
|
- AI 偏好输入、基于审核 POI 的路线生成。
|
||||||
|
- 路线结果列表、地图 marker 和 polyline 展示。
|
||||||
|
- 模拟打卡、模拟积分和徽章反馈。
|
||||||
|
- 个人中心汇总演示记录。
|
||||||
|
- 全主流程的加载、空、失败和重试状态。
|
||||||
|
|
||||||
|
### 条件范围
|
||||||
|
|
||||||
|
- 关键词搜索和榜单:数据量与演示需要明确时纳入。
|
||||||
|
- 真实定位打卡:仅在隐私配置、权限和真机验证就绪时纳入。
|
||||||
|
- 开放式景点问答:仅在知识库、内容安全和 API 稳定时纳入。
|
||||||
|
- 微信分享卡片或海报:需锁定素材和分享形态。
|
||||||
|
- 道路级路线、外部地图导航:依赖地图服务商能力及 Key。
|
||||||
|
|
||||||
|
### 后续阶段
|
||||||
|
|
||||||
|
- 游记发布、内容审核和 UGC 运营。
|
||||||
|
- 正式积分账户、流水、规则、风控、商城和二维码核销。
|
||||||
|
- 优惠券、活动预约、商户、商品、订单、支付、退款、分账和对账。
|
||||||
|
- CMS、商户后台、数据看板和监管端。
|
||||||
|
- 实时客流/路况、多轮 AI、语音讲解、AI 客服、多语种和 AR。
|
||||||
|
- 正式会员、用户画像、分层运营、港澳推广及跨境支付。
|
||||||
|
|
||||||
|
当前 PRD 已按上述范围形成 V1.0 评审稿。所有“产品细化”与“假设”必须在范围评审中确认后,才能转为正式基线。
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# 附件总结与需求差异分析 V1.1
|
||||||
|
|
||||||
|
| 文档版本 | 创建日期 | 文档状态 | 版本简述 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| V1.0 | 2026-07-29 | 已归档 | 汇总三份附件,提出“地图 + AI 路线 + 模拟打卡”POC 范围建议 |
|
||||||
|
| V1.1 | 2026-07-29 | 当前版本 | 根据用户最新决策,将本期收敛为“地图 + POI + 真实定位打卡 + 本机奖励”POC |
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [1. 分析口径](#1-分析口径)
|
||||||
|
- [2. 三份附件摘要](#2-三份附件摘要)
|
||||||
|
- [3. 附件共同主线](#3-附件共同主线)
|
||||||
|
- [4. 最新决策及优先级](#4-最新决策及优先级)
|
||||||
|
- [5. 范围差异与处理](#5-范围差异与处理)
|
||||||
|
- [6. 当前 POC 范围](#6-当前-poc-范围)
|
||||||
|
- [7. 仍需补齐的输入](#7-仍需补齐的输入)
|
||||||
|
- [8. 现有工程核对](#8-现有工程核对)
|
||||||
|
- [9. V1.0 到 V1.1 变更清单](#9-v10-到-v11-变更清单)
|
||||||
|
- [10. 一致性结论](#10-一致性结论)
|
||||||
|
|
||||||
|
## 1. 分析口径
|
||||||
|
|
||||||
|
### 1.1 信息标记
|
||||||
|
|
||||||
|
| 标记 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 已确认 | 用户明确决定,或当前工程中可直接核实的事实 |
|
||||||
|
| 资料事实 | 三份附件中的原始描述,不自动等于本期承诺 |
|
||||||
|
| 产品细化 | 为形成可开发、可测试需求而补充的规则 |
|
||||||
|
| 假设 | 资料不足时采用的临时判断,错误时需要调整方案 |
|
||||||
|
| 待技术确认 | 产品方向已定,但具体实现或凭证属性需研发验证 |
|
||||||
|
| 暂缓 | 本期不关闭,也不作为交付阻塞项 |
|
||||||
|
|
||||||
|
### 1.2 来源优先级
|
||||||
|
|
||||||
|
当前采用 `用户最新决策 > 当前工程事实 > S1 POC 建设方案 > S2 功能版规划 > S3 三期总规划`。
|
||||||
|
|
||||||
|
因此,附件中的 AI、路径规划、模拟打卡和多端表达,即使原先属于 POC 描述,也已被用户最新决策替换。它们只保留为来源事实或后续方向,不能继续出现在本期 P0、主流程和验收中。
|
||||||
|
|
||||||
|
## 2. 三份附件摘要
|
||||||
|
|
||||||
|
### 2.1 S1:POC 建设方案
|
||||||
|
|
||||||
|
S1 定义了一个 7 天、前后端并行、用于客户展示的 POC。内容包含全域地图、POI、AI 问答或路线、路线展示、模拟打卡、积分徽章和个人中心,并给出约 10 页、Node.js/Python、JSON/SQLite 等候选表达。
|
||||||
|
|
||||||
|
S1 的主要价值是确认 POC 性质、7 天约束和演示导向。其 AI 与模拟打卡方案已被最新用户决策调整;候选技术栈也不构成后端技术强制要求。
|
||||||
|
|
||||||
|
### 2.2 S2:16 页功能版规划
|
||||||
|
|
||||||
|
S2 细化地图缩放、POI 渐进展示、分类筛选、榜单、点位详情、路线、真实定位打卡、游记分享、积分商城、挑战和优惠券等功能。
|
||||||
|
|
||||||
|
S2 同时包含 POC 可借鉴的交互细节和生产运营能力。当前只吸收地图、POI、真实定位打卡、积分与徽章相关内容;路线、分享、商城、挑战和优惠券不进入本期。
|
||||||
|
|
||||||
|
### 2.3 S3:46 页三期总规划
|
||||||
|
|
||||||
|
S3 描述一期至三期的完整文旅小程序、运营后台、商户交易、会员体系、AI 服务、多语言和长期运营方案,周期远大于 7 天。
|
||||||
|
|
||||||
|
S3 用于理解长期方向和资料背景,不作为当前 POC 的研发合同。交易、正式账户、后台、AI 和多端建设均需后续独立立项。
|
||||||
|
|
||||||
|
## 3. 附件共同主线
|
||||||
|
|
||||||
|
三份附件的长期产品主线可以归纳为:文旅数据汇集、地图发现、信息决策、行程辅助、到点互动、用户资产和运营迭代。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[文旅 POI 数据] --> B[地图与分类发现]
|
||||||
|
B --> C[POI 摘要和详情]
|
||||||
|
C --> D[路线或导航辅助]
|
||||||
|
D --> E[到点打卡]
|
||||||
|
E --> F[积分和徽章]
|
||||||
|
F --> G[账户与运营]
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 POC 只截取其中可在 7 天内独立验证的部分:`A → B → C → E → F`。路线或导航辅助、正式账户和运营不属于本期交付。
|
||||||
|
|
||||||
|
## 4. 最新决策及优先级
|
||||||
|
|
||||||
|
| 主题 | 最新决策 | 状态 | 对附件内容的处理 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 交付性质 | 7 天 POC,不是正式版本 | 已确认 | S3 正式一期及生产能力全部后置 |
|
||||||
|
| 开发框架 | 当前 `map-gm-app` uni-app 工程 | 已确认 | 以前端现状作为适配基线 |
|
||||||
|
| 目标平台 | 微信小程序 | 已确认 | 不验收 H5、App 或其他小程序 |
|
||||||
|
| 验证系统 | 仅 iOS | 已确认 | Android 不进入本期兼容验收 |
|
||||||
|
| 具体环境 | 暂不定最低基础库和具体机型 | 暂缓 | 只记录实际测试环境,不承诺覆盖区间 |
|
||||||
|
| 地图供应商 | 高德地图 API | 已确认 | 不再把供应商作为开放产品问题 |
|
||||||
|
| 地图凭证 | 已提供,脱敏尾号 `...554f` | 已确认 | 完整值不进入文档或前端源码 |
|
||||||
|
| AI | 本期不处理 | 已确认 | 移出 P0、页面、接口、指标和排期 |
|
||||||
|
| 路径规划 | 本期不处理 | 已确认 | 不绘制路线、不做导航、不定义路线模型 |
|
||||||
|
| 打卡 | 必须使用真实定位 | 已确认 | S1 的模拟打卡方案失效 |
|
||||||
|
| 虚拟定位 | 产品内测试能力本期不开发 | 已确认 | 不提供坐标切换或假打卡开关 |
|
||||||
|
| 身份 | 匿名使用 | 已确认 | 不建设登录和正式会员体系 |
|
||||||
|
| 个人中心 | 只保存本机数据 | 已确认 | 不建设服务端资产和跨设备同步 |
|
||||||
|
| Q-12 | 暂不考虑 | 暂缓 | 不作为当前阻塞项 |
|
||||||
|
|
||||||
|
## 5. 范围差异与处理
|
||||||
|
|
||||||
|
| 差异 | 附件或 V1.0 口径 | V1.1 口径 | 处理理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POC 主流程 | 地图 → POI → AI 路线 → 模拟打卡 | 地图 → POI → 真实定位 → 半径校验 → 打卡 → 奖励 → 本机回看 | 用户最新决策 |
|
||||||
|
| 地图供应商 | 待确认 | 高德地图 API 已确认 | 用户最新决策 |
|
||||||
|
| 地图技术形态 | 供应商、Key、路线能力混为一项 | 供应商已定;Key 类型、显示载体和安全限制待技术确认 | 产品决策与实现决策分离 |
|
||||||
|
| AI 页面 | P0 | 本期删除 | 本期不处理 AI |
|
||||||
|
| 路线结果页 | P0 | 本期删除 | 本期不处理路径规划 |
|
||||||
|
| 路线 polyline | P0/P1 | 本期删除 | 不保留隐性路线工作量 |
|
||||||
|
| 打卡模式 | 默认模拟,真实定位为条件范围 | 只允许真实定位成功 | 用户最新决策 |
|
||||||
|
| 打卡降级 | 可用模拟方式继续演示 | 定位失败不得打卡或授奖 | 防止假结果冒充真实定位 |
|
||||||
|
| 虚拟位置测试 | 未形成独立边界 | 产品内能力明确不做 | 控制测试工具侵入产品范围 |
|
||||||
|
| 用户身份 | 假设匿名 | 已确认匿名 | 用户最新决策 |
|
||||||
|
| 个人资产 | 假设本机保存 | 已确认本机保存 | 用户最新决策 |
|
||||||
|
| 兼容范围 | iOS + Android | 微信小程序,仅 iOS 验证 | 用户最新决策 |
|
||||||
|
| 最低基础库/机型 | P0 待确认 | 暂缓 | 不作为 7 天开工阻塞项 |
|
||||||
|
| 演示对象/投屏 | P0 待确认 | 暂缓 | 用户明确暂不考虑 |
|
||||||
|
|
||||||
|
## 6. 当前 POC 范围
|
||||||
|
|
||||||
|
### 6.1 必做范围
|
||||||
|
|
||||||
|
- 高德地图相关能力接入、地图拖动缩放和光明区默认视野。
|
||||||
|
- 审核后的样本 POI marker、分类、筛选、摘要和详情。
|
||||||
|
- 微信小程序按需申请位置权限并取得当前真实位置。
|
||||||
|
- 统一 GCJ-02 坐标,按 POI 可配置半径执行距离校验。
|
||||||
|
- 定位拒绝、系统定位关闭、精确位置不可用、超时、精度不足和越界处理。
|
||||||
|
- 首次有效打卡、重复打卡幂等、积分和徽章反馈。
|
||||||
|
- 匿名本机持久化、个人中心回看和演示数据重置。
|
||||||
|
- iOS 微信小程序实测及异常恢复验证。
|
||||||
|
|
||||||
|
### 6.2 条件范围
|
||||||
|
|
||||||
|
- POI 名称搜索:只有样本规模和搜索数据支持时纳入。
|
||||||
|
- 官方推荐排序:只有来源和配置明确时纳入,不制作虚假算法榜单。
|
||||||
|
- POI 电话、预约或外部链接:只有数据和微信端行为验证通过时显示。
|
||||||
|
|
||||||
|
### 6.3 明确不在本期
|
||||||
|
|
||||||
|
- AI 助手、AI 问答、AI 路线和相关知识库。
|
||||||
|
- 路径规划、路线结果、polyline、道路路线、实时导航和动态重排。
|
||||||
|
- 产品内虚拟定位、坐标输入、测试位置切换或假打卡开关。
|
||||||
|
- 正式登录、会员、服务端积分、跨设备同步和生产级防作弊。
|
||||||
|
- Android、H5、App、支付宝等端的本期验收。
|
||||||
|
- CMS、运营后台、UGC、交易、支付、优惠券、核销和数据大屏。
|
||||||
|
|
||||||
|
## 7. 仍需补齐的输入
|
||||||
|
|
||||||
|
### 7.1 Day 1 技术确认
|
||||||
|
|
||||||
|
| ID | 事项 | 默认处理 | 是否阻塞对应功能 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T-01 | 已提供高德 Key 的类型、所属账号、额度、白名单和可用 API | 不猜测 Key 类型;先做最小调用验证 | 是,阻塞高德在线能力 |
|
||||||
|
| T-02 | 高德是直接提供底图显示,还是通过小程序 SDK/Web Service 提供 LBS 数据 | 采用能在微信小程序稳定运行的官方方案 | 是,阻塞地图技术方案 |
|
||||||
|
| T-03 | 微信 AppID、位置隐私声明和接口权限是否配置可用 | Day 1 真机申请一次定位 | 是,阻塞真实定位 |
|
||||||
|
| T-04 | POI 坐标、坐标系、打卡半径和审核负责人 | 统一 GCJ-02;未单配半径时暂用 `200m` | 是,阻塞目标 POI 打卡 |
|
||||||
|
|
||||||
|
### 7.2 内容输入
|
||||||
|
|
||||||
|
- POC POI 样本数量、分类、图片、简介、开放时间和合法使用来源。
|
||||||
|
- 每个可打卡 POI 的准确坐标、可安全到达的实际测试点和奖励配置。
|
||||||
|
- 徽章名称、图标、说明及每个任务的积分值。
|
||||||
|
|
||||||
|
### 7.3 非阻塞暂缓项
|
||||||
|
|
||||||
|
- 最低微信基础库版本、具体 iPhone 型号和覆盖范围。
|
||||||
|
- 最终演示对象、签字人、网络和投屏环境。
|
||||||
|
- AI、路径规划和已有相关能力的后续接入方式。
|
||||||
|
- 产品内虚拟定位测试能力。
|
||||||
|
|
||||||
|
## 8. 现有工程核对
|
||||||
|
|
||||||
|
当前工程可以确认的技术基线为 uni-app、Vue 3、TypeScript、Pinia、`pinia-plugin-persist-uni`、uview-plus、UnoCSS、请求封装、文件路由和微信小程序构建命令。
|
||||||
|
|
||||||
|
工程仍是通用模板:首页请求 GitHub 示例接口,应用名称仍为 `unisave`,生成配置中的微信 AppID 仍为测试值。代码中未发现高德地图、`map` 业务页面、`getLocation`、POI、真实定位打卡、距离校验、积分或徽章实现。
|
||||||
|
|
||||||
|
因此,“有人已经做好”的能力不在当前工作区可见范围内。V1.1 不把外部或并行工作的 AI/路径能力算作当前工程已完成,也不修改任何业务代码。
|
||||||
|
|
||||||
|
## 9. V1.0 到 V1.1 变更清单
|
||||||
|
|
||||||
|
| 文档区域 | V1.1 变更 |
|
||||||
|
|---|---|
|
||||||
|
| 执行摘要与问题陈述 | 删除 AI 路线价值主张,聚焦地图发现和真实到点互动验证 |
|
||||||
|
| 产品目标与指标 | 删除 AI 成功率、路线合理性和返回时延;新增定位判定、越界误放行和奖励幂等指标 |
|
||||||
|
| 用户旅程 | 改为地图 → POI → 定位授权 → 范围校验 → 打卡 → 本机回看 |
|
||||||
|
| P0/P1/范围外 | AI 和路径全部移出;真实定位由条件范围升为 P0;虚拟定位明确排除 |
|
||||||
|
| 产品方案 | 删除 AI 服务、路线几何和 Fixture 路线;增加定位、距离校验和本机奖励状态 |
|
||||||
|
| 页面 | 删除 AI 助手和路线结果;收敛为地图、POI 详情、打卡反馈、个人中心 |
|
||||||
|
| 功能需求 | 删除 `AI-*`、`ROUTE-*`;新增 `LOC-*` 和真实定位版 `CHECK-*` |
|
||||||
|
| 业务规则 | 删除 AI 路线规则;新增坐标、精度、时效、半径、权限和本机资产规则 |
|
||||||
|
| 数据模型 | 删除路线请求/结果;增加定位样本、打卡任务、打卡记录和本机状态版本 |
|
||||||
|
| 接口 | 删除 AI/路线接口;高德和微信定位能力单独定义 |
|
||||||
|
| 埋点 | 删除 AI/路线事件;禁止记录精确定位和轨迹 |
|
||||||
|
| 非功能需求 | 兼容范围改为仅 iOS 验证;最低基础库和具体机型暂缓 |
|
||||||
|
| 工程适配 | 按当前模板现状描述,不声明业务能力已完成 |
|
||||||
|
| 验收和排期 | 改为现场真实定位、权限异常、越界和本机持久化验证 |
|
||||||
|
| 决策表 | Q-01、Q-10、Q-11 关闭;Q-02/Q-05 部分关闭;Q-07 至 Q-09 后置;Q-12 暂缓 |
|
||||||
|
|
||||||
|
## 10. 一致性结论
|
||||||
|
|
||||||
|
V1.1 的本期唯一主流程为:
|
||||||
|
|
||||||
|
`打开地图 → 浏览/筛选 POI → 查看详情 → 获取真实定位 → 校验距离与有效半径 → 完成打卡 → 获得积分/徽章 → 个人中心本机回看`
|
||||||
|
|
||||||
|
AI、路径规划、模拟或虚拟定位、多端、正式账户和服务端资产不得重新出现在 P0、页面导航、数据模型、接口、指标、7 天排期或完成定义中。高德供应商已经确定,但凭证类型、接入层次和安全限制仍是技术确认,不应被误写为产品方向未定。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"miniprogramRoot": "dist/dev/mp-weixin/",
|
||||||
|
"projectname": "光明文旅地图",
|
||||||
|
"description": "深圳市光明区全域文旅地图、POI 数字化展示、AI 文旅助手与行程规划 POC",
|
||||||
|
"setting": {
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"minified": true,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"enhance": true,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"useCompilerPlugins": false,
|
||||||
|
"minifyWXML": true
|
||||||
|
},
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"srcMiniprogramRoot": "dist/dev/mp-weixin/",
|
||||||
|
"simulatorPluginLibVersion": {},
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"appid": "wx946b42048d117dde",
|
||||||
|
"editorSetting": {},
|
||||||
|
"condition": {}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
const projectRoot = process.cwd()
|
||||||
|
const outputRoot = resolve(projectRoot, 'dist/dev/mp-weixin')
|
||||||
|
|
||||||
|
mkdirSync(outputRoot, { recursive: true })
|
||||||
|
|
||||||
|
const placeholder = {
|
||||||
|
note: 'This directory is generated by uni-app. Run `pnpm dev:mp-weixin` before compiling in WeChat DevTools.',
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
resolve(outputRoot, 'README.generated.json'),
|
||||||
|
`${JSON.stringify(placeholder, null, 2)}\n`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`微信开发目录已准备:${outputRoot}`)
|
||||||
|
console.log('下一步运行:pnpm dev:mp-weixin')
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
const projectRoot = process.cwd()
|
||||||
|
const uniCli = resolve(projectRoot, 'node_modules/@dcloudio/vite-plugin-uni/bin/uni.js')
|
||||||
|
|
||||||
|
// Some macOS sessions expose a low launchd file-watch limit even when the
|
||||||
|
// shell's ulimit is high. Polling keeps uni-app builds reliable in that case.
|
||||||
|
const child = spawn(process.execPath, [uniCli, ...process.argv.slice(2)], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || '1',
|
||||||
|
},
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(`无法启动 uni-app CLI:${error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code ?? 1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
const projectRoot = process.cwd()
|
||||||
|
const outputDirectory = process.argv[2] ?? 'dist/dev/mp-weixin'
|
||||||
|
const outputRoot = resolve(projectRoot, outputDirectory)
|
||||||
|
const expectedPages = [
|
||||||
|
'pages/map/index',
|
||||||
|
'pages/assistant/index',
|
||||||
|
'pages/itinerary/index',
|
||||||
|
'pages/planner/index',
|
||||||
|
'pages/poi/detail',
|
||||||
|
]
|
||||||
|
const expectedTabPages = [
|
||||||
|
'pages/map/index',
|
||||||
|
'pages/assistant/index',
|
||||||
|
'pages/planner/index',
|
||||||
|
]
|
||||||
|
const expectedTabIcons = [
|
||||||
|
'static/tabbar/map.png',
|
||||||
|
'static/tabbar/map-selected.png',
|
||||||
|
'static/tabbar/assistant.png',
|
||||||
|
'static/tabbar/assistant-selected.png',
|
||||||
|
'static/tabbar/planner.png',
|
||||||
|
'static/tabbar/planner-selected.png',
|
||||||
|
]
|
||||||
|
const requiredFiles = [
|
||||||
|
'app.json',
|
||||||
|
'app.js',
|
||||||
|
'app.wxss',
|
||||||
|
'project.config.json',
|
||||||
|
...expectedPages.flatMap(page => ['json', 'js', 'wxml', 'wxss'].map(extension => `${page}.${extension}`)),
|
||||||
|
...expectedTabIcons,
|
||||||
|
]
|
||||||
|
|
||||||
|
const missingFiles = requiredFiles.filter(file => !existsSync(resolve(outputRoot, file)))
|
||||||
|
if (missingFiles.length > 0) {
|
||||||
|
console.error(`微信小程序产物不完整:${outputRoot}`)
|
||||||
|
missingFiles.forEach(file => console.error(`- 缺少 ${file}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appConfig = JSON.parse(readFileSync(resolve(outputRoot, 'app.json'), 'utf8'))
|
||||||
|
const expectedPrivateInfos = ['getLocation', 'startLocationUpdate', 'onLocationChange']
|
||||||
|
const configuredPrivateInfos = appConfig.requiredPrivateInfos ?? []
|
||||||
|
if (JSON.stringify(configuredPrivateInfos) !== JSON.stringify(expectedPrivateInfos)) {
|
||||||
|
console.error(`定位隐私接口声明错误:期望 ${expectedPrivateInfos.join(', ')},实际为 ${configuredPrivateInfos.join(', ') || '未配置'}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationPermissionDescription = appConfig.permission?.['scope.userLocation']?.desc ?? ''
|
||||||
|
const locationPermissionDescriptionLength = Array.from(locationPermissionDescription).length
|
||||||
|
if (locationPermissionDescriptionLength < 1 || locationPermissionDescriptionLength > 30) {
|
||||||
|
console.error('app.json 缺少 scope.userLocation 用途说明。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingPages = expectedPages.filter(page => !appConfig.pages?.includes(page))
|
||||||
|
if (missingPages.length > 0) {
|
||||||
|
console.error('app.json 未注册全部业务页面:')
|
||||||
|
missingPages.forEach(page => console.error(`- ${page}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unexpectedPages = appConfig.pages?.filter(page => !expectedPages.includes(page)) ?? []
|
||||||
|
if (unexpectedPages.length > 0 || appConfig.pages?.length !== expectedPages.length) {
|
||||||
|
console.error('app.json 包含未纳入本期范围的页面:')
|
||||||
|
unexpectedPages.forEach(page => console.error(`- ${page}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appConfig.pages?.[0] !== 'pages/map/index') {
|
||||||
|
console.error(`微信小程序启动页错误:期望 pages/map/index,实际为 ${appConfig.pages?.[0] ?? '未配置'}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbiddenPages = appConfig.pages?.filter(page => [
|
||||||
|
'pages/index/index',
|
||||||
|
'pages/hi',
|
||||||
|
'pages/unocss/index',
|
||||||
|
'pages/uview-plus/index',
|
||||||
|
].includes(page)) ?? []
|
||||||
|
|
||||||
|
if (forbiddenPages.length > 0) {
|
||||||
|
console.error('微信小程序产物仍包含模板演示页面:')
|
||||||
|
forbiddenPages.forEach(page => console.error(`- ${page}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabPages = appConfig.tabBar?.list?.map(item => item.pagePath) ?? []
|
||||||
|
if (JSON.stringify(tabPages) !== JSON.stringify(expectedTabPages)) {
|
||||||
|
console.error(`tabBar 页面错误:期望 ${expectedTabPages.join(', ')},实际为 ${tabPages.join(', ') || '未配置'}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['black', 'white'].includes(appConfig.tabBar?.borderStyle)) {
|
||||||
|
console.error(`tabBar.borderStyle 只能为 black 或 white,实际为 ${appConfig.tabBar?.borderStyle ?? '未配置'}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredIcons = appConfig.tabBar.list.flatMap(item => [item.iconPath, item.selectedIconPath])
|
||||||
|
if (JSON.stringify(configuredIcons) !== JSON.stringify(expectedTabIcons)) {
|
||||||
|
console.error('tabBar 图标配置与预期不一致。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ideConfigPath = resolve(projectRoot, 'project.config.json')
|
||||||
|
if (!existsSync(ideConfigPath)) {
|
||||||
|
console.error('项目根目录缺少 project.config.json,微信开发者工具无法按项目根目录导入。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ideConfig = JSON.parse(readFileSync(ideConfigPath, 'utf8'))
|
||||||
|
if (ideConfig.compileType !== 'miniprogram') {
|
||||||
|
console.error(`project.config.json 的 compileType 必须为 miniprogram,实际为 ${ideConfig.compileType ?? '未配置'}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ideConfig.miniprogramRoot !== 'dist/dev/mp-weixin/') {
|
||||||
|
console.error(`project.config.json 的 miniprogramRoot 错误:${ideConfig.miniprogramRoot ?? '未配置'}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ideConfig.srcMiniprogramRoot !== ideConfig.miniprogramRoot) {
|
||||||
|
console.error('project.config.json 的 srcMiniprogramRoot 必须与 miniprogramRoot 一致。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputIdeConfig = JSON.parse(readFileSync(resolve(outputRoot, 'project.config.json'), 'utf8'))
|
||||||
|
if (outputIdeConfig.compileType !== 'miniprogram') {
|
||||||
|
console.error(`编译产物 project.config.json 的 compileType 必须为 miniprogram,实际为 ${outputIdeConfig.compileType ?? '未配置'}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ideConfig.appid || !outputIdeConfig.appid || ideConfig.appid !== outputIdeConfig.appid) {
|
||||||
|
console.error('根目录与编译产物的微信 AppID 不一致,请同步 project.config.json 与 .env.local。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`微信开发者工具产物检查通过:${outputRoot}`)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Mock is deterministic and does not call an external model.
|
||||||
|
LLM_MODE=mock
|
||||||
|
|
||||||
|
# Configure these on the server only when LLM_MODE=real.
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=
|
||||||
|
LLM_TIMEOUT_SECONDS=45
|
||||||
|
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Travel recommendation backend
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
`POST /api/v1/plans` accepts a planning request and a coordinate-free POI whitelist:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "custom",
|
||||||
|
"preferences": {
|
||||||
|
"destination": "深圳市光明区",
|
||||||
|
"themes": ["亲子"],
|
||||||
|
"duration": "half_day",
|
||||||
|
"pace": "moderate",
|
||||||
|
"interests": ["自然风光"],
|
||||||
|
"adults": 2,
|
||||||
|
"children": 1,
|
||||||
|
"childAges": [6],
|
||||||
|
"transport": "public_transport",
|
||||||
|
"budgetLevel": "standard",
|
||||||
|
"extraRequirements": ""
|
||||||
|
},
|
||||||
|
"durationMinutes": 240,
|
||||||
|
"selectedPoiIds": ["poi_a", "poi_b"],
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"id": "poi_a",
|
||||||
|
"name": "Example A",
|
||||||
|
"categoryCode": "urban_park",
|
||||||
|
"tagCodes": ["family_friendly"],
|
||||||
|
"summary": "Coordinate-free summary",
|
||||||
|
"recommendationIndex": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "poi_b",
|
||||||
|
"name": "Example B",
|
||||||
|
"categoryCode": "cultural_venue",
|
||||||
|
"tagCodes": ["indoor_venue"],
|
||||||
|
"summary": "Coordinate-free summary",
|
||||||
|
"recommendationIndex": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The response contains only POI IDs, reasons, and suggested order. In `custom` mode all 2-8 user-selected IDs are retained. The model never receives `origin`, and never owns coordinates, route geometry, transfer estimates, or final itinerary time; those remain deterministic client responsibilities.
|
||||||
|
|
||||||
|
`GET /api/v1/health` reports `mode` (`mock` or `real`), whether real-model credentials are `configured`, and whether the selected mode is `ready`.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/python -m pip install -e '.[test]'
|
||||||
|
cd ..
|
||||||
|
corepack pnpm server:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm server:test
|
||||||
|
```
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Guangming travel recommendation API."""
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .schemas import PlanRecommendationRequest, PoiCandidate
|
||||||
|
|
||||||
|
|
||||||
|
THEME_TAGS: dict[str, set[str]] = {
|
||||||
|
"亲子": {"family_friendly", "science_learning", "indoor_venue"},
|
||||||
|
"情侣": {"photo_spot", "nature_view", "pastoral_scenery"},
|
||||||
|
"朋友": {"outdoor_leisure", "photo_spot", "culture_experience"},
|
||||||
|
"银发": {"walking", "city_park", "culture_experience", "indoor_venue"},
|
||||||
|
"研学": {"science_learning", "culture_experience", "nature_view"},
|
||||||
|
}
|
||||||
|
|
||||||
|
INTEREST_TAGS: dict[str, set[str]] = {
|
||||||
|
"自然风光": {"nature_view", "pastoral_scenery", "city_park", "outdoor_leisure"},
|
||||||
|
"文化场馆": {"culture_experience", "indoor_venue"},
|
||||||
|
"生态科普": {"science_learning", "nature_view", "pastoral_scenery"},
|
||||||
|
"美食": set(),
|
||||||
|
"摄影": {"photo_spot", "nature_view", "pastoral_scenery"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _score(candidate: PoiCandidate, request: PlanRecommendationRequest) -> int:
|
||||||
|
score = candidate.recommendation_index * 10
|
||||||
|
desired_tags: set[str] = set()
|
||||||
|
for theme in request.preferences.themes:
|
||||||
|
desired_tags.update(THEME_TAGS.get(theme, set()))
|
||||||
|
for interest in request.preferences.interests:
|
||||||
|
desired_tags.update(INTEREST_TAGS.get(interest, set()))
|
||||||
|
score += len(desired_tags.intersection(candidate.tag_codes)) * 8
|
||||||
|
if request.preferences.children and "family_friendly" in candidate.tag_codes:
|
||||||
|
score += 8
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def ranked_candidates(request: PlanRecommendationRequest) -> list[PoiCandidate]:
|
||||||
|
"""Provide a deterministic mock ranking without storing a second POI catalog."""
|
||||||
|
|
||||||
|
return sorted(
|
||||||
|
request.candidates,
|
||||||
|
key=lambda candidate: (-_score(candidate, request), candidate.id),
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user