Files
gmTouringMiniApp/src/pages/planner/index.vue
T

1265 lines
33 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.
<script setup lang="ts">
import type { PoiSummary } from '@/domain/poi'
import type { BudgetLevel, Interest, Pace, PlanningRequest, Theme, Transport, TravelPreferences } from '@/domain/travel'
import { getPoiRepository } from '@/data/poi'
import { createPlan, isRemoteTravelAssistantEnabled, loadPlanningRequest, loadPreferences, savePlan, savePreferences } from '@/services/travel-assistant'
import { useLocationStore } from '@/stores'
interface PlannerPreset {
themes?: unknown
interests?: unknown
extraRequirements?: unknown
}
const PRESET_STORAGE_KEY = 'guangming:planner-preset'
const MIN_DURATION_MINUTES = 2 * 60
const MAX_DURATION_MINUTES = 10 * 60
const DURATION_STEP_MINUTES = 30
const MIN_CUSTOM_POIS = 2
const MAX_CUSTOM_POIS = 8
const themeOptions: Theme[] = ['亲子', '情侣', '朋友', '银发', '研学']
const interestOptions: Interest[] = ['自然风光', '文化场馆', '生态科普', '美食', '摄影']
const paceOptions: Array<{ label: string, description: string, value: Pace }> = [
{ label: '轻松', description: '少走慢游', value: 'relaxed' },
{ label: '适中', description: '均衡安排', value: 'moderate' },
{ label: '紧凑', description: '尽量多看', value: 'compact' },
]
const transportOptions: Array<{ label: string, value: Transport }> = [
{ label: '公共交通', value: 'public_transport' },
{ label: '自驾', value: 'driving' },
{ label: '步行', value: 'walking' },
]
const budgetOptions: Array<{ 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 planningMode = ref<PlanningRequest['mode']>('quick')
const durationMinutes = ref(4 * 60)
const selectedPoiIds = ref<string[]>([])
const customStartsFromCurrentLocation = ref(false)
const submitting = ref(false)
const locationStore = useLocationStore()
const remoteEnabled = computed(() => isRemoteTravelAssistantEnabled())
const locationAvailable = computed(() => locationStore.status === 'ready' && Boolean(locationStore.snapshot))
const poiSummaries = shallowRef<PoiSummary[]>(getPoiRepository().getPoiSummaries())
const poiGroups = computed(() => {
const groups = new Map<string, PoiSummary[]>()
poiSummaries.value.forEach((poi) => {
const current = groups.get(poi.categoryName) ?? []
current.push(poi)
groups.set(poi.categoryName, current)
})
return [...groups.entries()].map(([categoryName, pois]) => ({ categoryName, pois }))
})
const durationText = computed(() => {
const hours = Math.floor(durationMinutes.value / 60)
const minutes = durationMinutes.value % 60
return minutes ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`
})
const usesCurrentLocation = computed(() => locationAvailable.value
&& (planningMode.value === 'quick' || customStartsFromCurrentLocation.value))
const planningEngineLabel = computed(() => remoteEnabled.value
? '在线智能服务'
: '本地智能规划')
const locationActionText = computed(() => {
if (locationStore.status === 'locating')
return '定位中…'
if (locationStore.status === 'denied')
return '开启位置权限'
return locationAvailable.value ? '刷新当前位置' : '获取当前位置'
})
const locationDescription = computed(() => {
if (locationStore.status === 'locating')
return '正在获取位置,请稍候。'
if (planningMode.value === 'quick') {
return locationAvailable.value
? '将从当前位置出发并优先安排较近点位;可随时刷新。'
: '尚未获取位置,提交后会按光明区全域生成路线。'
}
if (!locationAvailable.value)
return '可选获取位置,用于优化自选点位的起点和顺序。'
return customStartsFromCurrentLocation.value
? '已将当前位置设为自选路线起点。'
: '位置已就绪;如需从当前位置出发,请开启下方选项。'
})
function assignPreferences(preferences: TravelPreferences) {
Object.assign(form, {
...preferences,
themes: [...preferences.themes],
interests: [...preferences.interests],
childAges: [...preferences.childAges],
})
}
function isStringArray<T extends string>(value: unknown, allowed: readonly T[]): value is T[] {
return Array.isArray(value) && value.every(item => typeof item === 'string' && allowed.includes(item as T))
}
function applyPreset() {
let preset: PlannerPreset | null = null
try {
const value = uni.getStorageSync(PRESET_STORAGE_KEY)
preset = value && typeof value === 'object' ? value as PlannerPreset : null
uni.removeStorageSync(PRESET_STORAGE_KEY)
}
catch {
preset = null
}
if (!preset)
return
if (isStringArray(preset.themes, themeOptions))
form.themes = [...preset.themes]
if (isStringArray(preset.interests, interestOptions))
form.interests = [...preset.interests]
if (typeof preset.extraRequirements === 'string')
form.extraRequirements = preset.extraRequirements.slice(0, 500)
}
function toggleTheme(theme: Theme) {
const index = form.themes.indexOf(theme)
if (index >= 0)
form.themes.splice(index, 1)
else
form.themes.push(theme)
}
function toggleInterest(interest: Interest) {
const index = form.interests.indexOf(interest)
if (index >= 0)
form.interests.splice(index, 1)
else
form.interests.push(interest)
}
function setPlanningMode(mode: PlanningRequest['mode']) {
planningMode.value = mode
}
function togglePoi(poiId: string) {
const index = selectedPoiIds.value.indexOf(poiId)
if (index >= 0) {
selectedPoiIds.value.splice(index, 1)
return
}
if (selectedPoiIds.value.length >= MAX_CUSTOM_POIS) {
uni.showToast({ title: `最多选择 ${MAX_CUSTOM_POIS} 个点位`, icon: 'none' })
return
}
selectedPoiIds.value.push(poiId)
}
function changeDuration(event: { detail: { value: number } }) {
const value = Number(event.detail.value)
if (!Number.isFinite(value))
return
durationMinutes.value = Math.min(
MAX_DURATION_MINUTES,
Math.max(MIN_DURATION_MINUTES, Math.round(value / DURATION_STEP_MINUTES) * DURATION_STEP_MINUTES),
)
}
function changeCustomOrigin(event: Event) {
const value = (event as Event & { detail?: { value?: unknown } }).detail?.value
customStartsFromCurrentLocation.value = Boolean(value)
}
function changeAdults(delta: number) {
form.adults = Math.min(20, Math.max(0, form.adults + delta))
}
function 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
}
function setAge(index: number, event: { detail: { value: number } }) {
form.childAges[index] = event.detail.value
}
function isPermissionDenied(error: unknown): boolean {
if (!error || typeof error !== 'object')
return false
const message = String((error as { errMsg?: unknown }).errMsg ?? '')
return /auth deny|auth denied|authorize:fail|permission/i.test(message)
}
function requestCurrentLocation() {
if (locationStore.status === 'locating')
return
if (locationStore.status === 'denied') {
uni.openSetting({
success: (settings) => {
if (settings.authSetting['scope.userLocation'])
locateFromUserAction()
},
})
return
}
locateFromUserAction()
}
function locateFromUserAction() {
locationStore.startLocating()
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true,
highAccuracyExpireTime: 8000,
success: (result) => {
const updated = locationStore.updateLocation(
Number(result.longitude),
Number(result.latitude),
Number(result.accuracy ?? result.horizontalAccuracy),
)
if (!updated) {
locationStore.failLocation('error', '定位结果无效,请稍后重试')
uni.showToast({ title: '定位结果无效', icon: 'none' })
return
}
if (planningMode.value === 'custom')
customStartsFromCurrentLocation.value = true
uni.showToast({ title: '已更新当前位置', icon: 'success' })
},
fail: (error) => {
if (isPermissionDenied(error)) {
locationStore.failLocation('denied', '位置权限未开启')
uni.showModal({
title: '需要位置权限',
content: '开启后可从当前位置出发规划路线,也可不授权并使用全域规划。',
confirmText: '去设置',
success: result => result.confirm && requestCurrentLocation(),
})
return
}
locationStore.failLocation('unavailable', '暂时无法获取位置')
uni.showToast({ title: '定位失败,可继续使用全域规划', icon: 'none' })
},
})
}
function showDurationFitNotice(status: 'overflow' | 'underfilled'): Promise<void> {
return new Promise((resolve) => {
uni.showModal({
title: status === 'overflow' ? '时间不足' : '行程时间提示',
content: status === 'overflow'
? '当前点位与行程安排无法在所选时长内完整体验,请在查看路线后适当精简点位或延长时长。'
: '当前规划路径与所需时间差距较大,请结合实际交通、现场开放情况和个人节奏调整行程。',
showCancel: false,
confirmText: '查看路线',
complete: () => resolve(),
})
})
}
async function submit() {
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
}
if (planningMode.value === 'custom'
&& (selectedPoiIds.value.length < MIN_CUSTOM_POIS || selectedPoiIds.value.length > MAX_CUSTOM_POIS)) {
uni.showToast({ title: `请选择 ${MIN_CUSTOM_POIS}-${MAX_CUSTOM_POIS} 个点位`, icon: 'none' })
return
}
submitting.value = true
try {
const preferences: TravelPreferences = {
...form,
duration: durationMinutes.value <= 4 * 60 ? 'half_day' : 'full_day',
themes: [...form.themes],
interests: [...form.interests],
childAges: [...form.childAges],
}
const snapshot = locationStore.snapshot
const origin = usesCurrentLocation.value && snapshot
? {
longitude: snapshot.longitude,
latitude: snapshot.latitude,
coordinateSystem: snapshot.coordinateSystem,
}
: undefined
const request: PlanningRequest = {
mode: planningMode.value,
preferences,
durationMinutes: durationMinutes.value,
selectedPoiIds: planningMode.value === 'custom' ? [...selectedPoiIds.value] : [],
origin,
}
const response = await createPlan(request)
savePreferences(preferences)
savePlan(response)
if (response.itinerary.durationFitStatus === 'overflow' || response.itinerary.durationFitStatus === 'underfilled')
await showDurationFitNotice(response.itinerary.durationFitStatus)
uni.navigateTo({ url: '/pages/itinerary/index' })
}
catch (error) {
uni.showModal({
title: '暂时无法生成路线',
content: error instanceof Error ? error.message : '请检查选择后重试',
showCancel: false,
})
}
finally {
submitting.value = false
}
}
onShow(() => {
const storedRequest = loadPlanningRequest()
const storedPreferences = storedRequest?.preferences ?? loadPreferences()
if (storedPreferences)
assignPreferences(storedPreferences)
if (storedRequest) {
planningMode.value = storedRequest.mode
durationMinutes.value = storedRequest.durationMinutes
selectedPoiIds.value = [...storedRequest.selectedPoiIds]
customStartsFromCurrentLocation.value = false
}
else if (storedPreferences) {
durationMinutes.value = storedPreferences.duration === 'half_day' ? 4 * 60 : 8 * 60
}
applyPreset()
})
</script>
<template>
<view class="planner-page">
<view class="planner-intro">
<view class="planner-intro__icon">
<text>智游</text>
</view>
<view class="planner-intro__bubble">
<text class="planner-intro__title">定制你的光明文旅路线</text>
<text class="planner-intro__copy">可以一键生成推荐路线也可先选好想去的点位再编排</text>
<view class="planner-intro__mode">
<view class="planner-intro__dot" />
{{ planningEngineLabel }}
</view>
</view>
</view>
<view class="planner-form">
<view class="planner-form__header">
<view>
<text class="planner-form__title">出行画像</text>
<text class="planner-form__subtitle">选择会作为路线编排依据</text>
</view>
<text class="planner-form__district">深圳 · 光明</text>
</view>
<view class="form-section planning-mode-section">
<view class="form-section__heading">
<text class="form-section__label">规划模式</text>
</view>
<view class="planning-mode-grid">
<button
class="planning-mode-card"
:class="{ 'planning-mode-card--active': planningMode === 'quick' }"
@click="setPlanningMode('quick')"
>
<text class="planning-mode-card__title">当前位置快速规划</text>
<text class="planning-mode-card__copy">根据时长和偏好智能选点</text>
</button>
<button
class="planning-mode-card"
:class="{ 'planning-mode-card--active': planningMode === 'custom' }"
@click="setPlanningMode('custom')"
>
<text class="planning-mode-card__title">自选点位规划</text>
<text class="planning-mode-card__copy">从全部 POI 中选 2-8 个编排</text>
</button>
</view>
<text v-if="remoteEnabled && planningMode === 'custom'" class="planning-mode-tip">
在线 AI 将在你选择的点位内建议顺序本机负责定位交通与时长核算
</text>
</view>
<view class="planning-origin" :class="{ 'planning-origin--ready': locationAvailable }">
<view class="planning-origin__heading">
<text class="planning-origin__title">
{{ usesCurrentLocation ? '已使用当前位置' : '当前位置为可选项' }}
</text>
<button :disabled="locationStore.status === 'locating'" @click="requestCurrentLocation">
{{ locationActionText }}
</button>
</view>
<text class="planning-origin__copy">{{ locationDescription }}</text>
<view v-if="planningMode === 'custom' && locationAvailable" class="origin-switch">
<text>自选路线从当前位置出发</text>
<switch
:checked="customStartsFromCurrentLocation"
color="#167a5b"
@change="changeCustomOrigin"
/>
</view>
<text class="planning-origin__privacy">定位仅由你主动触发精确坐标不写入本机路线缓存</text>
</view>
<view v-if="planningMode === 'custom'" class="form-section custom-poi-section">
<view class="form-section__heading custom-poi-heading">
<view>
<text class="form-section__label">选择想去的点位</text>
<text class="form-section__hint">至少 2 最多 8 </text>
</view>
<text class="custom-poi-count" :class="{ 'custom-poi-count--ready': selectedPoiIds.length >= MIN_CUSTOM_POIS }">
已选 {{ selectedPoiIds.length }}/{{ MAX_CUSTOM_POIS }}
</text>
</view>
<scroll-view class="poi-picker" scroll-y enhanced show-scrollbar>
<view v-for="group in poiGroups" :key="group.categoryName" class="poi-group">
<text class="poi-group__title">{{ group.categoryName }} · {{ group.pois.length }}</text>
<button
v-for="poi in group.pois"
:key="poi.id"
class="poi-option"
:class="{ 'poi-option--active': selectedPoiIds.includes(poi.id) }"
@click="togglePoi(poi.id)"
>
<view class="poi-option__content">
<text class="poi-option__name">{{ poi.name }}</text>
<text class="poi-option__meta">{{ poi.categoryName }} · 推荐 {{ poi.recommendationIndex }}/5</text>
</view>
<text class="poi-option__check">
{{ selectedPoiIds.includes(poi.id) ? selectedPoiIds.indexOf(poi.id) + 1 : '' }}
</text>
</button>
</view>
</scroll-view>
</view>
<view class="form-section">
<view class="form-section__heading">
<text class="form-section__label">{{ planningMode === 'quick' ? '出行主题' : '路线偏好' }}</text>
<text class="form-section__hint">多选</text>
</view>
<view class="option-grid option-grid--themes">
<button
v-for="theme in themeOptions"
:key="theme"
class="option"
:class="{ 'option--active': form.themes.includes(theme) }"
@click="toggleTheme(theme)"
>
<text v-if="form.themes.includes(theme)" class="option__check"></text>
{{ theme }}
</button>
</view>
</view>
<view class="form-section">
<view class="form-section__heading duration-heading">
<text class="form-section__label">游玩时长</text>
<text class="duration-value">{{ durationText }}</text>
</view>
<slider
:value="durationMinutes"
:min="MIN_DURATION_MINUTES"
:max="MAX_DURATION_MINUTES"
:step="DURATION_STEP_MINUTES"
active-color="#167a5b"
background-color="#dce8e2"
block-color="#167a5b"
block-size="22"
@change="changeDuration"
/>
<view class="duration-scale">
<text>2 小时</text>
<text>30 分钟步进</text>
<text>10 小时</text>
</view>
</view>
<view class="form-section">
<view class="form-section__heading">
<text class="form-section__label">行程节奏</text>
</view>
<view class="option-grid option-grid--three">
<button
v-for="pace in paceOptions"
:key="pace.value"
class="option option--stack"
:class="{ 'option--active': form.pace === pace.value }"
@click="form.pace = pace.value"
>
<text>{{ pace.label }}</text><text class="option__sub">{{ pace.description }}</text>
</button>
</view>
</view>
<view class="form-section">
<view class="form-section__heading">
<text class="form-section__label">特色偏好</text>
<text class="form-section__hint">多选</text>
</view>
<view class="option-grid option-grid--interests">
<button
v-for="interest in interestOptions"
:key="interest"
class="option"
:class="{ 'option--active': form.interests.includes(interest) }"
@click="toggleInterest(interest)"
>
<text v-if="form.interests.includes(interest)" class="option__check"></text>
{{ interest }}
</button>
</view>
</view>
<view class="form-section">
<view class="form-section__heading">
<text class="form-section__label">出行方式</text>
</view>
<view class="option-grid option-grid--three">
<button
v-for="transport in transportOptions"
:key="transport.value"
class="option"
:class="{ 'option--active': form.transport === transport.value }"
@click="form.transport = transport.value"
>
{{ transport.label }}
</button>
</view>
</view>
<view class="form-section">
<view class="form-section__heading">
<text class="form-section__label">预算偏好</text>
</view>
<view class="option-grid option-grid--three">
<button
v-for="budget in budgetOptions"
:key="budget.value"
class="option"
:class="{ 'option--active': form.budgetLevel === budget.value }"
@click="form.budgetLevel = budget.value"
>
{{ budget.label }}
</button>
</view>
</view>
<view class="form-section">
<view class="form-section__heading">
<text class="form-section__label">同行人数</text>
<text class="form-section__hint">儿童年龄会影响推荐</text>
</view>
<view class="people-grid">
<view class="counter-card">
<view><text class="counter-card__label">成人</text><text class="counter-card__hint">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-card">
<view><text class="counter-card__label">儿童</text><text class="counter-card__hint">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"
active-color="#167a5b"
background-color="#dce8e2"
block-color="#167a5b"
block-size="18"
@change="setAge(index, $event)"
/>
<text class="age-row__value">{{ form.childAges[index] }} </text>
</view>
</view>
</view>
<view class="form-section form-section--last">
<view class="form-section__heading">
<text class="form-section__label">补充说明</text>
<text class="form-section__hint">选填</text>
</view>
<view class="textarea-wrap">
<textarea
v-model="form.extraRequirements"
class="textarea"
maxlength="500"
placeholder="例如:下午下雨,只安排室内项目;带婴儿车,不爬山…"
placeholder-class="textarea__placeholder"
/>
<text class="textarea__count">{{ form.extraRequirements.length }}/500</text>
</view>
</view>
<button class="planner-submit" :disabled="submitting" @click="submit">
<text class="planner-submit__spark"></text>
<text>{{ submitting ? '正在编排路线…' : planningMode === 'quick' ? '开始快速规划' : `编排 ${selectedPoiIds.length} 个自选点位` }}</text>
</button>
</view>
<text class="planner-footer">匿名使用 · 偏好和路线仅保存在本机</text>
</view>
</template>
<route lang="yaml">
layout: map
style:
navigationBarTitleText: 智能路线规划
navigationBarBackgroundColor: '#ffffff'
navigationBarTextStyle: black
backgroundColor: '#f4f8f6'
</route>
<style scoped lang="scss">
.planner-page {
min-height: 100vh;
padding: 28rpx 20rpx calc(50rpx + env(safe-area-inset-bottom));
color: #17221d;
background: #f4f8f6;
}
.planner-intro {
display: flex;
gap: 18rpx;
align-items: flex-end;
padding: 20rpx 10rpx 30rpx;
}
.planner-intro__icon {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 124rpx;
height: 124rpx;
font-size: 21rpx;
font-weight: 800;
color: #22e2b6;
background: #0b3933;
border: 8rpx solid #d9f4e7;
border-radius: 42rpx;
box-shadow: 0 14rpx 30rpx rgb(5 112 68 / 15%);
}
.planner-intro__bubble {
display: flex;
flex: 1;
flex-direction: column;
padding: 24rpx;
background: #fff;
border: 1rpx solid #d4e8df;
border-radius: 24rpx;
box-shadow: 0 12rpx 32rpx rgb(20 80 54 / 7%);
}
.planner-intro__title {
font-size: 28rpx;
font-weight: 800;
line-height: 40rpx;
color: #087846;
}
.planner-intro__copy {
margin-top: 8rpx;
font-size: 20rpx;
line-height: 32rpx;
color: #64716b;
}
.planner-intro__mode {
display: flex;
gap: 8rpx;
align-items: center;
margin-top: 12rpx;
font-size: 17rpx;
color: #718078;
}
.planner-intro__dot {
width: 11rpx;
height: 11rpx;
background: #08a865;
border-radius: 50%;
box-shadow: 0 0 0 5rpx rgb(8 168 101 / 12%);
}
.planner-form {
padding: 0 28rpx 32rpx;
background: #fff;
border: 1rpx solid #d9e6e0;
border-radius: 28rpx;
box-shadow: 0 20rpx 60rpx rgb(19 75 50 / 8%);
}
.planner-form__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 0;
border-bottom: 1rpx solid #e9efec;
}
.planner-form__title,
.planner-form__subtitle,
.planner-form__district {
display: block;
}
.planning-origin {
padding: 18rpx 20rpx;
margin-top: 22rpx;
color: #68766f;
background: #f4f7f5;
border: 1rpx solid #e1e8e4;
border-radius: 14rpx;
}
.planning-mode-section {
padding-top: 26rpx;
}
.planning-mode-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14rpx;
}
.planning-mode-card {
display: flex;
flex-direction: column;
align-items: flex-start;
min-height: 140rpx;
padding: 20rpx;
margin: 0;
line-height: 1.45;
text-align: left;
background: #f7faf8;
border: 2rpx solid #dce7e1;
border-radius: 18rpx;
}
.planning-mode-card::after,
.planning-origin button::after,
.poi-option::after {
border: 0;
}
.planning-mode-card--active {
color: #fff;
background: linear-gradient(145deg, #087f51, #086a47);
border-color: #08784c;
box-shadow: 0 10rpx 24rpx rgb(5 118 69 / 18%);
}
.planning-mode-card__title {
font-size: 23rpx;
font-weight: 800;
line-height: 34rpx;
}
.planning-mode-card__copy {
margin-top: 8rpx;
font-size: 17rpx;
line-height: 27rpx;
color: #77857e;
}
.planning-mode-card--active .planning-mode-card__copy {
color: rgb(255 255 255 / 75%);
}
.planning-mode-tip {
display: block;
margin-top: 14rpx;
font-size: 18rpx;
line-height: 30rpx;
color: #7a6750;
}
.planning-origin__heading {
display: flex;
gap: 16rpx;
align-items: center;
justify-content: space-between;
}
.planning-origin__heading button {
flex: none;
width: auto;
height: 54rpx;
padding: 0 18rpx;
margin: 0;
font-size: 18rpx;
line-height: 54rpx;
color: #167a5b;
background: #fff;
border: 1rpx solid #cfe0d8;
border-radius: 27rpx;
}
.planning-origin__heading button[disabled] {
opacity: 0.55;
}
.planning-origin--ready {
color: #245641;
background: #eaf7f0;
border-color: #cde8da;
}
.planning-origin__title,
.planning-origin__copy {
display: block;
}
.origin-switch {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 14rpx;
margin-top: 14rpx;
font-size: 19rpx;
border-top: 1rpx solid #d9e8e0;
}
.origin-switch switch {
transform: scale(0.78);
transform-origin: right center;
}
.planning-origin__privacy {
display: block;
margin-top: 12rpx;
font-size: 16rpx;
line-height: 26rpx;
color: #8a9690;
}
.custom-poi-section {
padding-bottom: 22rpx;
}
.custom-poi-heading {
justify-content: space-between;
}
.custom-poi-heading > view {
display: flex;
flex-direction: column;
gap: 5rpx;
}
.custom-poi-count {
flex: none;
padding: 7rpx 13rpx;
font-size: 18rpx;
color: #65726b;
background: #edf2ef;
border-radius: 999rpx;
}
.custom-poi-count--ready {
color: #08794b;
background: #e4f5ec;
}
.poi-picker {
box-sizing: border-box;
height: 620rpx;
padding: 4rpx 16rpx 16rpx;
background: #f7faf8;
border: 1rpx solid #dfe9e4;
border-radius: 18rpx;
}
.poi-group {
padding-top: 18rpx;
}
.poi-group__title {
display: block;
margin: 0 4rpx 10rpx;
font-size: 18rpx;
font-weight: 700;
color: #6b7972;
}
.poi-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 86rpx;
padding: 13rpx 15rpx;
margin: 0 0 10rpx;
color: #27332d;
text-align: left;
background: #fff;
border: 1rpx solid #e0e8e4;
border-radius: 14rpx;
}
.poi-option--active {
color: #075f3d;
background: #e9f7f0;
border-color: #87c7aa;
}
.poi-option__content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.poi-option__name {
overflow: hidden;
font-size: 21rpx;
font-weight: 700;
line-height: 31rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.poi-option__meta {
margin-top: 3rpx;
font-size: 16rpx;
line-height: 25rpx;
color: #849089;
}
.poi-option__check {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 42rpx;
height: 42rpx;
margin-left: 16rpx;
font-size: 20rpx;
font-weight: 800;
color: #128056;
background: #eef6f2;
border-radius: 50%;
}
.poi-option--active .poi-option__check {
color: #fff;
background: #138056;
}
.duration-heading {
justify-content: space-between;
}
.duration-value {
padding: 7rpx 13rpx;
font-size: 20rpx;
font-weight: 800;
color: #08794b;
background: #e7f6ee;
border-radius: 999rpx;
}
.duration-scale {
display: flex;
justify-content: space-between;
font-size: 17rpx;
color: #89958f;
}
.planning-origin__title {
font-size: 22rpx;
font-weight: 700;
line-height: 32rpx;
}
.planning-origin__copy {
margin-top: 4rpx;
font-size: 18rpx;
line-height: 30rpx;
}
.planner-form__title {
font-size: 31rpx;
font-weight: 800;
}
.planner-form__subtitle {
margin-top: 5rpx;
font-size: 18rpx;
color: #8b9892;
}
.planner-form__district {
padding: 8rpx 14rpx;
font-size: 17rpx;
font-weight: 700;
color: #07814d;
background: #e9f7f0;
border-radius: 999rpx;
}
.form-section {
padding: 32rpx 0;
border-bottom: 1rpx solid #edf1ef;
}
.form-section--last {
border-bottom: 0;
}
.form-section__heading {
display: flex;
gap: 12rpx;
align-items: center;
margin-bottom: 20rpx;
}
.form-section__label {
font-size: 25rpx;
font-weight: 700;
}
.form-section__hint {
font-size: 19rpx;
color: #9aa49f;
}
.option-grid {
display: grid;
gap: 14rpx;
}
.option-grid--themes {
grid-template-columns: repeat(3, 1fr);
}
.option-grid--interests {
grid-template-columns: repeat(2, 1fr);
}
.option-grid--two {
grid-template-columns: repeat(2, 1fr);
}
.option-grid--three {
grid-template-columns: repeat(3, 1fr);
}
.option {
display: flex;
gap: 6rpx;
align-items: center;
justify-content: center;
width: 100%;
min-height: 72rpx;
padding: 10rpx 8rpx;
margin: 0;
font-size: 22rpx;
line-height: 32rpx;
color: #46514b;
background: #fff;
border: 1rpx solid #dfe6e2;
border-radius: 14rpx;
}
.option::after,
.counter button::after,
.planner-submit::after {
border: 0;
}
.option--stack {
flex-direction: column;
}
.option--active {
font-weight: 700;
color: #fff;
background: linear-gradient(145deg, #079c5c, #087d4d);
border-color: #078e55;
box-shadow: 0 8rpx 18rpx rgb(5 142 84 / 18%);
}
.option__sub {
font-size: 16rpx;
line-height: 24rpx;
color: #99a49e;
}
.option--active .option__sub {
color: rgb(255 255 255 / 76%);
}
.option__check {
font-size: 18rpx;
}
.people-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14rpx;
}
.counter-card {
padding: 19rpx;
border: 1rpx solid #e2e9e5;
border-radius: 16rpx;
}
.counter-card__label,
.counter-card__hint {
display: block;
}
.counter-card__label {
font-size: 23rpx;
font-weight: 700;
}
.counter-card__hint {
margin-top: 3rpx;
font-size: 16rpx;
color: #9aa49f;
}
.counter {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
}
.counter button {
width: 52rpx;
height: 52rpx;
padding: 0;
margin: 0;
font-size: 28rpx;
line-height: 50rpx;
color: #087d4d;
background: #eaf7f0;
border: 0;
border-radius: 12rpx;
}
.counter text {
min-width: 36rpx;
font-size: 27rpx;
font-weight: 800;
text-align: center;
}
.ages {
padding: 18rpx;
margin-top: 16rpx;
background: #f4f9f6;
border-radius: 14rpx;
}
.age-row {
display: grid;
grid-template-columns: 116rpx 1fr 66rpx;
align-items: center;
font-size: 18rpx;
}
.age-row__value {
font-weight: 700;
color: #07814d;
text-align: right;
}
.textarea-wrap {
position: relative;
}
.textarea {
box-sizing: border-box;
width: 100%;
height: 180rpx;
padding: 22rpx 24rpx 50rpx;
font-size: 22rpx;
line-height: 34rpx;
color: #29342f;
background: #fbfcfc;
border: 1rpx solid #e0e6e3;
border-radius: 16rpx;
}
.textarea__placeholder {
color: #aeb7b2;
}
.textarea__count {
position: absolute;
right: 18rpx;
bottom: 16rpx;
font-size: 17rpx;
color: #a1aaa5;
}
.planner-submit {
display: flex;
gap: 15rpx;
align-items: center;
justify-content: center;
width: 100%;
height: 94rpx;
margin: 28rpx 0 0;
font-size: 28rpx;
font-weight: 800;
line-height: 94rpx;
color: #fff;
background: linear-gradient(100deg, #079c5c, #067d4a);
border: 0;
border-radius: 20rpx;
box-shadow: 0 13rpx 28rpx rgb(3 129 75 / 25%);
}
.planner-submit[disabled] {
opacity: 0.68;
}
.planner-submit__spark {
font-size: 31rpx;
}
.planner-footer {
display: block;
padding: 36rpx 20rpx 4rpx;
font-size: 20rpx;
line-height: 32rpx;
color: #718078;
text-align: center;
}
</style>