forked from zhouruizhe/gmTouringMiniApp
303 lines
17 KiB
Vue
303 lines
17 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="nav">
|
||
<view class="back" @click="goBack">‹</view>
|
||
<text class="nav-title">AI 智能规划</text>
|
||
<view class="nav-space" />
|
||
</view>
|
||
|
||
<view class="intro">
|
||
<view class="bot" aria-label="光明文旅 AI 助手">
|
||
<view class="antenna"><view class="antenna-dot" /></view>
|
||
<view class="bot-ear left" />
|
||
<view class="bot-ear right" />
|
||
<view class="bot-head">
|
||
<view class="bot-face">
|
||
<view class="bot-eye" />
|
||
<view class="bot-mouth" />
|
||
<view class="bot-eye" />
|
||
</view>
|
||
</view>
|
||
<view class="bot-body"><text>AI</text></view>
|
||
</view>
|
||
<view class="speech">
|
||
<view class="speech-tail" />
|
||
<text class="hello">Hi,我是光明文旅 AI 助手</text>
|
||
<text class="intro-copy">告诉我你的偏好和需求,<br />我将为你生成专属路线</text>
|
||
<view class="online"><view class="online-dot" /> REAL AI · 在线规划</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="form-card">
|
||
<view class="card-title">
|
||
<view>
|
||
<text class="title">告诉我你的偏好</text>
|
||
<text class="title-sub">已选内容会成为 AI 规划依据</text>
|
||
</view>
|
||
<view class="progress">1/1</view>
|
||
</view>
|
||
|
||
<view class="section first">
|
||
<view class="section-head">
|
||
<text class="label">出行主题</text><text class="hint">多选</text>
|
||
</view>
|
||
<view class="option-grid four">
|
||
<view v-for="item in themeOptions" :key="item" class="option" :class="{ active: form.themes.includes(item) }" @click="toggleTheme(item)">
|
||
<text v-if="form.themes.includes(item)" class="check">✓</text>{{ item }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section">
|
||
<view class="section-head"><text class="label">游玩时长</text></view>
|
||
<view class="option-grid three">
|
||
<view class="option" :class="{ active: form.duration === 'half_day' }" @click="form.duration = 'half_day'">
|
||
<text class="option-main">半日游</text><text class="option-sub">约 4 小时</text>
|
||
</view>
|
||
<view class="option" :class="{ active: form.duration === 'full_day' }" @click="form.duration = 'full_day'">
|
||
<text class="option-main">一日游</text><text class="option-sub">约 8 小时</text>
|
||
</view>
|
||
<view class="option disabled"><text class="option-main">两日游</text><text class="option-sub">敬请期待</text></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section">
|
||
<view class="section-head"><text class="label">节奏偏好</text></view>
|
||
<view class="option-grid three">
|
||
<view v-for="item in paceOptions" :key="item.value" class="option" :class="{ active: form.pace === item.value }" @click="form.pace = item.value">
|
||
<text class="option-main">{{ item.label }}</text><text class="option-sub">{{ item.desc }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section">
|
||
<view class="section-head">
|
||
<text class="label">特色偏好</text><text class="hint">多选</text>
|
||
</view>
|
||
<view class="option-grid three interest-grid">
|
||
<view v-for="item in interestOptions" :key="item" class="option" :class="{ active: form.interests.includes(item) }" @click="toggleInterest(item)">
|
||
<text v-if="form.interests.includes(item)" class="check">✓</text>{{ item }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section">
|
||
<view class="section-head"><text class="label">出行方式</text></view>
|
||
<view class="option-grid three">
|
||
<view v-for="item in transportOptions" :key="item.value" class="option" :class="{ active: form.transport === item.value }" @click="form.transport = item.value">{{ item.label }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section">
|
||
<view class="section-head"><text class="label">预算偏好</text></view>
|
||
<view class="option-grid three">
|
||
<view v-for="item in budgetOptions" :key="item.value" class="option" :class="{ active: form.budgetLevel === item.value }" @click="form.budgetLevel = item.value">{{ item.label }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section people-section">
|
||
<view class="section-head">
|
||
<text class="label">同行人数</text><text class="hint">儿童年龄会影响推荐</text>
|
||
</view>
|
||
<view class="people-grid">
|
||
<view class="counter-row">
|
||
<view><text class="person-label">成人</text><text class="person-note">18 岁以上</text></view>
|
||
<view class="counter"><button @click="changeAdults(-1)">−</button><text>{{ form.adults }}</text><button @click="changeAdults(1)">+</button></view>
|
||
</view>
|
||
<view class="counter-row">
|
||
<view><text class="person-label">儿童</text><text class="person-note">0–17 岁</text></view>
|
||
<view class="counter"><button @click="changeChildren(-1)">−</button><text>{{ form.children }}</text><button @click="changeChildren(1)">+</button></view>
|
||
</view>
|
||
</view>
|
||
<view v-if="form.children" class="ages">
|
||
<view v-for="(_, index) in form.childAges" :key="index" class="age-row">
|
||
<text>儿童 {{ index + 1 }} 年龄</text>
|
||
<slider :value="form.childAges[index]" min="0" max="17" activeColor="#07945b" backgroundColor="#dce8e2" block-color="#07945b" block-size="18" @change="setAge(index, $event)" />
|
||
<text class="age-value">{{ form.childAges[index] }} 岁</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="section last">
|
||
<view class="section-head">
|
||
<text class="label">补充说明</text><text class="hint">选填</text>
|
||
</view>
|
||
<view class="textarea-wrap">
|
||
<textarea v-model="form.extraRequirements" class="textarea" maxlength="500" placeholder="如:想带小朋友,喜欢公园和美食,不想爬山…" placeholder-class="placeholder" />
|
||
<text class="count">{{ form.extraRequirements.length }}/500</text>
|
||
</view>
|
||
</view>
|
||
|
||
<button class="submit" :disabled="submitting" @click="submit">
|
||
<text class="submit-spark">✦</text>
|
||
<text>{{ submitting ? 'AI 正在编排行程…' : '开始智能规划' }}</text>
|
||
</button>
|
||
</view>
|
||
|
||
<view class="footer-note">
|
||
<view class="line" /><view class="footer-dot" />
|
||
<text>AI 懂你所想 · 规划你所需</text>
|
||
<view class="footer-dot" /><view class="line" />
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { reactive, ref } from 'vue'
|
||
import { createPlan, savePlan, savePreferences } from '@/api/travelAssistant'
|
||
import type { BudgetLevel, Interest, Pace, Theme, Transport, TravelPreferences } from '@/types/travel'
|
||
|
||
const themeOptions: Theme[] = ['亲子', '情侣', '朋友', '银发', '研学']
|
||
const interestOptions: Interest[] = ['自然风光', '文化场馆', '生态科普', '美食', '摄影']
|
||
const paceOptions: { label: string; desc: string; value: Pace }[] = [
|
||
{ label: '轻松', desc: '慢慢走', value: 'relaxed' },
|
||
{ label: '适中', desc: '刚刚好', value: 'moderate' },
|
||
{ label: '紧凑', desc: '多看看', value: 'compact' },
|
||
]
|
||
const transportOptions: { label: string; value: Transport }[] = [
|
||
{ label: '公共交通', value: 'public_transport' },
|
||
{ label: '自驾', value: 'driving' },
|
||
{ label: '步行', value: 'walking' },
|
||
]
|
||
const budgetOptions: { label: string; value: BudgetLevel }[] = [
|
||
{ label: '经济', value: 'economy' },
|
||
{ label: '适中', value: 'standard' },
|
||
{ label: '品质', value: 'quality' },
|
||
]
|
||
|
||
const form = reactive<TravelPreferences>({
|
||
destination: '深圳市光明区',
|
||
themes: ['亲子'],
|
||
duration: 'half_day',
|
||
pace: 'relaxed',
|
||
interests: ['自然风光'],
|
||
adults: 2,
|
||
children: 0,
|
||
childAges: [],
|
||
transport: 'public_transport',
|
||
budgetLevel: 'standard',
|
||
extraRequirements: '',
|
||
})
|
||
const submitting = ref(false)
|
||
|
||
const toggleTheme = (item: Theme) => {
|
||
const index = form.themes.indexOf(item)
|
||
index >= 0 ? form.themes.splice(index, 1) : form.themes.push(item)
|
||
}
|
||
const toggleInterest = (item: Interest) => {
|
||
const index = form.interests.indexOf(item)
|
||
index >= 0 ? form.interests.splice(index, 1) : form.interests.push(item)
|
||
}
|
||
const changeAdults = (delta: number) => {
|
||
form.adults = Math.min(20, Math.max(0, form.adults + delta))
|
||
}
|
||
const changeChildren = (delta: number) => {
|
||
const next = Math.min(20, Math.max(0, form.children + delta))
|
||
while (form.childAges.length < next) form.childAges.push(6)
|
||
form.childAges.splice(next)
|
||
form.children = next
|
||
}
|
||
const setAge = (index: number, event: { detail: { value: number } }) => {
|
||
form.childAges[index] = event.detail.value
|
||
}
|
||
const goBack = () => uni.navigateBack()
|
||
|
||
const submit = async () => {
|
||
if (!form.themes.length && !form.interests.length) {
|
||
uni.showToast({ title: '请至少选择一项主题或偏好', icon: 'none' })
|
||
return
|
||
}
|
||
if (form.adults + form.children < 1) {
|
||
uni.showToast({ title: '出行总人数至少为 1', icon: 'none' })
|
||
return
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
const preferences = { ...form, themes: [...form.themes], interests: [...form.interests], childAges: [...form.childAges] }
|
||
const response = await createPlan(preferences)
|
||
savePreferences(preferences)
|
||
savePlan(response)
|
||
uni.navigateTo({ url: '/pages/itinerary/index' })
|
||
} catch (error) {
|
||
uni.showModal({ title: '暂时无法生成', content: error instanceof Error ? error.message : '请稍后再试', showCancel: false })
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
page { background: #f4f8f6; }
|
||
.page { min-height: 100vh; padding-bottom: calc(60rpx + env(safe-area-inset-bottom)); color: #17221d; background: radial-gradient(circle at 12% 12%, rgba(20,166,105,.08), transparent 330rpx), #f4f8f6; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
|
||
.nav { position: relative; display: flex; align-items: center; justify-content: space-between; height: 100rpx; padding: calc(24rpx + env(safe-area-inset-top)) 28rpx 0; background: rgba(255,255,255,.92); border-bottom: 1rpx solid #e9efec; }
|
||
.back,.nav-space { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; }
|
||
.back { color: #101a15; font-family: Georgia,serif; font-size: 58rpx; font-weight: 300; }
|
||
.nav-title { font-size: 32rpx; font-weight: 700; letter-spacing: 1rpx; }
|
||
.intro { display: flex; align-items: flex-end; gap: 20rpx; padding: 42rpx 28rpx 34rpx; }
|
||
.bot { position: relative; flex-shrink: 0; width: 150rpx; height: 170rpx; }
|
||
.antenna { position: absolute; z-index: 1; left: 73rpx; top: 0; width: 4rpx; height: 30rpx; background: #81d2ad; }
|
||
.antenna-dot { position: absolute; left: -8rpx; top: -4rpx; width: 20rpx; height: 20rpx; border: 3rpx solid #b5e4ce; border-radius: 50%; background: #fff; box-shadow: 0 3rpx 10rpx rgba(0,123,76,.18); }
|
||
.bot-head { position: absolute; z-index: 3; left: 16rpx; top: 28rpx; width: 118rpx; height: 92rpx; border: 4rpx solid #93d8b8; border-radius: 46rpx 46rpx 38rpx 38rpx; background: linear-gradient(145deg,#fff,#edf8f3); box-shadow: 0 12rpx 24rpx rgba(5,112,68,.15); }
|
||
.bot-face { position: absolute; inset: 17rpx 14rpx; display: flex; align-items: center; justify-content: space-around; border-radius: 28rpx; background: #092f2b; box-shadow: inset 0 0 16rpx rgba(0,246,190,.18); }
|
||
.bot-eye { width: 13rpx; height: 29rpx; border-radius: 999rpx; background: #18e4bd; box-shadow: 0 0 10rpx rgba(24,228,189,.7); }
|
||
.bot-mouth { width: 20rpx; height: 5rpx; margin-top: 18rpx; border-radius: 99rpx; background: #18e4bd; }
|
||
.bot-ear { position: absolute; z-index: 2; top: 58rpx; width: 20rpx; height: 46rpx; border: 3rpx solid #8ad5b1; background: #e7f7ef; }
|
||
.bot-ear.left { left: 3rpx; border-radius: 18rpx 4rpx 4rpx 18rpx; }
|
||
.bot-ear.right { right: 3rpx; border-radius: 4rpx 18rpx 18rpx 4rpx; }
|
||
.bot-body { position: absolute; left: 37rpx; top: 117rpx; width: 76rpx; height: 48rpx; display: flex; justify-content: center; padding-top: 14rpx; box-sizing: border-box; border: 4rpx solid #9bdcbe; border-radius: 30rpx 30rpx 12rpx 12rpx; color: #06905a; background: #f2fbf7; font-size: 14rpx; font-weight: 800; letter-spacing: 2rpx; }
|
||
.speech { position: relative; flex: 1; min-height: 150rpx; display: flex; flex-direction: column; justify-content: center; padding: 26rpx 28rpx; box-sizing: border-box; border: 2rpx solid #d4e8df; border-radius: 30rpx; background: rgba(255,255,255,.92); box-shadow: 0 12rpx 32rpx rgba(20,80,54,.07); }
|
||
.speech-tail { position: absolute; left: -16rpx; bottom: 34rpx; width: 28rpx; height: 28rpx; border-left: 2rpx solid #d4e8df; border-bottom: 2rpx solid #d4e8df; background: #fff; transform: rotate(45deg); }
|
||
.hello { color: #087846; font-size: 29rpx; font-weight: 800; }
|
||
.intro-copy { margin-top: 10rpx; color: #64716b; font-size: 21rpx; line-height: 1.65; }
|
||
.online { display: flex; align-items: center; margin-top: 12rpx; color: #7a8b83; font-size: 15rpx; letter-spacing: 1rpx; }
|
||
.online-dot { width: 11rpx; height: 11rpx; margin-right: 8rpx; border-radius: 50%; background: #08a865; box-shadow: 0 0 0 5rpx rgba(8,168,101,.12); }
|
||
.form-card { margin: 0 20rpx; padding: 0 28rpx 30rpx; border: 2rpx solid #d9e6e0; border-radius: 32rpx; background: rgba(255,255,255,.96); box-shadow: 0 20rpx 60rpx rgba(19,75,50,.08); }
|
||
.card-title { display: flex; align-items: center; justify-content: space-between; margin: 0 -28rpx; padding: 29rpx 28rpx; border-bottom: 1rpx solid #e9efec; }
|
||
.title { display: block; font-size: 31rpx; font-weight: 800; }
|
||
.title-sub { display: block; margin-top: 6rpx; color: #8b9892; font-size: 18rpx; }
|
||
.progress { padding: 8rpx 16rpx; border-radius: 999rpx; color: #07814d; background: #e9f7f0; font-size: 17rpx; font-weight: 700; }
|
||
.section { padding: 34rpx 0; border-bottom: 1rpx solid #edf1ef; }
|
||
.section.first { padding-top: 30rpx; }
|
||
.section.last { border-bottom: 0; }
|
||
.section-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 20rpx; }
|
||
.label { font-size: 25rpx; font-weight: 700; }
|
||
.hint { color: #9aa49f; font-size: 19rpx; }
|
||
.hint::before { content: '· '; }
|
||
.option-grid { display: grid; gap: 16rpx; }
|
||
.option-grid.four { grid-template-columns: repeat(4,1fr); }
|
||
.option-grid.three { grid-template-columns: repeat(3,1fr); }
|
||
.option { min-height: 70rpx; display: flex; align-items: center; justify-content: center; gap: 7rpx; padding: 12rpx 10rpx; box-sizing: border-box; border: 2rpx solid #e0e6e3; border-radius: 14rpx; color: #454f4a; background: #fff; font-size: 23rpx; transition: transform .18s ease, background .18s ease; }
|
||
.option:active { transform: scale(.97); }
|
||
.option.active { color: #fff; border-color: #078e55; background: linear-gradient(145deg,#079c5c,#087d4d); box-shadow: 0 8rpx 18rpx rgba(5,142,84,.2); font-weight: 700; }
|
||
.option.disabled { color: #b7bfbb; background: #f6f8f7; }
|
||
.check { font-size: 18rpx; }
|
||
.option-main,.option-sub { display: block; text-align: center; }
|
||
.option-main { font-size: 23rpx; }
|
||
.option-sub { margin-left: 6rpx; color: #9aa49f; font-size: 16rpx; }
|
||
.option.active .option-sub { color: rgba(255,255,255,.75); }
|
||
.interest-grid .option { font-size: 21rpx; }
|
||
.people-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||
.counter-row { min-height: 112rpx; display: flex; flex-direction: column; gap: 18rpx; padding: 20rpx; box-sizing: border-box; border: 2rpx solid #e4eae7; border-radius: 16rpx; }
|
||
.person-label,.person-note { display: block; }
|
||
.person-label { font-size: 23rpx; font-weight: 700; }
|
||
.person-note { margin-top: 3rpx; color: #9aa49f; font-size: 16rpx; }
|
||
.counter { display: flex; align-items: center; justify-content: space-between; }
|
||
.counter button { width: 52rpx; height: 52rpx; margin: 0; padding: 0; line-height: 50rpx; border: 0; border-radius: 12rpx; color: #087d4d; background: #eaf7f0; font-size: 28rpx; }
|
||
.counter button::after { border: 0; }
|
||
.counter text { min-width: 36rpx; text-align: center; color: #16231d; font-size: 27rpx; font-weight: 800; }
|
||
.ages { margin-top: 18rpx; padding: 18rpx; border-radius: 14rpx; background: #f4f9f6; }
|
||
.age-row { display: grid; grid-template-columns: 150rpx 1fr 68rpx; align-items: center; font-size: 18rpx; }
|
||
.age-value { text-align: right; color: #07814d; font-weight: 700; }
|
||
.textarea-wrap { position: relative; }
|
||
.textarea { width: 100%; height: 180rpx; padding: 22rpx 24rpx 50rpx; box-sizing: border-box; border: 2rpx solid #e0e6e3; border-radius: 16rpx; color: #29342f; background: #fbfcfc; font-size: 22rpx; line-height: 1.7; }
|
||
.placeholder { color: #aeb7b2; }
|
||
.count { position: absolute; right: 18rpx; bottom: 16rpx; color: #a1aaa5; font-size: 17rpx; }
|
||
.submit { width: 100%; height: 94rpx; display: flex; align-items: center; justify-content: center; gap: 17rpx; margin-top: 30rpx; border: 0; border-radius: 20rpx; color: #fff; background: linear-gradient(100deg,#079c5c,#067d4a); box-shadow: 0 13rpx 28rpx rgba(3,129,75,.25); font-size: 28rpx; font-weight: 800; letter-spacing: 1rpx; }
|
||
.submit::after { border: 0; }
|
||
.submit[disabled] { opacity: .7; }
|
||
.submit-spark { font-size: 31rpx; }
|
||
.footer-note { display: flex; align-items: center; justify-content: center; gap: 13rpx; padding: 46rpx 28rpx calc(10rpx + env(safe-area-inset-bottom)); color: #176744; font-family: 'STSong','Songti SC',serif; font-size: 22rpx; letter-spacing: 3rpx; }
|
||
.line { width: 50rpx; height: 1rpx; background: #9cc8b3; }
|
||
.footer-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #8cbda6; }
|
||
</style>
|