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
+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;