1265 lines
33 KiB
Vue
1265 lines
33 KiB
Vue
<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">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"
|
||
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>
|