Files
gmTouringMiniApp/files/归档/my-miniapp/src/pages/planner/index.vue
T

303 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>