Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
{
"name": "uni-preset-vue",
"version": "0.0.0",
"scripts": {
"dev:custom": "uni -p",
"dev:h5": "uni",
"dev:h5:ssr": "uni --ssr",
"dev:mp-alipay": "uni -p mp-alipay",
"dev:mp-baidu": "uni -p mp-baidu",
"dev:mp-jd": "uni -p mp-jd",
"dev:mp-kuaishou": "uni -p mp-kuaishou",
"dev:mp-lark": "uni -p mp-lark",
"dev:mp-qq": "uni -p mp-qq",
"dev:mp-toutiao": "uni -p mp-toutiao",
"dev:mp-harmony": "uni -p mp-harmony",
"dev:mp-weixin": "uni -p mp-weixin",
"dev:mp-xhs": "uni -p mp-xhs",
"dev:quickapp-webview": "uni -p quickapp-webview",
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:custom": "uni build -p",
"build:h5": "uni build",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"build:mp-baidu": "uni build -p mp-baidu",
"build:mp-jd": "uni build -p mp-jd",
"build:mp-kuaishou": "uni build -p mp-kuaishou",
"build:mp-lark": "uni build -p mp-lark",
"build:mp-qq": "uni build -p mp-qq",
"build:mp-toutiao": "uni build -p mp-toutiao",
"build:mp-harmony": "uni build -p mp-harmony",
"build:mp-weixin": "uni build -p mp-weixin",
"build:mp-xhs": "uni build -p mp-xhs",
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-5010520260709002",
"@dcloudio/uni-app-harmony": "3.0.0-5010520260709002",
"@dcloudio/uni-app-plus": "3.0.0-5010520260709002",
"@dcloudio/uni-components": "3.0.0-5010520260709002",
"@dcloudio/uni-h5": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-alipay": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-baidu": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-harmony": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-jd": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-kuaishou": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-lark": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-qq": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-toutiao": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-weixin": "3.0.0-5010520260709002",
"@dcloudio/uni-mp-xhs": "3.0.0-5010520260709002",
"@dcloudio/uni-quickapp-webview": "3.0.0-5010520260709002",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-5010520260709002",
"@dcloudio/uni-cli-shared": "3.0.0-5010520260709002",
"@dcloudio/uni-stacktracey": "3.0.0-5010520260709002",
"@dcloudio/vite-plugin-uni": "3.0.0-5010520260709002",
"@vue/tsconfig": "^0.1.3",
"@vue/runtime-core": "^3.4.21",
"typescript": "^4.9.4",
"vite": "5.2.8",
"vue-tsc": "^1.0.24"
}
}
@@ -0,0 +1,25 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wx2a49d023ec320d0b",
"editorSetting": {}
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types='@dcloudio/types' />
import 'vue'
declare module '@vue/runtime-core' {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {
}
}
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
onLaunch(() => {
console.log("App Launch");
});
onShow(() => {
console.log("App Show");
});
onHide(() => {
console.log("App Hide");
});
</script>
<style></style>
@@ -0,0 +1,61 @@
import type { APIError, PlanResponse, TravelPreferences } from '@/types/travel'
// H5 dev uses the Vite /api proxy; mini-program builds should inject an HTTPS base URL.
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''
const request = <T>(options: UniApp.RequestOptions): Promise<T> =>
new Promise((resolve, reject) => {
uni.request({
...options,
success: (response) => {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.data as T)
return
}
const error = response.data as APIError
reject(new Error(error?.detail || `请求失败(${response.statusCode}`))
},
fail: () => {
reject(new Error('无法连接行程服务,请检查后端是否已启动'))
},
})
})
export const createPlan = (preferences: TravelPreferences) =>
request<PlanResponse>({
url: `${API_BASE_URL}/api/v1/plans`,
method: 'POST',
data: preferences,
header: { 'content-type': 'application/json' },
timeout: 50000,
})
export const adjustPlan = (conversationId: string, message: string) =>
request<PlanResponse>({
url: `${API_BASE_URL}/api/v1/conversations/${conversationId}/messages`,
method: 'POST',
data: { message },
header: { 'content-type': 'application/json' },
timeout: 50000,
})
export const PLAN_STORAGE_KEY = 'guangming:last-plan'
export const PREFERENCES_STORAGE_KEY = 'guangming:last-preferences'
export const savePlan = (plan: PlanResponse) => {
uni.setStorageSync(PLAN_STORAGE_KEY, plan)
}
export const loadPlan = (): PlanResponse | null => {
const value = uni.getStorageSync(PLAN_STORAGE_KEY)
return value && typeof value === 'object' ? (value as PlanResponse) : null
}
export const savePreferences = (preferences: TravelPreferences) => {
uni.setStorageSync(PREFERENCES_STORAGE_KEY, preferences)
}
export const loadPreferences = (): TravelPreferences | null => {
const value = uni.getStorageSync(PREFERENCES_STORAGE_KEY)
return value && typeof value === 'object' ? (value as TravelPreferences) : null
}
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}
+8
View File
@@ -0,0 +1,8 @@
import { createSSRApp } from "vue";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
return {
app,
};
}
+72
View File
@@ -0,0 +1,72 @@
{
"name" : "",
"appid" : "",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx2a49d023ec320d0b",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics": {
"enable": false
},
"vueVersion" : "3"
}
+31
View File
@@ -0,0 +1,31 @@
{
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "逐光 · 光明区"
}
},
{
"path": "pages/planner/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "定制光明行程"
}
},
{
"path": "pages/itinerary/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的光明行程"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "逐光文旅",
"navigationBarBackgroundColor": "#1D392C",
"backgroundColor": "#F1EEE5"
}
}
@@ -0,0 +1,199 @@
<template>
<view class="page">
<view class="hero">
<view class="orb orb-one" />
<view class="orb orb-two" />
<view class="grid" />
<view class="topbar">
<view class="brand">
<text class="brand-cn"> </text>
<text class="brand-en">GUANGMING FIELD GUIDE</text>
</view>
<view class="district-pill">深圳 · 光明区</view>
</view>
<view class="hero-copy">
<text class="eyebrow">SCIENCE × NATURE × CITY</text>
<text class="hero-title">在光明<br />遇见城市的<br /><text class="accent">另一种呼吸</text></text>
<text class="hero-body">从未来科技馆到都市田园一天之内切换科学自然与生活</text>
<view class="hero-action" @click="makePlan">
<text>定制我的光明行程</text>
<text class="arrow"></text>
</view>
</view>
<view class="coordinate">
<text>22.748° N</text>
<view class="coordinate-line" />
<text>113.935° E</text>
</view>
</view>
<view class="content">
<view class="intro">
<text class="section-kicker">WHY GUANGMING</text>
<text class="intro-title">一座把未来种进田野的城区</text>
<text class="intro-body">这里有科学城的银色飞船也有四季变化的田园花景沿着绿道慢慢走城市的锋芒与自然的松弛恰好相遇</text>
<view class="fact-row">
<view class="fact">
<text class="fact-value">½1</text>
<text class="fact-label">推荐游玩天数</text>
</view>
<view class="fact">
<text class="fact-value">15</text>
<text class="fact-label">POC精选地点</text>
</view>
<view class="fact">
<text class="fact-value">AI</text>
<text class="fact-label">按偏好编排行程</text>
</view>
</view>
</view>
<view class="section-head">
<view>
<text class="section-kicker">THREE WAYS TO SEE</text>
<text class="section-title">三种光明</text>
</view>
<text class="index-mark">03</text>
</view>
<view class="story-list">
<view v-for="(story, index) in stories" :key="story.title" class="story" :class="`story-${index + 1}`">
<view class="story-art">
<view class="story-shape" />
<text class="story-number">0{{ index + 1 }}</text>
</view>
<view class="story-copy">
<text class="story-tag">{{ story.tag }}</text>
<text class="story-title">{{ story.title }}</text>
<text class="story-desc">{{ story.desc }}</text>
</view>
</view>
</view>
<view class="planner-card" @click="makePlan">
<view class="planner-top">
<text class="planner-label">AI TRIP MAKER</text>
<text class="spark"></text>
</view>
<text class="planner-title">不照搬攻略<br />只生成适合你的光明</text>
<view class="choice-preview">
<text>亲子</text><text>半日</text><text>轻松</text><text>自然</text>
</view>
<view class="planner-button">
<text>开始选择偏好</text>
<text></text>
</view>
</view>
<text class="source-note">地点资料基于光明区政府及场馆公开信息整理动态开放与预约信息请出行前确认</text>
</view>
</view>
</template>
<script setup lang="ts">
const stories = [
{ tag: 'FUTURE / 未来', title: '科学城的银色飞船', desc: '走进深圳科学技术馆,在互动展项里触摸数字文明。' },
{ tag: 'FIELD / 田野', title: '都市里的四季田园', desc: '在欢乐田园等风吹过稻浪,把时间交还给自然。' },
{ tag: 'GREEN / 漫游', title: '桥与绿道之间', desc: '沿虹桥与山林步道散步,看城市向绿色深处延伸。' },
]
const makePlan = () => {
uni.navigateTo({ url: '/pages/planner/index' })
}
</script>
<style>
page { background: #efeede; }
.page { min-height: 100vh; color: #17382f; background: #efeede; }
.hero { position: relative; height: 1180rpx; overflow: hidden; color: #f5f1da; background: linear-gradient(145deg, #123d35 0%, #0b2825 62%, #071d1c 100%); }
.grid { position: absolute; inset: 0; opacity: .15; background-image: linear-gradient(rgba(255,255,255,.22) 1rpx, transparent 1rpx), linear-gradient(90deg, rgba(255,255,255,.22) 1rpx, transparent 1rpx); background-size: 86rpx 86rpx; transform: perspective(500rpx) rotateX(62deg) scale(1.5); transform-origin: bottom; }
.orb { position: absolute; border-radius: 50%; filter: blur(2rpx); }
.orb-one { width: 620rpx; height: 620rpx; right: -240rpx; top: 130rpx; border: 1rpx solid rgba(208,255,101,.5); box-shadow: 0 0 120rpx rgba(174,235,55,.13) inset; }
.orb-two { width: 190rpx; height: 190rpx; right: 58rpx; top: 330rpx; background: #c5ef55; box-shadow: 0 0 100rpx rgba(197,239,85,.35); }
.topbar { position: relative; z-index: 2; display: flex; justify-content: space-between; align-items: flex-start; padding: calc(34rpx + env(safe-area-inset-top)) 38rpx 0; }
.brand { display: flex; flex-direction: column; gap: 4rpx; }
.brand-cn { font-family: 'STSong','Songti SC',serif; font-size: 30rpx; font-weight: 700; letter-spacing: 14rpx; }
.brand-en { font-size: 13rpx; letter-spacing: 3rpx; opacity: .6; }
.district-pill { flex-shrink: 0; padding: 11rpx 15rpx; border: 1rpx solid rgba(255,255,255,.3); border-radius: 999rpx; font-size: 18rpx; letter-spacing: 1rpx; }
.hero-copy { position: absolute; z-index: 2; left: 42rpx; right: 42rpx; bottom: 130rpx; }
.eyebrow,.section-kicker { display: block; color: #a5c83d; font-size: 18rpx; letter-spacing: 5rpx; }
.hero-title { display: block; margin-top: 32rpx; font-family: 'STSong','Songti SC',serif; font-size: 78rpx; line-height: 1.2; font-weight: 700; letter-spacing: 2rpx; }
.accent { color: #c8ed5d; font-style: italic; }
.hero-body { display: block; width: 560rpx; margin-top: 28rpx; color: rgba(245,241,218,.7); font-size: 27rpx; line-height: 1.8; }
.hero-action { display: flex; justify-content: space-between; align-items: center; width: 570rpx; margin-top: 40rpx; padding: 27rpx 30rpx; box-sizing: border-box; color: #102f29; background: #c8ed5d; font-size: 27rpx; font-weight: 700; }
.arrow { font-size: 38rpx; }
.coordinate { position: absolute; z-index: 2; right: -84rpx; bottom: 325rpx; display: flex; align-items: center; gap: 14rpx; color: rgba(255,255,255,.42); font-size: 15rpx; letter-spacing: 2rpx; transform: rotate(90deg); }
.coordinate-line { width: 80rpx; height: 1rpx; background: rgba(255,255,255,.3); }
.content { padding: 0 36rpx 100rpx; background: radial-gradient(circle at 90% 10%, rgba(200,237,93,.16), transparent 340rpx), #efeede; }
.intro { padding: 84rpx 6rpx 78rpx; border-bottom: 1rpx solid rgba(23,56,47,.16); }
.intro-title,.section-title { display: block; font-family: 'STSong','Songti SC',serif; font-weight: 700; }
.intro-title { margin-top: 22rpx; font-size: 51rpx; line-height: 1.35; }
.intro-body { display: block; margin-top: 25rpx; color: #4f635b; font-family: 'STSong','Songti SC',serif; font-size: 28rpx; line-height: 1.95; }
.fact-row { display: flex; margin-top: 48rpx; }
.fact { flex: 1; display: flex; flex-direction: column; gap: 9rpx; }
.fact-value { font-family: Georgia,serif; font-size: 37rpx; color: #17382f; }
.fact-label { font-size: 19rpx; color: #718078; }
.section-head { display: flex; justify-content: space-between; align-items: flex-end; padding: 78rpx 6rpx 38rpx; }
.section-title { margin-top: 12rpx; font-size: 54rpx; }
.index-mark { font-family: Georgia,serif; font-style: italic; font-size: 68rpx; color: rgba(23,56,47,.17); }
.story-list { display: flex; flex-direction: column; gap: 24rpx; }
.story { display: flex; min-height: 260rpx; background: #f7f4e8; box-shadow: 0 18rpx 50rpx rgba(19,52,44,.07); }
.story-art { position: relative; width: 250rpx; overflow: hidden; background: #173d35; }
.story-shape { position: absolute; width: 200rpx; height: 200rpx; border-radius: 50%; right: -70rpx; bottom: -54rpx; background: #c7eb5d; }
.story-2 .story-art { background: #ca6b3d; }
.story-2 .story-shape { width: 250rpx; height: 110rpx; border-radius: 50% 50% 0 0; right: -35rpx; bottom: 0; background: #f2c85d; }
.story-3 .story-art { background: #244f48; }
.story-3 .story-shape { width: 280rpx; height: 50rpx; right: -20rpx; bottom: 76rpx; border-radius: 99rpx; background: #df593f; transform: rotate(-26deg); }
.story-number { position: absolute; left: 22rpx; top: 20rpx; color: rgba(255,255,255,.75); font-family: Georgia,serif; font-size: 23rpx; }
.story-copy { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 28rpx; }
.story-tag { color: #b05e39; font-size: 17rpx; letter-spacing: 3rpx; }
.story-title { margin-top: 13rpx; font-family: 'STSong','Songti SC',serif; font-size: 33rpx; font-weight: 700; }
.story-desc { margin-top: 13rpx; color: #68776f; font-size: 22rpx; line-height: 1.65; }
.planner-card { margin-top: 90rpx; padding: 40rpx; color: #f3f0dc; background: #15372f; box-shadow: 18rpx 18rpx 0 #c8ed5d; }
.planner-top { display: flex; align-items: center; justify-content: space-between; }
.planner-label { color: #c8ed5d; font-size: 18rpx; letter-spacing: 4rpx; }
.spark { color: #c8ed5d; font-size: 45rpx; }
.planner-title { display: block; margin-top: 30rpx; font-family: 'STSong','Songti SC',serif; font-size: 47rpx; line-height: 1.45; font-weight: 700; }
.choice-preview { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 34rpx; }
.choice-preview text { padding: 12rpx 18rpx; border: 1rpx solid rgba(255,255,255,.28); border-radius: 999rpx; font-size: 20rpx; }
.planner-button { display: flex; justify-content: space-between; margin-top: 36rpx; padding-top: 28rpx; border-top: 1rpx solid rgba(255,255,255,.2); color: #c8ed5d; font-size: 25rpx; }
.source-note { display: block; margin: 72rpx 16rpx 0; color: #87928b; font-size: 19rpx; line-height: 1.7; text-align: center; }
/* AI travel assistant visual system */
page,.page { background: #f4f8f6; }
.page { color: #17221d; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
.hero { height: 1020rpx; color: #17221d; background: radial-gradient(circle at 90% 28%, rgba(8,156,92,.14), transparent 360rpx), linear-gradient(180deg,#fff 0%,#f3faf6 100%); }
.grid { opacity: .28; background-image: linear-gradient(rgba(7,142,85,.1) 1rpx, transparent 1rpx),linear-gradient(90deg,rgba(7,142,85,.1) 1rpx,transparent 1rpx); }
.orb-one { border-color: rgba(7,156,92,.22); box-shadow: 0 0 120rpx rgba(7,156,92,.08) inset; }
.orb-two { background: #0a9b5d; box-shadow: 0 0 80rpx rgba(7,156,92,.22); }
.topbar { padding-top: calc(28rpx + env(safe-area-inset-top)); }
.brand-cn { color: #087b4a; }
.brand-en { color: #73827a; opacity: 1; }
.district-pill { color: #087b4a; border-color: #b9d9c9; background: rgba(255,255,255,.75); }
.hero-copy { bottom: 100rpx; }
.eyebrow,.section-kicker { color: #078e55; }
.hero-title { font-size: 72rpx; }
.accent { color: #078e55; font-style: normal; }
.hero-body { color: #68766f; }
.hero-action { width: 590rpx; border-radius: 20rpx; color: #fff; background: linear-gradient(100deg,#079c5c,#067d4a); box-shadow: 0 14rpx 32rpx rgba(3,129,75,.24); }
.coordinate { color: rgba(7,96,57,.4); }
.coordinate-line { background: rgba(7,96,57,.24); }
.content { background: #f4f8f6; }
.intro { margin-top: 28rpx; padding: 58rpx 34rpx; border: 2rpx solid #dce9e2; border-radius: 30rpx; background: #fff; box-shadow: 0 18rpx 48rpx rgba(19,75,50,.07); }
.intro-title,.section-title,.story-title,.planner-title { font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
.intro-body { color: #617068; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; font-size: 25rpx; }
.story { border: 2rpx solid #e0e9e4; border-radius: 24rpx; overflow: hidden; background: #fff; box-shadow: 0 12rpx 34rpx rgba(19,75,50,.06); }
.story-art { background: #087c4b; }
.story-shape { background: #8fe0b8; }
.story-2 .story-art { background: #0b9560; }
.story-2 .story-shape { background: #b6ead0; }
.story-3 .story-art { background: #076b48; }
.story-3 .story-shape { background: #62cea0; }
.story-tag { color: #078e55; }
.planner-card { border-radius: 30rpx; background: linear-gradient(145deg,#089b5d,#067645); box-shadow: 0 20rpx 50rpx rgba(3,114,66,.22); }
.planner-label,.spark,.planner-button { color: #d9ffeb; }
.choice-preview text { background: rgba(255,255,255,.08); }
</style>
@@ -0,0 +1,321 @@
<template>
<view v-if="plan" class="page">
<view class="nav">
<view class="back" @click="goBack"></view>
<text class="nav-title">推荐路线</text>
<text class="replan" @click="goPlanner">重新规划</text>
</view>
<view class="content">
<view class="route-card">
<view class="recommend"><text class="recommend-icon"></text>为你推荐以下路线</view>
<text class="route-title">{{ plan.itinerary.title }}</text>
<view class="tags">
<text v-for="tag in routeTags" :key="tag">{{ tag }}</text>
</view>
<view class="route-meta">
<view><text class="meta-label">总时长</text><text class="meta-value">{{ formatMinutes(plan.itinerary.totalMinutes) }}</text></view>
<view><text class="meta-label">行程</text><text class="meta-value">{{ plan.itinerary.items.length }} 个地点</text></view>
</view>
<text class="route-summary">{{ plan.itinerary.summary }}</text>
</view>
<view v-if="plan.changeSummary" class="change-note"><text> 已更新</text>{{ plan.changeSummary }}</view>
<view class="map-card">
<view class="map-grid" />
<view class="park park-one" />
<view class="park park-two" />
<view class="water" />
<view class="road road-one" />
<view class="road road-two" />
<view class="road road-three" />
<view class="route-line route-one" />
<view class="route-line route-two" />
<view class="route-line route-three" />
<view
v-for="(item, index) in mapItems"
:key="item.placeId"
class="map-stop"
:class="`map-stop-${index + 1}`"
>
<view class="map-pin">{{ index + 1 }}</view>
<text class="map-label">{{ shortName(item.placeName) }}</text>
</view>
<view class="map-badge"><text>路线示意</text><text>非实时地图</text></view>
</view>
<view class="list-card">
<view
v-for="(item, index) in plan.itinerary.items"
:key="`${item.placeId}-${index}`"
class="place-row"
>
<view class="place-art" :class="`art-${index % 4}`">
<view class="sun" />
<view class="hill hill-back" />
<view class="hill hill-front" />
<view class="building"><view /><view /><view /></view>
<text>{{ String(index + 1).padStart(2, '0') }}</text>
</view>
<view class="place-copy">
<view class="place-head">
<view class="place-number">{{ index + 1 }}</view>
<text class="place-name">{{ item.placeName }}</text>
</view>
<view class="place-tags">
<text>{{ index === 0 ? '首站' : '推荐' }}</text>
<text>{{ preferenceTag(index) }}</text>
</view>
<text class="activity">{{ item.activity }}</text>
<view class="place-foot">
<text>{{ item.startTime }}{{ item.endTime }}</text>
<text v-if="index">{{ transferText(item.transferFromPreviousMinutes) }}</text>
</view>
<view class="reason"><text></text>{{ item.reason }}</view>
</view>
</view>
</view>
<view class="cost-card">
<view class="cost-icon">¥</view>
<view><text>费用提示</text><text>{{ plan.itinerary.estimatedCostText }}</text></view>
</view>
<view class="adjust-card">
<text class="section-kicker">AI 路线微调</text>
<text class="section-title">还想怎么调整</text>
<view class="quick-list">
<view v-for="prompt in quickPrompts" :key="prompt" class="quick" @click="sendAdjustment(prompt)">
<text>{{ prompt }}</text><text></text>
</view>
</view>
<view class="composer">
<input
v-model="message"
:disabled="adjusting"
maxlength="500"
placeholder="例如:第二个地点换成室内项目"
placeholder-class="placeholder"
confirm-type="send"
@confirm="sendAdjustment()"
/>
<button :disabled="adjusting || !message.trim()" @click="sendAdjustment()">{{ adjusting ? '' : '发送' }}</button>
</view>
</view>
<view class="detail-card">
<view class="detail-section">
<text class="section-kicker">出行提醒</text>
<text v-for="note in plan.itinerary.notes" :key="note" class="note">· {{ note }}</text>
</view>
<view class="detail-section sources">
<text class="section-kicker">资料来源</text>
<view v-for="source in plan.itinerary.sources" :key="source.placeId" class="source" @click="copySource(source.sourceUrl)">
<view><text>{{ source.sourceName }}</text><text>{{ source.updatedAt }} 更新</text></view><text>复制链接</text>
</view>
</view>
</view>
</view>
<view class="bottom-bar">
<button class="save-button" @click="saveRoute"><text></text>{{ saved ? '已保存' : '保存路线' }}</button>
<button class="accept-button" @click="acceptRoute"><text></text>采纳这条路线</button>
</view>
</view>
<view v-else class="empty">
<view class="empty-icon"></view>
<text>还没有生成行程</text>
<button @click="goPlanner">返回选择偏好</button>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { adjustPlan, loadPlan, loadPreferences, savePlan } from '@/api/travelAssistant'
import type { PlanResponse, TravelPreferences } from '@/types/travel'
const plan = ref<PlanResponse | null>(null)
const preferences = ref<TravelPreferences | null>(null)
const message = ref('')
const adjusting = ref(false)
const saved = ref(false)
const quickPrompts = ['节奏慢一点', '换一个室内地点', '增加亲子体验']
const paceLabels = { relaxed: '轻松休闲', moderate: '节奏适中', compact: '紧凑高效' }
const durationLabels = { half_day: '半日游', full_day: '一日游' }
const routeTags = computed(() => {
const value = preferences.value
if (!value) return ['AI 优选', '光明文旅']
return [
durationLabels[value.duration],
value.themes[0],
paceLabels[value.pace],
value.interests[0],
].filter(Boolean)
})
const mapItems = computed(() => plan.value?.itinerary.items.slice(0, 4) || [])
onLoad(() => {
plan.value = loadPlan()
preferences.value = loadPreferences()
})
const formatMinutes = (minutes: number) => {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest ? `${hours}小时${rest}` : `${hours}小时`
}
const transferText = (minutes: number | null) => minutes === null ? '交通待确认' : `${minutes} 分钟`
const shortName = (name: string) => name.length > 8 ? `${name.slice(0, 8)}` : name
const preferenceTag = (index: number) => {
const interests = preferences.value?.interests || []
return interests[index % Math.max(interests.length, 1)] || '光明精选'
}
const goBack = () => uni.navigateBack()
const goPlanner = () => uni.redirectTo({ url: '/pages/planner/index' })
const copySource = (url: string) => {
uni.setClipboardData({ data: url, success: () => uni.showToast({ title: '来源链接已复制', icon: 'none' }) })
}
const saveRoute = () => {
if (!plan.value) return
savePlan(plan.value)
saved.value = true
uni.showToast({ title: '路线已保存', icon: 'success' })
}
const acceptRoute = () => {
saveRoute()
uni.showModal({
title: '路线已采纳',
content: '行程已保存到本机。真实地图导航将在下一阶段接入。',
showCancel: false,
})
}
const sendAdjustment = async (quick?: string) => {
if (!plan.value || adjusting.value) return
const content = (quick || message.value).trim()
if (!content) return
adjusting.value = true
try {
const response = await adjustPlan(plan.value.conversationId, content)
plan.value = response
savePlan(response)
saved.value = false
message.value = ''
uni.pageScrollTo({ scrollTop: 0, duration: 300 })
} catch (error) {
uni.showModal({ title: '调整未完成', content: error instanceof Error ? error.message : '上一次行程已为你保留', showCancel: false })
} finally {
adjusting.value = false
}
}
</script>
<style>
page { background: #f4f8f6; }
.page { min-height: 100vh; padding-bottom: calc(150rpx + env(safe-area-inset-bottom)); color: #17221d; background: radial-gradient(circle at 8% 8%,rgba(9,151,89,.06),transparent 340rpx),#f4f8f6; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
.nav { position: sticky; z-index: 30; top: 0; height: 100rpx; display: flex; align-items: center; justify-content: space-between; padding: calc(24rpx + env(safe-area-inset-top)) 28rpx 0; border-bottom: 1rpx solid #e7eeea; background: rgba(255,255,255,.96); }
.back { width: 76rpx; color: #14201a; font-family: Georgia,serif; font-size: 59rpx; font-weight: 300; }
.nav-title { position: absolute; left: 180rpx; right: 180rpx; text-align: center; font-size: 32rpx; font-weight: 800; }
.replan { color: #087d4d; font-size: 23rpx; font-weight: 700; }
.content { padding: 24rpx 20rpx 60rpx; }
.route-card,.list-card,.adjust-card,.detail-card { border: 2rpx solid #d9e7e0; border-radius: 26rpx; background: #fff; box-shadow: 0 14rpx 40rpx rgba(20,78,53,.07); }
.route-card { padding: 28rpx; border-radius: 26rpx 26rpx 0 0; }
.recommend { display: flex; align-items: center; gap: 10rpx; color: #0a9258; font-size: 21rpx; font-weight: 700; }
.recommend-icon { width: 34rpx; height: 34rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #087b4b; background: #c9f0dc; }
.route-title { display: block; margin-top: 19rpx; font-size: 38rpx; line-height: 1.35; font-weight: 800; letter-spacing: 1rpx; }
.tags,.place-tags { display: flex; flex-wrap: wrap; gap: 10rpx; }
.tags { margin-top: 17rpx; }
.tags text,.place-tags text { padding: 8rpx 14rpx; border-radius: 8rpx; color: #27825a; background: #eaf7f0; font-size: 18rpx; }
.route-meta { display: flex; gap: 48rpx; margin-top: 24rpx; }
.route-meta > view { display: flex; align-items: baseline; gap: 10rpx; }
.meta-label { color: #829089; font-size: 19rpx; }
.meta-value { color: #18231e; font-size: 29rpx; font-weight: 800; }
.route-summary { display: block; margin-top: 15rpx; color: #718078; font-size: 20rpx; line-height: 1.7; }
.change-note { margin-top: 18rpx; padding: 20rpx 22rpx; border: 1rpx solid #cce6d8; border-radius: 15rpx; color: #527061; background: #eaf7f0; font-size: 20rpx; }
.change-note text { margin-right: 12rpx; color: #087d4d; font-weight: 800; }
.map-card { position: relative; height: 430rpx; overflow: hidden; border-right: 2rpx solid #d9e7e0; border-bottom: 2rpx solid #d9e7e0; border-left: 2rpx solid #d9e7e0; background: #edf4ed; }
.map-grid { position: absolute; inset: 0; opacity: .8; background-image: linear-gradient(23deg,transparent 46%,#d7ded7 47%,#fff 49%,#d7ded7 51%,transparent 53%),linear-gradient(115deg,transparent 45%,#dae1da 46%,#fff 49%,#dae1da 52%,transparent 53%); background-size: 180rpx 150rpx,230rpx 190rpx; }
.park { position: absolute; border-radius: 46% 54% 50% 50%; background: rgba(151,206,146,.33); }
.park-one { left: 38rpx; top: 48rpx; width: 190rpx; height: 110rpx; transform: rotate(-14deg); }
.park-two { right: 44rpx; bottom: 34rpx; width: 230rpx; height: 140rpx; transform: rotate(17deg); }
.water { position: absolute; left: -50rpx; top: 232rpx; width: 820rpx; height: 22rpx; border-radius: 50%; background: #b9dfec; transform: rotate(-10deg); }
.road { position: absolute; height: 15rpx; border: 3rpx solid #fff; border-radius: 999rpx; background: #d4d9d5; }
.road-one { left: -30rpx; top: 92rpx; width: 520rpx; transform: rotate(12deg); }
.road-two { right: -40rpx; top: 200rpx; width: 500rpx; transform: rotate(-18deg); }
.road-three { left: 110rpx; bottom: 70rpx; width: 530rpx; transform: rotate(8deg); }
.route-line { position: absolute; z-index: 3; height: 10rpx; border-radius: 99rpx; background: #1686df; box-shadow: 0 0 0 4rpx rgba(255,255,255,.8); transform-origin: left center; }
.route-one { left: 148rpx; top: 112rpx; width: 210rpx; transform: rotate(22deg); }
.route-two { left: 339rpx; top: 188rpx; width: 190rpx; transform: rotate(35deg); }
.route-three { left: 493rpx; top: 294rpx; width: 150rpx; transform: rotate(-30deg); }
.map-stop { position: absolute; z-index: 5; display: flex; align-items: center; gap: 8rpx; }
.map-stop-1 { left: 98rpx; top: 73rpx; }
.map-stop-2 { left: 315rpx; top: 156rpx; }
.map-stop-3 { left: 492rpx; top: 272rpx; }
.map-stop-4 { right: 38rpx; bottom: 64rpx; flex-direction: row-reverse; }
.map-pin { width: 50rpx; height: 50rpx; display: flex; align-items: center; justify-content: center; border: 5rpx solid #fff; border-radius: 50% 50% 50% 8rpx; color: #fff; background: #078b52; box-shadow: 0 5rpx 14rpx rgba(6,93,56,.28); font-size: 21rpx; font-weight: 800; transform: rotate(-45deg); }
.map-pin::first-letter { transform: rotate(45deg); }
.map-label { max-width: 190rpx; padding: 8rpx 12rpx; border-radius: 8rpx; color: #28342e; background: rgba(255,255,255,.92); box-shadow: 0 4rpx 12rpx rgba(40,75,57,.12); font-size: 17rpx; font-weight: 700; }
.map-badge { position: absolute; left: 18rpx; bottom: 16rpx; z-index: 7; display: flex; flex-direction: column; gap: 3rpx; padding: 10rpx 14rpx; border-radius: 10rpx; color: #315543; background: rgba(255,255,255,.9); font-size: 16rpx; }
.map-badge text:last-child { color: #8a9690; font-size: 13rpx; }
.list-card { margin-top: 18rpx; overflow: hidden; }
.place-row { display: flex; gap: 20rpx; padding: 24rpx; border-bottom: 1rpx solid #e8eeeb; }
.place-row:last-child { border-bottom: 0; }
.place-art { position: relative; flex-shrink: 0; width: 190rpx; height: 150rpx; overflow: hidden; border-radius: 16rpx; background: linear-gradient(#b8e5f2 0 48%,#a8d58c 49%); }
.place-art.art-1 { background: linear-gradient(#cce8f2 0 49%,#99c7a0 50%); }
.place-art.art-2 { background: linear-gradient(#c1e2f4 0 48%,#8ec69a 49%); }
.place-art.art-3 { background: linear-gradient(#f4d8b4 0 48%,#c18a55 49%); }
.place-art > text { position: absolute; right: 9rpx; top: 7rpx; color: rgba(255,255,255,.88); font-family: Georgia,serif; font-size: 19rpx; font-weight: 700; }
.sun { position: absolute; right: 25rpx; top: 21rpx; width: 30rpx; height: 30rpx; border-radius: 50%; background: #fff3a2; }
.hill { position: absolute; border-radius: 50% 50% 0 0; }
.hill-back { left: -28rpx; bottom: 38rpx; width: 150rpx; height: 58rpx; background: #74b986; transform: rotate(12deg); }
.hill-front { right: -34rpx; bottom: 22rpx; width: 160rpx; height: 70rpx; background: #4b9b68; transform: rotate(-7deg); }
.building { position: absolute; z-index: 2; left: 65rpx; bottom: 22rpx; width: 65rpx; height: 58rpx; display: flex; justify-content: space-around; padding: 13rpx 7rpx 0; box-sizing: border-box; background: #f0eee4; box-shadow: 0 4rpx 8rpx rgba(0,0,0,.18); }
.building::before { content: ''; position: absolute; left: -8rpx; top: -20rpx; border-right: 40rpx solid transparent; border-bottom: 22rpx solid #557d67; border-left: 40rpx solid transparent; }
.building view { width: 8rpx; height: 34rpx; background: #759682; }
.place-copy { min-width: 0; flex: 1; }
.place-head { display: flex; align-items: flex-start; gap: 10rpx; }
.place-number { flex-shrink: 0; width: 37rpx; height: 37rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #fff; background: #177fce; font-size: 18rpx; font-weight: 800; }
.place-name { flex: 1; padding-top: 2rpx; font-size: 27rpx; line-height: 1.35; font-weight: 800; }
.place-tags { margin: 12rpx 0 10rpx 47rpx; }
.place-tags text { padding: 5rpx 10rpx; font-size: 15rpx; }
.activity { display: block; margin-left: 47rpx; color: #68766f; font-size: 20rpx; line-height: 1.55; }
.place-foot { display: flex; justify-content: space-between; margin: 12rpx 0 0 47rpx; color: #858f8a; font-size: 17rpx; }
.reason { display: flex; gap: 8rpx; margin: 14rpx 0 0 47rpx; padding: 12rpx; border-radius: 9rpx; color: #426351; background: #f0f8f4; font-size: 17rpx; line-height: 1.45; }
.reason text { color: #07935a; }
.cost-card { display: flex; gap: 18rpx; margin-top: 18rpx; padding: 24rpx; border-radius: 20rpx; color: #fff; background: linear-gradient(135deg,#0a965b,#077647); box-shadow: 0 12rpx 28rpx rgba(5,118,69,.17); }
.cost-icon { flex-shrink: 0; width: 52rpx; height: 52rpx; display: flex; align-items: center; justify-content: center; border: 1rpx solid rgba(255,255,255,.5); border-radius: 50%; font-size: 23rpx; }
.cost-card > view:last-child { display: flex; flex-direction: column; gap: 7rpx; font-size: 18rpx; line-height: 1.5; }
.cost-card > view:last-child text:first-child { font-size: 22rpx; font-weight: 800; }
.adjust-card,.detail-card { margin-top: 18rpx; padding: 28rpx; }
.section-kicker { display: block; color: #078e55; font-size: 18rpx; font-weight: 800; letter-spacing: 2rpx; }
.section-title { display: block; margin-top: 8rpx; font-size: 31rpx; font-weight: 800; }
.quick-list { display: flex; flex-direction: column; margin-top: 20rpx; border: 1rpx solid #e1eae5; border-radius: 14rpx; }
.quick { display: flex; justify-content: space-between; padding: 21rpx 20rpx; border-bottom: 1rpx solid #e8eeeb; font-size: 21rpx; }
.quick:last-child { border-bottom: 0; }
.quick text:last-child { color: #078e55; font-size: 30rpx; line-height: 20rpx; }
.composer { display: flex; margin-top: 18rpx; overflow: hidden; border: 2rpx solid #dce7e1; border-radius: 14rpx; }
.composer input { flex: 1; height: 82rpx; padding: 0 20rpx; font-size: 20rpx; }
.placeholder { color: #a5afaa; }
.composer button { width: 112rpx; height: 82rpx; margin: 0; border-radius: 0; color: #fff; background: #078e55; font-size: 20rpx; line-height: 82rpx; }
.composer button::after { border: 0; }
.composer button[disabled] { opacity: .55; }
.detail-section + .detail-section { margin-top: 30rpx; padding-top: 26rpx; border-top: 1rpx solid #e5ece8; }
.note { display: block; margin-top: 13rpx; color: #68766f; font-size: 19rpx; line-height: 1.6; }
.source { display: flex; align-items: center; justify-content: space-between; gap: 18rpx; padding: 18rpx 0; border-bottom: 1rpx solid #e8eeeb; color: #078e55; font-size: 17rpx; }
.source view { flex: 1; display: flex; flex-direction: column; gap: 5rpx; color: #34473d; }
.source view text:last-child { color: #919b96; font-size: 15rpx; }
.bottom-bar { position: fixed; z-index: 40; right: 0; bottom: 0; left: 0; display: flex; gap: 18rpx; padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #dfe8e3; background: rgba(255,255,255,.96); box-shadow: 0 -10rpx 30rpx rgba(26,70,49,.07); }
.bottom-bar button { height: 88rpx; display: flex; align-items: center; justify-content: center; gap: 10rpx; margin: 0; border-radius: 18rpx; font-size: 23rpx; font-weight: 800; }
.bottom-bar button::after { border: 0; }
.save-button { width: 230rpx; color: #087d4d; border: 2rpx solid #11915b; background: #fff; }
.save-button text { font-size: 33rpx; font-weight: 400; }
.accept-button { flex: 1; color: #fff; background: linear-gradient(100deg,#079c5c,#067d4a); box-shadow: 0 10rpx 22rpx rgba(3,129,75,.2); }
.accept-button text { font-size: 26rpx; transform: rotate(-26deg); }
.empty { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 28rpx; color: #26372e; background: #f4f8f6; font-size: 31rpx; font-weight: 700; }
.empty-icon { width: 100rpx; height: 100rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #078e55; background: #e4f5ec; font-size: 56rpx; }
.empty button { padding: 0 34rpx; border-radius: 16rpx; color: #fff; background: #078e55; font-size: 21rpx; }
</style>
@@ -0,0 +1,302 @@
<template>
<view class="page">
<view class="nav">
<view class="back" @click="goBack"></view>
<text class="nav-title">AI 智能规划</text>
<view class="nav-space" />
</view>
<view class="intro">
<view class="bot" aria-label="光明文旅 AI 助手">
<view class="antenna"><view class="antenna-dot" /></view>
<view class="bot-ear left" />
<view class="bot-ear right" />
<view class="bot-head">
<view class="bot-face">
<view class="bot-eye" />
<view class="bot-mouth" />
<view class="bot-eye" />
</view>
</view>
<view class="bot-body"><text>AI</text></view>
</view>
<view class="speech">
<view class="speech-tail" />
<text class="hello">Hi我是光明文旅 AI 助手</text>
<text class="intro-copy">告诉我你的偏好和需求<br />我将为你生成专属路线</text>
<view class="online"><view class="online-dot" /> REAL AI · 在线规划</view>
</view>
</view>
<view class="form-card">
<view class="card-title">
<view>
<text class="title">告诉我你的偏好</text>
<text class="title-sub">已选内容会成为 AI 规划依据</text>
</view>
<view class="progress">1/1</view>
</view>
<view class="section first">
<view class="section-head">
<text class="label">出行主题</text><text class="hint">多选</text>
</view>
<view class="option-grid four">
<view v-for="item in themeOptions" :key="item" class="option" :class="{ active: form.themes.includes(item) }" @click="toggleTheme(item)">
<text v-if="form.themes.includes(item)" class="check"></text>{{ item }}
</view>
</view>
</view>
<view class="section">
<view class="section-head"><text class="label">游玩时长</text></view>
<view class="option-grid three">
<view class="option" :class="{ active: form.duration === 'half_day' }" @click="form.duration = 'half_day'">
<text class="option-main">半日游</text><text class="option-sub"> 4 小时</text>
</view>
<view class="option" :class="{ active: form.duration === 'full_day' }" @click="form.duration = 'full_day'">
<text class="option-main">一日游</text><text class="option-sub"> 8 小时</text>
</view>
<view class="option disabled"><text class="option-main">两日游</text><text class="option-sub">敬请期待</text></view>
</view>
</view>
<view class="section">
<view class="section-head"><text class="label">节奏偏好</text></view>
<view class="option-grid three">
<view v-for="item in paceOptions" :key="item.value" class="option" :class="{ active: form.pace === item.value }" @click="form.pace = item.value">
<text class="option-main">{{ item.label }}</text><text class="option-sub">{{ item.desc }}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-head">
<text class="label">特色偏好</text><text class="hint">多选</text>
</view>
<view class="option-grid three interest-grid">
<view v-for="item in interestOptions" :key="item" class="option" :class="{ active: form.interests.includes(item) }" @click="toggleInterest(item)">
<text v-if="form.interests.includes(item)" class="check"></text>{{ item }}
</view>
</view>
</view>
<view class="section">
<view class="section-head"><text class="label">出行方式</text></view>
<view class="option-grid three">
<view v-for="item in transportOptions" :key="item.value" class="option" :class="{ active: form.transport === item.value }" @click="form.transport = item.value">{{ item.label }}</view>
</view>
</view>
<view class="section">
<view class="section-head"><text class="label">预算偏好</text></view>
<view class="option-grid three">
<view v-for="item in budgetOptions" :key="item.value" class="option" :class="{ active: form.budgetLevel === item.value }" @click="form.budgetLevel = item.value">{{ item.label }}</view>
</view>
</view>
<view class="section people-section">
<view class="section-head">
<text class="label">同行人数</text><text class="hint">儿童年龄会影响推荐</text>
</view>
<view class="people-grid">
<view class="counter-row">
<view><text class="person-label">成人</text><text class="person-note">18 岁以上</text></view>
<view class="counter"><button @click="changeAdults(-1)"></button><text>{{ form.adults }}</text><button @click="changeAdults(1)"></button></view>
</view>
<view class="counter-row">
<view><text class="person-label">儿童</text><text class="person-note">017 </text></view>
<view class="counter"><button @click="changeChildren(-1)"></button><text>{{ form.children }}</text><button @click="changeChildren(1)"></button></view>
</view>
</view>
<view v-if="form.children" class="ages">
<view v-for="(_, index) in form.childAges" :key="index" class="age-row">
<text>儿童 {{ index + 1 }} 年龄</text>
<slider :value="form.childAges[index]" min="0" max="17" activeColor="#07945b" backgroundColor="#dce8e2" block-color="#07945b" block-size="18" @change="setAge(index, $event)" />
<text class="age-value">{{ form.childAges[index] }} </text>
</view>
</view>
</view>
<view class="section last">
<view class="section-head">
<text class="label">补充说明</text><text class="hint">选填</text>
</view>
<view class="textarea-wrap">
<textarea v-model="form.extraRequirements" class="textarea" maxlength="500" placeholder="如:想带小朋友,喜欢公园和美食,不想爬山…" placeholder-class="placeholder" />
<text class="count">{{ form.extraRequirements.length }}/500</text>
</view>
</view>
<button class="submit" :disabled="submitting" @click="submit">
<text class="submit-spark"></text>
<text>{{ submitting ? 'AI 正在编排行程…' : '开始智能规划' }}</text>
</button>
</view>
<view class="footer-note">
<view class="line" /><view class="footer-dot" />
<text>AI 懂你所想 · 规划你所需</text>
<view class="footer-dot" /><view class="line" />
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { createPlan, savePlan, savePreferences } from '@/api/travelAssistant'
import type { BudgetLevel, Interest, Pace, Theme, Transport, TravelPreferences } from '@/types/travel'
const themeOptions: Theme[] = ['亲子', '情侣', '朋友', '银发', '研学']
const interestOptions: Interest[] = ['自然风光', '文化场馆', '生态科普', '美食', '摄影']
const paceOptions: { label: string; desc: string; value: Pace }[] = [
{ label: '轻松', desc: '慢慢走', value: 'relaxed' },
{ label: '适中', desc: '刚刚好', value: 'moderate' },
{ label: '紧凑', desc: '多看看', value: 'compact' },
]
const transportOptions: { label: string; value: Transport }[] = [
{ label: '公共交通', value: 'public_transport' },
{ label: '自驾', value: 'driving' },
{ label: '步行', value: 'walking' },
]
const budgetOptions: { label: string; value: BudgetLevel }[] = [
{ label: '经济', value: 'economy' },
{ label: '适中', value: 'standard' },
{ label: '品质', value: 'quality' },
]
const form = reactive<TravelPreferences>({
destination: '深圳市光明区',
themes: ['亲子'],
duration: 'half_day',
pace: 'relaxed',
interests: ['自然风光'],
adults: 2,
children: 0,
childAges: [],
transport: 'public_transport',
budgetLevel: 'standard',
extraRequirements: '',
})
const submitting = ref(false)
const toggleTheme = (item: Theme) => {
const index = form.themes.indexOf(item)
index >= 0 ? form.themes.splice(index, 1) : form.themes.push(item)
}
const toggleInterest = (item: Interest) => {
const index = form.interests.indexOf(item)
index >= 0 ? form.interests.splice(index, 1) : form.interests.push(item)
}
const changeAdults = (delta: number) => {
form.adults = Math.min(20, Math.max(0, form.adults + delta))
}
const changeChildren = (delta: number) => {
const next = Math.min(20, Math.max(0, form.children + delta))
while (form.childAges.length < next) form.childAges.push(6)
form.childAges.splice(next)
form.children = next
}
const setAge = (index: number, event: { detail: { value: number } }) => {
form.childAges[index] = event.detail.value
}
const goBack = () => uni.navigateBack()
const submit = async () => {
if (!form.themes.length && !form.interests.length) {
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
return
}
if (form.adults + form.children < 1) {
uni.showToast({ title: '出行总人数至少为 1', icon: 'none' })
return
}
submitting.value = true
try {
const preferences = { ...form, themes: [...form.themes], interests: [...form.interests], childAges: [...form.childAges] }
const response = await createPlan(preferences)
savePreferences(preferences)
savePlan(response)
uni.navigateTo({ url: '/pages/itinerary/index' })
} catch (error) {
uni.showModal({ title: '暂时无法生成', content: error instanceof Error ? error.message : '请稍后再试', showCancel: false })
} finally {
submitting.value = false
}
}
</script>
<style>
page { background: #f4f8f6; }
.page { min-height: 100vh; padding-bottom: calc(60rpx + env(safe-area-inset-bottom)); color: #17221d; background: radial-gradient(circle at 12% 12%, rgba(20,166,105,.08), transparent 330rpx), #f4f8f6; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
.nav { position: relative; display: flex; align-items: center; justify-content: space-between; height: 100rpx; padding: calc(24rpx + env(safe-area-inset-top)) 28rpx 0; background: rgba(255,255,255,.92); border-bottom: 1rpx solid #e9efec; }
.back,.nav-space { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; }
.back { color: #101a15; font-family: Georgia,serif; font-size: 58rpx; font-weight: 300; }
.nav-title { font-size: 32rpx; font-weight: 700; letter-spacing: 1rpx; }
.intro { display: flex; align-items: flex-end; gap: 20rpx; padding: 42rpx 28rpx 34rpx; }
.bot { position: relative; flex-shrink: 0; width: 150rpx; height: 170rpx; }
.antenna { position: absolute; z-index: 1; left: 73rpx; top: 0; width: 4rpx; height: 30rpx; background: #81d2ad; }
.antenna-dot { position: absolute; left: -8rpx; top: -4rpx; width: 20rpx; height: 20rpx; border: 3rpx solid #b5e4ce; border-radius: 50%; background: #fff; box-shadow: 0 3rpx 10rpx rgba(0,123,76,.18); }
.bot-head { position: absolute; z-index: 3; left: 16rpx; top: 28rpx; width: 118rpx; height: 92rpx; border: 4rpx solid #93d8b8; border-radius: 46rpx 46rpx 38rpx 38rpx; background: linear-gradient(145deg,#fff,#edf8f3); box-shadow: 0 12rpx 24rpx rgba(5,112,68,.15); }
.bot-face { position: absolute; inset: 17rpx 14rpx; display: flex; align-items: center; justify-content: space-around; border-radius: 28rpx; background: #092f2b; box-shadow: inset 0 0 16rpx rgba(0,246,190,.18); }
.bot-eye { width: 13rpx; height: 29rpx; border-radius: 999rpx; background: #18e4bd; box-shadow: 0 0 10rpx rgba(24,228,189,.7); }
.bot-mouth { width: 20rpx; height: 5rpx; margin-top: 18rpx; border-radius: 99rpx; background: #18e4bd; }
.bot-ear { position: absolute; z-index: 2; top: 58rpx; width: 20rpx; height: 46rpx; border: 3rpx solid #8ad5b1; background: #e7f7ef; }
.bot-ear.left { left: 3rpx; border-radius: 18rpx 4rpx 4rpx 18rpx; }
.bot-ear.right { right: 3rpx; border-radius: 4rpx 18rpx 18rpx 4rpx; }
.bot-body { position: absolute; left: 37rpx; top: 117rpx; width: 76rpx; height: 48rpx; display: flex; justify-content: center; padding-top: 14rpx; box-sizing: border-box; border: 4rpx solid #9bdcbe; border-radius: 30rpx 30rpx 12rpx 12rpx; color: #06905a; background: #f2fbf7; font-size: 14rpx; font-weight: 800; letter-spacing: 2rpx; }
.speech { position: relative; flex: 1; min-height: 150rpx; display: flex; flex-direction: column; justify-content: center; padding: 26rpx 28rpx; box-sizing: border-box; border: 2rpx solid #d4e8df; border-radius: 30rpx; background: rgba(255,255,255,.92); box-shadow: 0 12rpx 32rpx rgba(20,80,54,.07); }
.speech-tail { position: absolute; left: -16rpx; bottom: 34rpx; width: 28rpx; height: 28rpx; border-left: 2rpx solid #d4e8df; border-bottom: 2rpx solid #d4e8df; background: #fff; transform: rotate(45deg); }
.hello { color: #087846; font-size: 29rpx; font-weight: 800; }
.intro-copy { margin-top: 10rpx; color: #64716b; font-size: 21rpx; line-height: 1.65; }
.online { display: flex; align-items: center; margin-top: 12rpx; color: #7a8b83; font-size: 15rpx; letter-spacing: 1rpx; }
.online-dot { width: 11rpx; height: 11rpx; margin-right: 8rpx; border-radius: 50%; background: #08a865; box-shadow: 0 0 0 5rpx rgba(8,168,101,.12); }
.form-card { margin: 0 20rpx; padding: 0 28rpx 30rpx; border: 2rpx solid #d9e6e0; border-radius: 32rpx; background: rgba(255,255,255,.96); box-shadow: 0 20rpx 60rpx rgba(19,75,50,.08); }
.card-title { display: flex; align-items: center; justify-content: space-between; margin: 0 -28rpx; padding: 29rpx 28rpx; border-bottom: 1rpx solid #e9efec; }
.title { display: block; font-size: 31rpx; font-weight: 800; }
.title-sub { display: block; margin-top: 6rpx; color: #8b9892; font-size: 18rpx; }
.progress { padding: 8rpx 16rpx; border-radius: 999rpx; color: #07814d; background: #e9f7f0; font-size: 17rpx; font-weight: 700; }
.section { padding: 34rpx 0; border-bottom: 1rpx solid #edf1ef; }
.section.first { padding-top: 30rpx; }
.section.last { border-bottom: 0; }
.section-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 20rpx; }
.label { font-size: 25rpx; font-weight: 700; }
.hint { color: #9aa49f; font-size: 19rpx; }
.hint::before { content: '· '; }
.option-grid { display: grid; gap: 16rpx; }
.option-grid.four { grid-template-columns: repeat(4,1fr); }
.option-grid.three { grid-template-columns: repeat(3,1fr); }
.option { min-height: 70rpx; display: flex; align-items: center; justify-content: center; gap: 7rpx; padding: 12rpx 10rpx; box-sizing: border-box; border: 2rpx solid #e0e6e3; border-radius: 14rpx; color: #454f4a; background: #fff; font-size: 23rpx; transition: transform .18s ease, background .18s ease; }
.option:active { transform: scale(.97); }
.option.active { color: #fff; border-color: #078e55; background: linear-gradient(145deg,#079c5c,#087d4d); box-shadow: 0 8rpx 18rpx rgba(5,142,84,.2); font-weight: 700; }
.option.disabled { color: #b7bfbb; background: #f6f8f7; }
.check { font-size: 18rpx; }
.option-main,.option-sub { display: block; text-align: center; }
.option-main { font-size: 23rpx; }
.option-sub { margin-left: 6rpx; color: #9aa49f; font-size: 16rpx; }
.option.active .option-sub { color: rgba(255,255,255,.75); }
.interest-grid .option { font-size: 21rpx; }
.people-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
.counter-row { min-height: 112rpx; display: flex; flex-direction: column; gap: 18rpx; padding: 20rpx; box-sizing: border-box; border: 2rpx solid #e4eae7; border-radius: 16rpx; }
.person-label,.person-note { display: block; }
.person-label { font-size: 23rpx; font-weight: 700; }
.person-note { margin-top: 3rpx; color: #9aa49f; font-size: 16rpx; }
.counter { display: flex; align-items: center; justify-content: space-between; }
.counter button { width: 52rpx; height: 52rpx; margin: 0; padding: 0; line-height: 50rpx; border: 0; border-radius: 12rpx; color: #087d4d; background: #eaf7f0; font-size: 28rpx; }
.counter button::after { border: 0; }
.counter text { min-width: 36rpx; text-align: center; color: #16231d; font-size: 27rpx; font-weight: 800; }
.ages { margin-top: 18rpx; padding: 18rpx; border-radius: 14rpx; background: #f4f9f6; }
.age-row { display: grid; grid-template-columns: 150rpx 1fr 68rpx; align-items: center; font-size: 18rpx; }
.age-value { text-align: right; color: #07814d; font-weight: 700; }
.textarea-wrap { position: relative; }
.textarea { width: 100%; height: 180rpx; padding: 22rpx 24rpx 50rpx; box-sizing: border-box; border: 2rpx solid #e0e6e3; border-radius: 16rpx; color: #29342f; background: #fbfcfc; font-size: 22rpx; line-height: 1.7; }
.placeholder { color: #aeb7b2; }
.count { position: absolute; right: 18rpx; bottom: 16rpx; color: #a1aaa5; font-size: 17rpx; }
.submit { width: 100%; height: 94rpx; display: flex; align-items: center; justify-content: center; gap: 17rpx; margin-top: 30rpx; border: 0; border-radius: 20rpx; color: #fff; background: linear-gradient(100deg,#079c5c,#067d4a); box-shadow: 0 13rpx 28rpx rgba(3,129,75,.25); font-size: 28rpx; font-weight: 800; letter-spacing: 1rpx; }
.submit::after { border: 0; }
.submit[disabled] { opacity: .7; }
.submit-spark { font-size: 31rpx; }
.footer-note { display: flex; align-items: center; justify-content: center; gap: 13rpx; padding: 46rpx 28rpx calc(10rpx + env(safe-area-inset-bottom)); color: #176744; font-family: 'STSong','Songti SC',serif; font-size: 22rpx; letter-spacing: 3rpx; }
.line { width: 50rpx; height: 1rpx; background: #9cc8b3; }
.footer-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #8cbda6; }
</style>
+6
View File
@@ -0,0 +1,6 @@
export {}
declare module "vue" {
type Hooks = App.AppInstance & Page.PageInstance;
interface ComponentCustomOptions extends Hooks {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

@@ -0,0 +1,59 @@
export type Theme = '亲子' | '情侣' | '朋友' | '银发' | '研学'
export type Interest = '自然风光' | '文化场馆' | '生态科普' | '美食' | '摄影'
export type Duration = 'half_day' | 'full_day'
export type Pace = 'relaxed' | 'moderate' | 'compact'
export type Transport = 'walking' | 'driving' | 'public_transport'
export type BudgetLevel = 'economy' | 'standard' | 'quality'
export interface TravelPreferences {
destination: '深圳市光明区'
themes: Theme[]
duration: Duration
pace: Pace
interests: Interest[]
adults: number
children: number
childAges: number[]
transport: Transport
budgetLevel: BudgetLevel
extraRequirements: string
}
export interface ItineraryItem {
startTime: string
endTime: string
placeId: string
placeName: string
activity: string
reason: string
transferFromPreviousMinutes: number | null
tips: string[]
}
export interface ItinerarySource {
placeId: string
sourceName: string
sourceUrl: string
updatedAt: string
}
export interface Itinerary {
title: string
summary: string
totalMinutes: number
estimatedCostText: string
items: ItineraryItem[]
notes: string[]
sources: ItinerarySource[]
}
export interface PlanResponse {
conversationId: string
assistantMessage: string
itinerary: Itinerary
changeSummary?: string | null
}
export interface APIError {
detail?: string
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgba(0, 0, 0, 0.4); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"lib": ["esnext", "dom"],
"types": ["@dcloudio/types"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import uni from "@dcloudio/vite-plugin-uni";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [uni()],
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
},
},
});
+6
View File
@@ -0,0 +1,6 @@
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=
LLM_TIMEOUT_SECONDS=45
LLM_MODE=mock
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
+7
View File
@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
.pytest_cache/
*.pyc
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
"""Guangming travel assistant POC."""
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Optional
from .schemas import TravelPreferences
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
class KnowledgeBase:
def __init__(self) -> None:
self.places: list[dict[str, Any]] = json.loads(
(DATA_DIR / "places.json").read_text(encoding="utf-8")
)
travel_rows = json.loads(
(DATA_DIR / "travel_times.json").read_text(encoding="utf-8")
)
self.travel_times = {
(row["from"], row["to"]): row for row in travel_rows
}
@property
def count(self) -> int:
return len(self.places)
def by_id(self, place_id: str) -> Optional[dict[str, Any]]:
return next((place for place in self.places if place["id"] == place_id), None)
def retrieve(self, preferences: TravelPreferences, limit: int = 8) -> list[dict[str, Any]]:
requirements = preferences.extra_requirements.lower()
indoor_required = any(
token in requirements for token in ("室内", "下雨", "雨天", "避雨")
)
avoid_climbing = any(
token in requirements
for token in ("不爬山", "不要爬山", "不登山", "体力不好", "婴儿车")
)
youngest = min(preferences.child_ages) if preferences.child_ages else None
scored: list[tuple[int, str, dict[str, Any]]] = []
for place in self.places:
if place["city"] != "深圳市" or place["district"] != "光明区":
continue
if youngest is not None and youngest < place.get("minAge", 0):
continue
if avoid_climbing and place.get("intensity") == "high":
continue
if indoor_required and not (
place.get("indoor") or place.get("rainyDaySuitable")
):
continue
score = 0
score += 3 * len(set(preferences.themes) & set(place.get("themes", [])))
score += 3 * len(
set(preferences.interests) & set(place.get("interests", []))
)
if preferences.children and "儿童" in place.get("suitableFor", []):
score += 2
elif not preferences.children and "成人" in place.get("suitableFor", []):
score += 2
if preferences.pace.value in place.get("pace", []):
score += 1
if preferences.budget_level.value == place.get("budgetLevel"):
score += 1
if indoor_required and (
place.get("indoor") or place.get("rainyDaySuitable")
):
score += 2
scored.append((score, place["name"], place))
scored.sort(key=lambda item: (-item[0], item[1]))
return [place for _, _, place in scored[:limit]]
def travel_minutes(
self, from_id: str, to_id: str, transport: str
) -> Optional[int]:
row = self.travel_times.get((from_id, to_id)) or self.travel_times.get(
(to_id, from_id)
)
if not row:
return None
value = row.get(transport)
return int(value) if value is not None else None
def travel_context(
self, candidates: list[dict[str, Any]], transport: str
) -> list[dict[str, Any]]:
ids = {place["id"] for place in candidates}
result = []
for row in self.travel_times.values():
if row["from"] in ids and row["to"] in ids:
result.append(
{
"from": row["from"],
"to": row["to"],
"minutes": row.get(transport),
"note": row.get("note", "POC估算,请以实际导航为准"),
}
)
return result
+203
View File
@@ -0,0 +1,203 @@
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta
from typing import Any, Optional
from pydantic import ValidationError
from .knowledge import KnowledgeBase
from .prompts import SYSTEM_PROMPT
from .schemas import GeneratedItinerary, Pace, TravelPreferences
class LLMError(RuntimeError):
pass
class LLMTimeout(LLMError):
pass
class LLMService:
def __init__(self, knowledge: KnowledgeBase) -> None:
self.knowledge = knowledge
self.mode = os.getenv("LLM_MODE", "mock").lower()
self.timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "45"))
@property
def configured(self) -> bool:
if self.mode == "mock":
return True
return bool(os.getenv("OPENAI_API_KEY") and os.getenv("OPENAI_MODEL"))
async def generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary] = None,
user_message: Optional[str] = None,
) -> GeneratedItinerary:
if self.mode == "mock":
return self._mock_generate(preferences, candidates, previous, user_message)
return await self._real_generate(preferences, candidates, previous, user_message)
async def _real_generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary],
user_message: Optional[str],
) -> GeneratedItinerary:
if not self.configured:
raise LLMError("真实模型尚未配置")
try:
from openai import APITimeoutError, AsyncOpenAI
except ImportError as exc:
raise LLMError("缺少 openai 依赖") from exc
client = AsyncOpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.getenv("OPENAI_BASE_URL") or None,
timeout=self.timeout,
)
payload = {
"preferences": preferences.model_dump(by_alias=True),
"candidates": candidates,
"travelTimes": self.knowledge.travel_context(
candidates, preferences.transport.value
),
"previousItinerary": (
previous.model_dump(by_alias=True) if previous else None
),
"adjustmentRequest": user_message,
}
feedback = ""
allowed_ids = {place["id"] for place in candidates}
for attempt in range(2):
try:
response = await client.chat.completions.create(
model=os.environ["OPENAI_MODEL"],
temperature=0.2,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": json.dumps(payload, ensure_ascii=False)
+ feedback,
},
],
)
except APITimeoutError as exc:
raise LLMTimeout("模型调用超时") from exc
except Exception as exc:
raise LLMError(f"模型调用失败:{exc}") from exc
try:
content = response.choices[0].message.content or "{}"
itinerary = GeneratedItinerary.model_validate_json(content)
unknown = {
item.place_id for item in itinerary.items
} - allowed_ids
if unknown:
raise ValueError(f"包含未知地点ID{sorted(unknown)}")
return itinerary
except (ValidationError, ValueError, json.JSONDecodeError) as exc:
if attempt == 1:
raise LLMError(f"模型输出无法通过结构校验:{exc}") from exc
feedback = f"\n上次输出校验失败:{exc}。请重新输出完整合法 JSON。"
raise LLMError("模型未返回有效结果")
def _mock_generate(
self,
preferences: TravelPreferences,
candidates: list[dict[str, Any]],
previous: Optional[GeneratedItinerary],
user_message: Optional[str],
) -> GeneratedItinerary:
if not candidates:
raise LLMError("没有符合条件的候选地点")
max_items = {
Pace.RELAXED: 3,
Pace.MODERATE: 4,
Pace.COMPACT: 5,
}[preferences.pace]
target = 240 if preferences.duration.value == "half_day" else 480
start = datetime(2026, 1, 1, 9, 0)
elapsed = 0
items = []
previous_place: Optional[dict[str, Any]] = None
for place in candidates:
if len(items) >= max_items:
break
transfer_value: Optional[int] = 0
if previous_place:
transfer_value = self.knowledge.travel_minutes(
previous_place["id"],
place["id"],
preferences.transport.value,
)
transfer_for_math = transfer_value or 0
duration = int(place.get("recommendedMinutes", 75))
if elapsed + transfer_for_math + duration > target + 30 and items:
continue
item_start = start + timedelta(minutes=transfer_for_math)
end = item_start + timedelta(minutes=duration)
items.append(
{
"startTime": item_start.strftime("%H:%M"),
"endTime": end.strftime("%H:%M"),
"placeId": place["id"],
"placeName": place["name"],
"activity": place["summary"],
"reason": self._reason(preferences, place),
"transferFromPreviousMinutes": (
transfer_value if previous_place else 0
),
"tips": place.get("tips", [])[:2],
}
)
elapsed += transfer_for_math + duration
start = end
previous_place = place
themes = "".join(preferences.themes or preferences.interests[:1])
adjustment = f";已响应“{user_message}" if user_message else ""
pace_label = {
Pace.RELAXED: "轻松",
Pace.MODERATE: "适中",
Pace.COMPACT: "紧凑",
}[preferences.pace]
return GeneratedItinerary.model_validate(
{
"title": f"光明区{themes or '精选'}{'半日' if preferences.duration.value == 'half_day' else '一日'}",
"summary": f"以科学、人文与都市自然为线索,按{pace_label}节奏安排{adjustment}",
"totalMinutes": max(elapsed, 1),
"estimatedCostText": "费用以场馆、景区及实际交通信息为准",
"items": items,
"notes": [
"开放时间、预约和票价请在出行前通过官方渠道再次确认。",
"交通耗时为POC估算,请以出发时的实际导航为准。",
],
}
)
@staticmethod
def _reason(
preferences: TravelPreferences, place: dict[str, Any]
) -> str:
matches = list(
(set(preferences.themes) & set(place.get("themes", [])))
| (set(preferences.interests) & set(place.get("interests", [])))
)
return (
f"符合你的{''.join(sorted(matches))}偏好"
if matches
else "作为光明区同路线备选,便于控制整体节奏"
)
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import os
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from .knowledge import KnowledgeBase
from .llm import LLMError, LLMService, LLMTimeout
from .planner import PlannerService, SessionStore
from .schemas import HealthResponse, MessageRequest, PlanResponse, TravelPreferences
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# Mock mode can run without python-dotenv; production installs project deps.
pass
knowledge = KnowledgeBase()
llm = LLMService(knowledge)
planner = PlannerService(knowledge, llm, SessionStore())
app = FastAPI(
title="光明区文旅行程助手 API",
version="0.1.0",
description="POC:本地知识检索 + OpenAI 兼容模型 + 内存会话",
)
origins = [
origin.strip()
for origin in os.getenv(
"CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
).split(",")
if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
@app.get("/api/v1/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(
status="ok",
llm_mode=llm.mode,
model_configured=llm.configured,
knowledge_count=knowledge.count,
)
@app.post("/api/v1/plans", response_model=PlanResponse)
async def create_plan(preferences: TravelPreferences) -> PlanResponse:
try:
return await planner.create(preferences)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LLMTimeout as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except (LLMError, ValueError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.post(
"/api/v1/conversations/{conversation_id}/messages",
response_model=PlanResponse,
)
async def adjust_plan(
conversation_id: str, request: MessageRequest
) -> PlanResponse:
try:
return await planner.adjust(conversation_id, request.message)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LLMTimeout as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except (LLMError, ValueError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import time
import uuid
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Optional
from .knowledge import KnowledgeBase
from .llm import LLMService
from .schemas import (
GeneratedItinerary,
Itinerary,
PlanResponse,
Source,
TravelPreferences,
)
@dataclass
class Session:
preferences: TravelPreferences
itinerary: GeneratedItinerary
created_at: float
history: list[str] = field(default_factory=list)
class SessionStore:
def __init__(self, max_size: int = 100, ttl_seconds: int = 7200) -> None:
self.max_size = max_size
self.ttl_seconds = ttl_seconds
self.sessions: OrderedDict[str, Session] = OrderedDict()
def put(self, session: Session) -> str:
self.cleanup()
while len(self.sessions) >= self.max_size:
self.sessions.popitem(last=False)
session_id = str(uuid.uuid4())
self.sessions[session_id] = session
return session_id
def get(self, session_id: str) -> Optional[Session]:
self.cleanup()
session = self.sessions.get(session_id)
if session:
self.sessions.move_to_end(session_id)
return session
def cleanup(self) -> None:
deadline = time.time() - self.ttl_seconds
expired = [
key for key, session in self.sessions.items()
if session.created_at < deadline
]
for key in expired:
self.sessions.pop(key, None)
class PlannerService:
def __init__(
self,
knowledge: KnowledgeBase,
llm: LLMService,
store: SessionStore,
) -> None:
self.knowledge = knowledge
self.llm = llm
self.store = store
async def create(self, preferences: TravelPreferences) -> PlanResponse:
candidates = self.knowledge.retrieve(preferences)
if not candidates:
raise LookupError("没有找到符合当前条件的光明区地点")
generated = await self.llm.generate(preferences, candidates)
generated = self._canonicalize(generated, candidates)
session_id = self.store.put(
Session(preferences=preferences, itinerary=generated, created_at=time.time())
)
return PlanResponse(
conversation_id=session_id,
assistant_message="已根据你的偏好生成光明区行程。",
itinerary=self._with_sources(generated),
)
async def adjust(self, session_id: str, message: str) -> PlanResponse:
session = self.store.get(session_id)
if not session:
raise LookupError("会话不存在或已过期,请重新生成行程")
updated_preferences = session.preferences.model_copy(
update={
"extra_requirements": (
session.preferences.extra_requirements + "" + message
).strip("")
}
)
candidates = self.knowledge.retrieve(updated_preferences)
if not candidates:
raise LookupError("没有找到符合调整条件的光明区地点")
generated = await self.llm.generate(
updated_preferences, candidates, session.itinerary, message
)
generated = self._canonicalize(generated, candidates)
session.preferences = updated_preferences
session.itinerary = generated
session.history.append(message)
return PlanResponse(
conversation_id=session_id,
assistant_message="已按你的补充要求调整行程。",
change_summary=f"已根据“{message}”更新相关安排。",
itinerary=self._with_sources(generated),
)
def _canonicalize(
self,
itinerary: GeneratedItinerary,
candidates: list[dict],
) -> GeneratedItinerary:
candidate_map = {place["id"]: place for place in candidates}
for item in itinerary.items:
place = candidate_map.get(item.place_id)
if not place:
raise ValueError(f"行程包含知识库外地点:{item.place_id}")
item.place_name = place["name"]
item.tips = [
tip for tip in item.tips if tip in place.get("tips", [])
] or place.get("tips", [])[:2]
return itinerary
def _with_sources(self, generated: GeneratedItinerary) -> Itinerary:
seen: set[str] = set()
sources: list[Source] = []
for item in generated.items:
if item.place_id in seen:
continue
seen.add(item.place_id)
place = self.knowledge.by_id(item.place_id)
if place:
sources.append(
Source(
place_id=place["id"],
source_name=place["sourceName"],
source_url=place["sourceUrl"],
updated_at=place["updatedAt"],
)
)
return Itinerary(
**generated.model_dump(),
sources=sources,
)
+26
View File
@@ -0,0 +1,26 @@
SYSTEM_PROMPT = """你是深圳市光明区文旅行程规划助手。
你只能使用“候选地点”中出现的 placeId,不得创造地点。
不得虚构开放时间、票价、预约、天气或交通耗时;缺少交通数据时必须输出 null。
优先满足年龄、安全、室内外、体力和用户明确排除条件。
半日游控制在约4小时,一日游控制在约8小时;轻松、适中、紧凑最多安排3、4、5个地点。
修改已有行程时,只修改用户要求影响的部分,尽量保留其余安排。
只输出一个 JSON 对象,不要输出 Markdown、代码围栏或解释文字。
JSON 结构:
{
"title": "字符串",
"summary": "字符串",
"totalMinutes": 240,
"estimatedCostText": "费用说明,不编造精确价格",
"items": [{
"startTime": "09:00",
"endTime": "10:30",
"placeId": "候选地点ID",
"placeName": "候选地点名称",
"activity": "活动安排",
"reason": "匹配理由",
"transferFromPreviousMinutes": null,
"tips": ["知识库内的提示"]
}],
"notes": ["动态信息需出行前确认"]
}"""
+115
View File
@@ -0,0 +1,115 @@
from enum import Enum
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
def to_camel(value: str) -> str:
parts = value.split("_")
return parts[0] + "".join(part.capitalize() for part in parts[1:])
class APIModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
class Duration(str, Enum):
HALF_DAY = "half_day"
FULL_DAY = "full_day"
class Pace(str, Enum):
RELAXED = "relaxed"
MODERATE = "moderate"
COMPACT = "compact"
class Transport(str, Enum):
WALKING = "walking"
DRIVING = "driving"
PUBLIC_TRANSPORT = "public_transport"
class BudgetLevel(str, Enum):
ECONOMY = "economy"
STANDARD = "standard"
QUALITY = "quality"
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
class TravelPreferences(APIModel):
destination: Literal["深圳市光明区"] = "深圳市光明区"
themes: list[Theme] = Field(default_factory=list)
duration: Duration
pace: Pace
interests: list[Interest] = Field(default_factory=list)
adults: int = Field(default=2, ge=0, le=20)
children: int = Field(default=0, ge=0, le=20)
child_ages: list[int] = Field(default_factory=list)
transport: Transport = Transport.PUBLIC_TRANSPORT
budget_level: BudgetLevel = BudgetLevel.STANDARD
extra_requirements: str = Field(default="", max_length=500)
@model_validator(mode="after")
def validate_group_and_preferences(self) -> "TravelPreferences":
if self.adults + self.children < 1:
raise ValueError("出行总人数至少为1")
if self.child_ages and len(self.child_ages) != self.children:
raise ValueError("儿童年龄数量必须与儿童人数一致")
if any(age < 0 or age > 17 for age in self.child_ages):
raise ValueError("儿童年龄须在0至17岁之间")
if not self.themes and not self.interests:
raise ValueError("主题或特色偏好至少选择一项")
return self
class ItineraryItem(APIModel):
start_time: str
end_time: str
place_id: str
place_name: str
activity: str
reason: str
transfer_from_previous_minutes: Optional[int] = Field(default=None, ge=0)
tips: list[str] = Field(default_factory=list)
class GeneratedItinerary(APIModel):
title: str
summary: str
total_minutes: int = Field(ge=1, le=720)
estimated_cost_text: str
items: list[ItineraryItem] = Field(min_length=1, max_length=5)
notes: list[str] = Field(default_factory=list)
class Source(APIModel):
place_id: str
source_name: str
source_url: str
updated_at: str
class Itinerary(GeneratedItinerary):
sources: list[Source]
class PlanResponse(APIModel):
conversation_id: str
assistant_message: str
itinerary: Itinerary
change_summary: Optional[str] = None
class MessageRequest(APIModel):
message: str = Field(min_length=1, max_length=500)
class HealthResponse(APIModel):
status: str
llm_mode: str
model_configured: bool
knowledge_count: int
+423
View File
@@ -0,0 +1,423 @@
[
{
"id": "guangming-science-museum",
"city": "深圳市",
"district": "光明区",
"name": "深圳科学技术馆",
"type": "museum",
"area": "光明中心区",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 3,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 150,
"budgetLevel": "standard",
"address": "深圳市光明区光辉大道8号",
"latitude": null,
"longitude": null,
"openTimeText": "开放与预约信息请以场馆官方最新公告为准",
"ticketText": "票务信息请以场馆官方最新公告为准",
"summary": "围绕数字文明与通信科技设置互动展项,适合科普参观和亲子研学。",
"tips": ["热门时段建议提前查询预约信息", "场馆较大,亲子家庭可预留充足时间"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12151090.html",
"updatedAt": "2025-04-29"
},
{
"id": "guangming-science-park",
"city": "深圳市",
"district": "光明区",
"name": "科学公园",
"type": "park",
"area": "光明中心区",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 90,
"budgetLevel": "economy",
"address": "深圳市光明区光明中心区",
"latitude": null,
"longitude": null,
"openTimeText": "开放区域请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "以山林、步道和生态体验连接科学城公共空间,适合散步和轻量户外活动。",
"tips": ["户外活动注意防晒和补水", "雨天请留意步道路面状况"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12235727.html",
"updatedAt": "2025-06-19"
},
{
"id": "guangming-happy-farm",
"city": "深圳市",
"district": "光明区",
"name": "光明欢乐田园",
"type": "park",
"area": "新湖街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 120,
"budgetLevel": "economy",
"address": "深圳市光明区新湖街道楼村一号路",
"latitude": null,
"longitude": null,
"openTimeText": "花期、开放区域及活动请以园区最新公告为准",
"ticketText": "费用请以园区最新公告为准",
"summary": "都市田园景观随季节变化,可体验农田、花景与开阔田园空间。",
"tips": ["景观受季节影响,出行前建议查询当期花田信息", "户外区域注意防晒"],
"sourceName": "光明区文化广电旅游体育局",
"sourceUrl": "https://www.szgm.gov.cn/zjgm/lsgm_118488/tsgm_118491/content/post_8758374.html",
"updatedAt": "2025-03-24"
},
{
"id": "guangming-farm-grand-view",
"city": "深圳市",
"district": "光明区",
"name": "光明农场大观园",
"type": "attraction",
"area": "光明街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 2,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 150,
"budgetLevel": "standard",
"address": "深圳市光明区光明街道",
"latitude": null,
"longitude": null,
"openTimeText": "开放时间和活动安排请以景区公告为准",
"ticketText": "购票信息请以景区官方渠道为准",
"summary": "以农场、动物互动和自然科普为特色,适合亲子家庭和研学体验。",
"tips": ["动物互动和主题活动以当日安排为准", "建议为儿童准备替换衣物"],
"sourceName": "深圳市光明区人民政府在线",
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12164420.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-hongqiao-park",
"city": "深圳市",
"district": "光明区",
"name": "虹桥公园",
"type": "park",
"area": "光明街道",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "medium",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 100,
"budgetLevel": "economy",
"address": "深圳市光明区光侨路以东、观光路以北",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "以醒目的红色桥体和山林绿意形成城市生态地标,适合步行和摄影。",
"tips": ["路线较长,可按体力选择折返", "户外步行注意防晒和补水"],
"sourceName": "光明区城市管理和综合执法局",
"sourceUrl": "https://www.szgm.gov.cn/gmcsglj/gkmlpt/content/10/10284/post_10284402.html",
"updatedAt": "2022-12-13"
},
{
"id": "guangming-dadingling-greenway",
"city": "深圳市",
"district": "光明区",
"name": "大顶岭绿道",
"type": "greenway",
"area": "光明街道",
"themes": ["情侣", "朋友", "研学"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["成人"],
"minAge": 8,
"pace": ["moderate", "compact"],
"intensity": "high",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 150,
"budgetLevel": "economy",
"address": "深圳市光明区大顶岭片区",
"latitude": null,
"longitude": null,
"openTimeText": "绿道开放情况请以管理方公告为准",
"ticketText": "费用信息请以现场公告为准",
"summary": "以悬桥、探桥和浮桥等节点串联山林生态,是具有运动强度的绿道体验。",
"tips": ["需要较多步行,不适合婴儿车和体力较弱人群", "雨天不建议前往"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163137.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-culture-art-center",
"city": "深圳市",
"district": "光明区",
"name": "光明文化艺术中心",
"type": "cultural_center",
"area": "凤凰街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["文化场馆", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 120,
"budgetLevel": "economy",
"address": "深圳市光明区观光路东北侧",
"latitude": null,
"longitude": null,
"openTimeText": "图书馆、展览和演出开放安排请分别查询官方公告",
"ticketText": "各空间及活动费用请以官方公告为准",
"summary": "集图书馆、演艺、展览与休闲空间于一体的综合文化地标。",
"tips": ["不同场馆开放安排可能不同", "观演活动通常需要单独预约或购票"],
"sourceName": "光明区文化广电旅游体育局",
"sourceUrl": "https://www.szgm.gov.cn/zjgm/lsgm_118488/tsgm_118491/content/post_8774880.html",
"updatedAt": "2025-03-24"
},
{
"id": "guangming-science-city-exhibition",
"city": "深圳市",
"district": "光明区",
"name": "光明科学城展示中心",
"type": "exhibition",
"area": "新湖街道",
"themes": ["朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区新湖街道圳园路南侧",
"latitude": null,
"longitude": null,
"openTimeText": "接待和开放信息请提前向官方渠道确认",
"ticketText": "参观安排请以官方公告为准",
"summary": "以展览展示功能介绍光明科学城规划与建设,适合城市与科学主题研学。",
"tips": ["并非常规全天候开放景点,务必提前确认", "适合与科学公园或欢乐田园组合"],
"sourceName": "光明区科学城开发建设署",
"sourceUrl": "https://www.szgm.gov.cn/gmcsfzcjzx/gkmlpt/content/10/10366/post_10366147.html",
"updatedAt": "2023-01-03"
},
{
"id": "guangming-ebohr-museum",
"city": "深圳市",
"district": "光明区",
"name": "依波钟表文化博物馆",
"type": "museum",
"area": "马田街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区钟表产业基地依波大厦",
"latitude": null,
"longitude": null,
"openTimeText": "工业旅游参观安排请提前向场馆确认",
"ticketText": "参观费用请以场馆最新信息为准",
"summary": "通过古代计时、钟表历史和制表技术展项展示时间文化与工业设计。",
"tips": ["工业旅游场馆建议提前预约确认", "适合学龄儿童及成人"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12165124.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-fiyta-center",
"city": "深圳市",
"district": "光明区",
"name": "飞亚达计时文化中心",
"type": "museum",
"area": "马田街道",
"themes": ["亲子", "朋友", "研学"],
"interests": ["文化场馆", "生态科普"],
"suitableFor": ["儿童", "成人"],
"minAge": 6,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区钟表产业基地",
"latitude": null,
"longitude": null,
"openTimeText": "工业旅游参观安排请提前向场馆确认",
"ticketText": "参观费用请以场馆最新信息为准",
"summary": "融合钟表文化、工业旅游和科普教育,包含航天主题展示与制表体验。",
"tips": ["体验项目和开放安排需要提前确认", "适合作为雨天研学备选"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12165107.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-honghua-park",
"city": "深圳市",
"district": "光明区",
"name": "红花山公园",
"type": "park",
"area": "公明街道",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 80,
"budgetLevel": "economy",
"address": "深圳市光明区公明街道",
"latitude": null,
"longitude": null,
"openTimeText": "开放区域请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "结合荔枝林、草坪、儿童活动和休闲设施的社区型生态公园。",
"tips": ["亲子活动请留意儿童设施现场状态", "户外活动注意防晒"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12164759.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-rosewood-town",
"city": "深圳市",
"district": "光明区",
"name": "光明红木文化古镇",
"type": "cultural_town",
"area": "公明街道",
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
"interests": ["文化场馆", "美食", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": true,
"rainyDaySuitable": true,
"recommendedMinutes": 100,
"budgetLevel": "standard",
"address": "深圳市光明区公明街道北环大道与民生路交会处",
"latitude": null,
"longitude": null,
"openTimeText": "各展馆和商户开放安排请以现场公告为准",
"ticketText": "费用请以现场公告为准",
"summary": "以红木工艺和岭南文化为主题,包含文化展陈、传统街区和特色商业。",
"tips": ["各主题空间开放时间可能不同", "适合安排为文化参观与用餐衔接点"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12164714.html",
"updatedAt": "2025-05-09"
},
{
"id": "guangming-nanguang-green-zone",
"city": "深圳市",
"district": "光明区",
"name": "南光绿境",
"type": "park",
"area": "茅洲河沿线",
"themes": ["亲子", "情侣", "朋友"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区茅洲河沿线",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园现场公告为准",
"ticketText": "活动项目费用请以运营方公告为准",
"summary": "由旧净水设施及桥下空间更新而成的滨水休闲区,包含运动和儿童活动空间。",
"tips": ["水上活动需另行确认开放与安全要求", "儿童在滨水区域需由成人看护"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163075.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-zuoan-tech-park",
"city": "深圳市",
"district": "光明区",
"name": "左岸科技公园",
"type": "park",
"area": "茅洲河沿线",
"themes": ["亲子", "情侣", "朋友", "银发"],
"interests": ["自然风光", "生态科普", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 75,
"budgetLevel": "economy",
"address": "深圳市光明区茅洲河沿线",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围请以公园现场公告为准",
"ticketText": "公共区域费用信息请以现场公告为准",
"summary": "沿茅洲河建设的生态修复型公园,适合滨水散步、观察城市生态更新。",
"tips": ["滨水步行注意儿童安全", "雨天请留意步道路面"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163102.html",
"updatedAt": "2025-05-08"
},
{
"id": "guangming-minghu-park",
"city": "深圳市",
"district": "光明区",
"name": "明湖城市公园",
"type": "park",
"area": "马田街道",
"themes": ["亲子", "情侣", "朋友"],
"interests": ["自然风光", "摄影"],
"suitableFor": ["儿童", "成人"],
"minAge": 0,
"pace": ["relaxed", "moderate"],
"intensity": "low",
"indoor": false,
"rainyDaySuitable": false,
"recommendedMinutes": 90,
"budgetLevel": "economy",
"address": "深圳市光明区光明大道、光明国际汽车城旁",
"latitude": null,
"longitude": null,
"openTimeText": "开放范围及活动请以公园公告为准",
"ticketText": "活动项目费用请以现场公告为准",
"summary": "融合湖畔休闲、亲子市集和汽车主题活动的城市公园。",
"tips": ["露营和市集活动并非常设内容", "湖边活动请照看儿童"],
"sourceName": "Guangming Government Online",
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163086.html",
"updatedAt": "2025-05-08"
}
]
@@ -0,0 +1,18 @@
[
{"from":"guangming-science-museum","to":"guangming-science-park","walking":12,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-park","to":"guangming-happy-farm","walking":35,"driving":10,"public_transport":24,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-museum","to":"guangming-happy-farm","walking":42,"driving":12,"public_transport":28,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-happy-farm","to":"guangming-farm-grand-view","walking":30,"driving":9,"public_transport":22,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-farm-grand-view","to":"guangming-hongqiao-park","walking":28,"driving":9,"public_transport":20,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-hongqiao-park","to":"guangming-dadingling-greenway","walking":15,"driving":6,"public_transport":18,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-museum","to":"guangming-culture-art-center","walking":55,"driving":14,"public_transport":28,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-park","to":"guangming-science-city-exhibition","walking":22,"driving":7,"public_transport":17,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-science-city-exhibition","to":"guangming-happy-farm","walking":18,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-ebohr-museum","to":"guangming-fiyta-center","walking":18,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-ebohr-museum","to":"guangming-minghu-park","walking":35,"driving":10,"public_transport":22,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-fiyta-center","to":"guangming-minghu-park","walking":30,"driving":9,"public_transport":20,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-honghua-park","to":"guangming-rosewood-town","walking":25,"driving":8,"public_transport":18,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-rosewood-town","to":"guangming-nanguang-green-zone","walking":45,"driving":12,"public_transport":25,"note":"POC估算,请以实际导航为准"},
{"from":"guangming-nanguang-green-zone","to":"guangming-zuoan-tech-park","walking":22,"driving":7,"public_transport":16,"note":"POC估算,请以实际导航为准"}
]
@@ -0,0 +1,13 @@
Metadata-Version: 2.4
Name: guangming-travel-assistant
Version: 0.1.0
Summary: Shenzhen Guangming travel planning assistant POC
Requires-Python: >=3.9
Requires-Dist: fastapi<1.0.0,>=0.115.0
Requires-Dist: openai<2.0.0,>=1.50.0
Requires-Dist: pydantic<2.13.0,>=2.8.0
Requires-Dist: python-dotenv<2.0.0,>=1.0.0
Requires-Dist: uvicorn[standard]<1.0.0,>=0.30.0
Provides-Extra: test
Requires-Dist: httpx<1.0.0,>=0.27.0; extra == "test"
Requires-Dist: pytest<9.0.0,>=8.0.0; extra == "test"
@@ -0,0 +1,15 @@
pyproject.toml
app/__init__.py
app/knowledge.py
app/llm.py
app/main.py
app/planner.py
app/prompts.py
app/schemas.py
guangming_travel_assistant.egg-info/PKG-INFO
guangming_travel_assistant.egg-info/SOURCES.txt
guangming_travel_assistant.egg-info/dependency_links.txt
guangming_travel_assistant.egg-info/requires.txt
guangming_travel_assistant.egg-info/top_level.txt
tests/test_api.py
tests/test_knowledge.py
@@ -0,0 +1,9 @@
fastapi<1.0.0,>=0.115.0
openai<2.0.0,>=1.50.0
pydantic<2.13.0,>=2.8.0
python-dotenv<2.0.0,>=1.0.0
uvicorn[standard]<1.0.0,>=0.30.0
[test]
httpx<1.0.0,>=0.27.0
pytest<9.0.0,>=8.0.0
+29
View File
@@ -0,0 +1,29 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "guangming-travel-assistant"
version = "0.1.0"
description = "Shenzhen Guangming travel planning assistant POC"
requires-python = ">=3.9"
dependencies = [
"fastapi>=0.115.0,<1.0.0",
"openai>=1.50.0,<2.0.0",
"pydantic>=2.8.0,<2.13.0",
"python-dotenv>=1.0.0,<2.0.0",
"uvicorn[standard]>=0.30.0,<1.0.0",
]
[project.optional-dependencies]
test = [
"httpx>=0.27.0,<1.0.0",
"pytest>=8.0.0,<9.0.0",
]
[tool.setuptools]
packages = ["app"]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+99
View File
@@ -0,0 +1,99 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def preferences(**overrides):
payload = {
"destination": "深圳市光明区",
"themes": ["亲子"],
"duration": "half_day",
"pace": "relaxed",
"interests": ["自然风光", "生态科普"],
"adults": 2,
"children": 1,
"childAges": [6],
"transport": "public_transport",
"budgetLevel": "standard",
"extraRequirements": "",
}
payload.update(overrides)
return payload
def test_health_reports_knowledge_and_mock_model():
response = client.get("/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["knowledgeCount"] >= 15
assert data["modelConfigured"] is True
def test_create_plan_only_returns_known_guangming_places():
response = client.post("/api/v1/plans", json=preferences())
assert response.status_code == 200
data = response.json()
assert data["conversationId"]
assert data["itinerary"]["items"]
assert len(data["itinerary"]["items"]) >= 2
assert len(data["itinerary"]["items"]) <= 3
assert all(
item["placeId"].startswith("guangming-")
for item in data["itinerary"]["items"]
)
assert len(data["itinerary"]["sources"]) == len(
{item["placeId"] for item in data["itinerary"]["items"]}
)
def test_indoor_requirement_filters_out_outdoor_places():
response = client.post(
"/api/v1/plans",
json=preferences(
themes=["研学"],
interests=["文化场馆"],
extraRequirements="下午下雨,只安排室内项目",
),
)
assert response.status_code == 200
outdoor_ids = {
"guangming-science-park",
"guangming-happy-farm",
"guangming-hongqiao-park",
"guangming-dadingling-greenway",
}
ids = {item["placeId"] for item in response.json()["itinerary"]["items"]}
assert ids.isdisjoint(outdoor_ids)
def test_adjust_existing_plan():
created = client.post("/api/v1/plans", json=preferences()).json()
response = client.post(
f"/api/v1/conversations/{created['conversationId']}/messages",
json={"message": "改成室内研学路线"},
)
assert response.status_code == 200
data = response.json()
assert data["conversationId"] == created["conversationId"]
assert data["changeSummary"]
assert data["itinerary"]["items"]
def test_missing_preferences_are_rejected():
response = client.post(
"/api/v1/plans",
json=preferences(themes=[], interests=[]),
)
assert response.status_code == 422
def test_expired_or_unknown_session_is_404():
response = client.post(
"/api/v1/conversations/not-found/messages",
json={"message": "慢一点"},
)
assert response.status_code == 404
@@ -0,0 +1,31 @@
from app.knowledge import KnowledgeBase
from app.schemas import TravelPreferences
def make_preferences(extra_requirements: str = "") -> TravelPreferences:
return TravelPreferences(
themes=["亲子"],
duration="half_day",
pace="relaxed",
interests=["自然风光"],
adults=2,
children=1,
child_ages=[3],
transport="public_transport",
budget_level="standard",
extra_requirements=extra_requirements,
)
def test_retrieval_is_deterministic():
knowledge = KnowledgeBase()
first = [place["id"] for place in knowledge.retrieve(make_preferences())]
second = [place["id"] for place in knowledge.retrieve(make_preferences())]
assert first == second
def test_avoid_climbing_excludes_high_intensity_places():
knowledge = KnowledgeBase()
result = knowledge.retrieve(make_preferences("带婴儿车,不爬山"))
assert all(place["intensity"] != "high" for place in result)
+7
View File
@@ -0,0 +1,7 @@
// app.js
App({
onLaunch() {
// 可以在此做全局初始化,如上报启动日志等
},
globalData: {}
});
+14
View File
@@ -0,0 +1,14 @@
{
"pages": [
"pages/poi/poi",
"pages/badges/badges"
],
"window": {
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTitleText": "打卡小程序",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}
+11
View File
@@ -0,0 +1,11 @@
/**app.wxss**/
page {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #333;
background: #fff8ec;
}
/* 通用按钮重置:去掉小程序 button 默认的边框和圆角 */
button::after {
border: none;
}
+52
View File
@@ -0,0 +1,52 @@
// pages/badges/badges.js
const userStore = require('../../userStore.js');
Page({
data: {
points: 0,
badges: []
},
onLoad() {
this.refresh();
},
// 每次显示都刷新一次,保证从其他页面打卡回来后数据是最新的
onShow() {
this.refresh();
},
refresh() {
const data = userStore.getUserData();
// 将徽章数据处理为渲染友好的结构(格式化获得时间、补默认图标)
const badges = (data.badges || []).map(b => ({
name: b.name,
icon: this.getBadgeIcon(b.name),
obtainedAtText: this.formatTime(b.obtainedAt)
}));
this.setData({
points: data.points || 0,
badges
});
},
// 根据徽章名称返回对应的 emoji 图标(也可换成图片路径)
getBadgeIcon(name) {
const map = {
'探索者': '🧭',
'旅行家': '🌍',
'打卡达人': '🏆'
};
return map[name] || '🎖️';
},
// 格式化时间戳为 "YYYY-MM-DD HH:mm"
formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const pad = n => (n < 10 ? '0' + n : '' + n);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
});
+6
View File
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "我的徽章",
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
}
+37
View File
@@ -0,0 +1,37 @@
<!--pages/badges/badges.wxml-->
<view class="page">
<!-- 顶部积分卡片 -->
<view class="header-card">
<view class="header-label">我的总积分</view>
<view class="header-points">
<text class="points-num">{{points}}</text>
<text class="points-unit"> 分</text>
</view>
</view>
<!-- 徽章墙标题 -->
<view class="section-title">
<text>徽章墙</text>
<text class="section-count">共 {{badges.length}} 枚</text>
</view>
<!-- 徽章网格 -->
<block wx:if="{{badges.length > 0}}">
<view class="badge-grid">
<view class="badge-item" wx:for="{{badges}}" wx:key="name">
<view class="badge-icon">{{item.icon}}</view>
<view class="badge-name">{{item.name}}</view>
<view class="badge-time">{{item.obtainedAtText}}</view>
</view>
</view>
</block>
<!-- 空状态 -->
<view class="empty" wx:else>
<view class="empty-icon">🎖️</view>
<view class="empty-text">还没有获得徽章</view>
<view class="empty-tip">去景点打卡,赢取你的第一枚徽章吧~</view>
</view>
</view>
+127
View File
@@ -0,0 +1,127 @@
/* pages/badges/badges.wxss */
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background: #fff8ec;
}
/* 顶部积分卡片 */
.header-card {
background: linear-gradient(135deg, #ffb347 0%, #ff7e5f 100%);
border-radius: 24rpx;
padding: 40rpx 32rpx;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(255, 126, 95, 0.25);
}
.header-label {
font-size: 28rpx;
opacity: 0.9;
}
.header-points {
margin-top: 12rpx;
display: flex;
align-items: baseline;
}
.points-num {
font-size: 72rpx;
font-weight: 700;
line-height: 1;
}
.points-unit {
font-size: 30rpx;
margin-left: 8rpx;
}
/* 分区标题 */
.section-title {
margin: 40rpx 8rpx 20rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-count {
font-size: 24rpx;
font-weight: 400;
color: #999;
}
/* 徽章网格 */
.badge-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.badge-item {
background: #ffffff;
border-radius: 20rpx;
padding: 28rpx 12rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
transition: transform 0.2s;
}
.badge-item:active {
transform: scale(0.96);
}
.badge-icon {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fff2d6, #ffe0b3);
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
margin-bottom: 16rpx;
}
.badge-name {
font-size: 28rpx;
color: #333;
font-weight: 600;
margin-bottom: 6rpx;
}
.badge-time {
font-size: 20rpx;
color: #999;
text-align: center;
}
/* 空状态 */
.empty {
margin-top: 120rpx;
text-align: center;
color: #999;
}
.empty-icon {
font-size: 120rpx;
opacity: 0.5;
}
.empty-text {
margin-top: 24rpx;
font-size: 30rpx;
color: #666;
}
.empty-tip {
margin-top: 12rpx;
font-size: 24rpx;
color: #aaa;
}
+80
View File
@@ -0,0 +1,80 @@
// pages/poi/poi.js
const userStore = require('../../userStore.js');
// 模拟的景点数据,实际项目中可以来自云端接口
const POI_LIST = [
{ id: 'poi_001', name: '西湖', icon: '🌊', desc: '杭州最著名的湖泊' },
{ id: 'poi_002', name: '灵隐寺', icon: '🛕', desc: '千年古刹,禅意悠远' },
{ id: 'poi_003', name: '雷峰塔', icon: '🗼', desc: '白娘子传说所在地' },
{ id: 'poi_004', name: '西溪湿地', icon: '🌾', desc: '城市中的绿色湿地' },
{ id: 'poi_005', name: '千岛湖', icon: '🏝️', desc: '碧水青山,湖光山色' }
];
Page({
data: {
points: 0,
badgeCount: 0,
pois: []
},
onLoad() {
this.refresh();
},
onShow() {
this.refresh();
},
// 每次刷新:把打卡状态合并到景点列表中,同时更新顶部积分/徽章数
refresh() {
const data = userStore.getUserData();
const checkedSet = new Set(data.checkIns.map(c => c.poiId));
const pois = POI_LIST.map(p => ({
...p,
checked: checkedSet.has(p.id)
}));
this.setData({
points: data.points,
badgeCount: data.badges.length,
pois
});
},
// 点击打卡按钮
onCheckIn(e) {
const { id, name } = e.currentTarget.dataset;
const res = userStore.checkIn(id, name);
wx.showToast({
title: res.message,
icon: res.success ? 'success' : 'none',
duration: 1500
});
if (res.success) {
this.refresh();
}
},
// 跳转到徽章页面
goBadges() {
wx.navigateTo({ url: '/pages/badges/badges' });
},
// 调试用:清空本地数据
onResetData() {
wx.showModal({
title: '确认清空',
content: '将清除所有积分、徽章和打卡记录',
success: (r) => {
if (r.confirm) {
wx.clearStorageSync();
this.refresh();
wx.showToast({ title: '已清空', icon: 'success' });
}
}
});
}
});
+6
View File
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "景点打卡",
"navigationBarBackgroundColor": "#fff8ec",
"navigationBarTextStyle": "black",
"backgroundColor": "#fff8ec"
}
+49
View File
@@ -0,0 +1,49 @@
<!--pages/poi/poi.wxml-->
<view class="page">
<!-- 顶部用户信息卡片 -->
<view class="header-card" bindtap="goBadges">
<view class="header-left">
<view class="header-label">我的积分</view>
<view class="header-points">
<text class="points-num">{{points}}</text>
<text class="points-unit"> 分</text>
</view>
</view>
<view class="header-right">
<view class="badge-count">
<text class="badge-count-num">{{badgeCount}}</text>
<text class="badge-count-label">枚徽章</text>
</view>
<view class="arrow">查看 </view>
</view>
</view>
<!-- 景点列表 -->
<view class="section-title">热门景点</view>
<view class="poi-list">
<view class="poi-item" wx:for="{{pois}}" wx:key="id">
<view class="poi-icon">{{item.icon}}</view>
<view class="poi-info">
<view class="poi-name">{{item.name}}</view>
<view class="poi-desc">{{item.desc}}</view>
</view>
<button
class="poi-btn {{item.checked ? 'poi-btn-done' : ''}}"
disabled="{{item.checked}}"
data-id="{{item.id}}"
data-name="{{item.name}}"
bindtap="onCheckIn"
>
{{item.checked ? '已打卡' : '打卡 +10'}}
</button>
</view>
</view>
<!-- 调试按钮:清空数据 -->
<view class="reset-wrap">
<button class="reset-btn" bindtap="onResetData">清空本地数据(调试)</button>
</view>
</view>
+156
View File
@@ -0,0 +1,156 @@
/* pages/poi/poi.wxss */
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
}
/* 顶部积分卡片 */
.header-card {
background: linear-gradient(135deg, #ffb347 0%, #ff7e5f 100%);
border-radius: 24rpx;
padding: 36rpx 32rpx;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(255, 126, 95, 0.25);
display: flex;
align-items: center;
justify-content: space-between;
}
.header-label {
font-size: 26rpx;
opacity: 0.9;
}
.header-points {
margin-top: 8rpx;
display: flex;
align-items: baseline;
}
.points-num {
font-size: 64rpx;
font-weight: 700;
line-height: 1;
}
.points-unit {
font-size: 28rpx;
margin-left: 6rpx;
}
.header-right {
text-align: right;
}
.badge-count-num {
font-size: 40rpx;
font-weight: 700;
margin-right: 6rpx;
}
.badge-count-label {
font-size: 24rpx;
opacity: 0.9;
}
.arrow {
margin-top: 12rpx;
font-size: 22rpx;
opacity: 0.9;
}
/* 分区标题 */
.section-title {
margin: 40rpx 8rpx 20rpx;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
/* 景点列表 */
.poi-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.poi-item {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.poi-icon {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fff2d6, #ffe0b3);
display: flex;
align-items: center;
justify-content: center;
font-size: 52rpx;
flex-shrink: 0;
}
.poi-info {
flex: 1;
margin: 0 24rpx;
overflow: hidden;
}
.poi-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.poi-desc {
margin-top: 6rpx;
font-size: 24rpx;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 打卡按钮 */
.poi-btn {
min-width: 160rpx;
height: 64rpx;
line-height: 64rpx;
padding: 0 24rpx;
font-size: 26rpx;
color: #fff;
background: linear-gradient(135deg, #ffb347, #ff7e5f);
border-radius: 32rpx;
border: none;
}
.poi-btn-done {
background: #e5e5e5 !important;
color: #999 !important;
}
.poi-btn[disabled] {
background: #e5e5e5 !important;
color: #999 !important;
}
/* 调试按钮 */
.reset-wrap {
margin: 60rpx 0 40rpx;
display: flex;
justify-content: center;
}
.reset-btn {
font-size: 24rpx;
color: #999;
background: transparent;
padding: 12rpx 32rpx;
}
+40
View File
@@ -0,0 +1,40 @@
{
"miniprogramRoot": "./",
"projectname": "打卡",
"appid": "wxa3abd3957b0eb47b",
"description": "景点打卡示例",
"setting": {
"es6": true,
"enhance": true,
"postcss": true,
"minified": true,
"urlCheck": true,
"compileHotReLoad": true,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"libVersion": "3.0.0",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
}
+8
View File
@@ -0,0 +1,8 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}
+113
View File
@@ -0,0 +1,113 @@
// userStore.js
// 用户数据管理模块:负责积分、徽章、打卡记录的本地存储与业务逻辑
const STORAGE_KEY = 'USER_DATA';
// 默认的用户数据结构
const defaultUserData = {
points: 0, // 用户积分
badges: [], // 用户徽章列表,元素形如 { name, obtainedAt }
checkIns: [] // 打卡记录,元素形如 { poiId, poiName, time }
};
/**
* 获取完整的用户数据
* 如果本地没有数据,返回默认结构
* @returns {{points:number, badges:Array, checkIns:Array}}
*/
function getUserData() {
try {
const data = wx.getStorageSync(STORAGE_KEY);
if (data && typeof data === 'object') {
// 兼容旧数据:补齐可能缺失的字段
return Object.assign({}, defaultUserData, data);
}
} catch (e) {
console.error('[userStore] 读取用户数据失败:', e);
}
// 深拷贝默认数据,避免外部修改影响默认值
return JSON.parse(JSON.stringify(defaultUserData));
}
/**
* 保存用户数据到本地
* @param {Object} data
*/
function saveUserData(data) {
try {
wx.setStorageSync(STORAGE_KEY, data);
} catch (e) {
console.error('[userStore] 保存用户数据失败:', e);
}
}
/**
* 判断某个景点是否已经打卡过
* @param {string|number} poiId
* @returns {boolean}
*/
function hasCheckedIn(poiId) {
const data = getUserData();
return data.checkIns.some(item => item.poiId === poiId);
}
/**
* 判断是否已经拥有某个徽章
* @param {Object} data 用户数据
* @param {string} badgeName
* @returns {boolean}
*/
function hasBadge(data, badgeName) {
return data.badges.some(b => b.name === badgeName);
}
/**
* 处理打卡逻辑
* 检查是否已打卡;如未打卡则 +10 积分,添加“探索者”徽章(如没有),保存数据
* @param {string|number} poiId 景点 ID
* @param {string} poiName 景点名称
* @returns {{success:boolean, message:string, data?:Object}}
*/
function checkIn(poiId, poiName) {
if (!poiId) {
return { success: false, message: '景点 ID 无效' };
}
const data = getUserData();
// 已打卡则不再重复奖励
if (data.checkIns.some(item => item.poiId === poiId)) {
return { success: false, message: '你已经打卡过该景点', data };
}
// 增加打卡记录
data.checkIns.push({
poiId,
poiName: poiName || '',
time: Date.now()
});
// 增加 10 积分
data.points += 10;
// 添加“探索者”徽章(若尚未拥有)
const BADGE_NAME = '探索者';
if (!hasBadge(data, BADGE_NAME)) {
data.badges.push({
name: BADGE_NAME,
obtainedAt: Date.now()
});
}
// 保存到本地
saveUserData(data);
return { success: true, message: '打卡成功,积分 +10', data };
}
module.exports = {
getUserData,
saveUserData,
hasCheckedIn,
checkIn
};
Binary file not shown.