322 lines
20 KiB
Vue
322 lines
20 KiB
Vue
<template>
|
||
<view v-if="plan" class="page">
|
||
<view class="nav">
|
||
<view class="back" @click="goBack">‹</view>
|
||
<text class="nav-title">推荐路线</text>
|
||
<text class="replan" @click="goPlanner">重新规划</text>
|
||
</view>
|
||
|
||
<view class="content">
|
||
<view class="route-card">
|
||
<view class="recommend"><text class="recommend-icon">✦</text>为你推荐以下路线</view>
|
||
<text class="route-title">{{ plan.itinerary.title }}</text>
|
||
<view class="tags">
|
||
<text v-for="tag in routeTags" :key="tag">{{ tag }}</text>
|
||
</view>
|
||
<view class="route-meta">
|
||
<view><text class="meta-label">总时长</text><text class="meta-value">{{ formatMinutes(plan.itinerary.totalMinutes) }}</text></view>
|
||
<view><text class="meta-label">行程</text><text class="meta-value">{{ plan.itinerary.items.length }} 个地点</text></view>
|
||
</view>
|
||
<text class="route-summary">{{ plan.itinerary.summary }}</text>
|
||
</view>
|
||
|
||
<view v-if="plan.changeSummary" class="change-note"><text>✓ 已更新</text>{{ plan.changeSummary }}</view>
|
||
|
||
<view class="map-card">
|
||
<view class="map-grid" />
|
||
<view class="park park-one" />
|
||
<view class="park park-two" />
|
||
<view class="water" />
|
||
<view class="road road-one" />
|
||
<view class="road road-two" />
|
||
<view class="road road-three" />
|
||
<view class="route-line route-one" />
|
||
<view class="route-line route-two" />
|
||
<view class="route-line route-three" />
|
||
<view
|
||
v-for="(item, index) in mapItems"
|
||
:key="item.placeId"
|
||
class="map-stop"
|
||
:class="`map-stop-${index + 1}`"
|
||
>
|
||
<view class="map-pin">{{ index + 1 }}</view>
|
||
<text class="map-label">{{ shortName(item.placeName) }}</text>
|
||
</view>
|
||
<view class="map-badge"><text>路线示意</text><text>非实时地图</text></view>
|
||
</view>
|
||
|
||
<view class="list-card">
|
||
<view
|
||
v-for="(item, index) in plan.itinerary.items"
|
||
:key="`${item.placeId}-${index}`"
|
||
class="place-row"
|
||
>
|
||
<view class="place-art" :class="`art-${index % 4}`">
|
||
<view class="sun" />
|
||
<view class="hill hill-back" />
|
||
<view class="hill hill-front" />
|
||
<view class="building"><view /><view /><view /></view>
|
||
<text>{{ String(index + 1).padStart(2, '0') }}</text>
|
||
</view>
|
||
<view class="place-copy">
|
||
<view class="place-head">
|
||
<view class="place-number">{{ index + 1 }}</view>
|
||
<text class="place-name">{{ item.placeName }}</text>
|
||
</view>
|
||
<view class="place-tags">
|
||
<text>{{ index === 0 ? '首站' : '推荐' }}</text>
|
||
<text>{{ preferenceTag(index) }}</text>
|
||
</view>
|
||
<text class="activity">{{ item.activity }}</text>
|
||
<view class="place-foot">
|
||
<text>{{ item.startTime }}–{{ item.endTime }}</text>
|
||
<text v-if="index">{{ transferText(item.transferFromPreviousMinutes) }}</text>
|
||
</view>
|
||
<view class="reason"><text>✦</text>{{ item.reason }}</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="cost-card">
|
||
<view class="cost-icon">¥</view>
|
||
<view><text>费用提示</text><text>{{ plan.itinerary.estimatedCostText }}</text></view>
|
||
</view>
|
||
|
||
<view class="adjust-card">
|
||
<text class="section-kicker">AI 路线微调</text>
|
||
<text class="section-title">还想怎么调整?</text>
|
||
<view class="quick-list">
|
||
<view v-for="prompt in quickPrompts" :key="prompt" class="quick" @click="sendAdjustment(prompt)">
|
||
<text>{{ prompt }}</text><text>›</text>
|
||
</view>
|
||
</view>
|
||
<view class="composer">
|
||
<input
|
||
v-model="message"
|
||
:disabled="adjusting"
|
||
maxlength="500"
|
||
placeholder="例如:第二个地点换成室内项目"
|
||
placeholder-class="placeholder"
|
||
confirm-type="send"
|
||
@confirm="sendAdjustment()"
|
||
/>
|
||
<button :disabled="adjusting || !message.trim()" @click="sendAdjustment()">{{ adjusting ? '…' : '发送' }}</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="detail-card">
|
||
<view class="detail-section">
|
||
<text class="section-kicker">出行提醒</text>
|
||
<text v-for="note in plan.itinerary.notes" :key="note" class="note">· {{ note }}</text>
|
||
</view>
|
||
<view class="detail-section sources">
|
||
<text class="section-kicker">资料来源</text>
|
||
<view v-for="source in plan.itinerary.sources" :key="source.placeId" class="source" @click="copySource(source.sourceUrl)">
|
||
<view><text>{{ source.sourceName }}</text><text>{{ source.updatedAt }} 更新</text></view><text>复制链接</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="bottom-bar">
|
||
<button class="save-button" @click="saveRoute"><text>☆</text>{{ saved ? '已保存' : '保存路线' }}</button>
|
||
<button class="accept-button" @click="acceptRoute"><text>➤</text>采纳这条路线</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="empty">
|
||
<view class="empty-icon">⌁</view>
|
||
<text>还没有生成行程</text>
|
||
<button @click="goPlanner">返回选择偏好</button>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { computed, ref } from 'vue'
|
||
import { adjustPlan, loadPlan, loadPreferences, savePlan } from '@/api/travelAssistant'
|
||
import type { PlanResponse, TravelPreferences } from '@/types/travel'
|
||
|
||
const plan = ref<PlanResponse | null>(null)
|
||
const preferences = ref<TravelPreferences | null>(null)
|
||
const message = ref('')
|
||
const adjusting = ref(false)
|
||
const saved = ref(false)
|
||
const quickPrompts = ['节奏慢一点', '换一个室内地点', '增加亲子体验']
|
||
|
||
const paceLabels = { relaxed: '轻松休闲', moderate: '节奏适中', compact: '紧凑高效' }
|
||
const durationLabels = { half_day: '半日游', full_day: '一日游' }
|
||
|
||
const routeTags = computed(() => {
|
||
const value = preferences.value
|
||
if (!value) return ['AI 优选', '光明文旅']
|
||
return [
|
||
durationLabels[value.duration],
|
||
value.themes[0],
|
||
paceLabels[value.pace],
|
||
value.interests[0],
|
||
].filter(Boolean)
|
||
})
|
||
const mapItems = computed(() => plan.value?.itinerary.items.slice(0, 4) || [])
|
||
|
||
onLoad(() => {
|
||
plan.value = loadPlan()
|
||
preferences.value = loadPreferences()
|
||
})
|
||
|
||
const formatMinutes = (minutes: number) => {
|
||
const hours = Math.floor(minutes / 60)
|
||
const rest = minutes % 60
|
||
return rest ? `${hours}小时${rest}分` : `${hours}小时`
|
||
}
|
||
const transferText = (minutes: number | null) => minutes === null ? '交通待确认' : `约 ${minutes} 分钟`
|
||
const shortName = (name: string) => name.length > 8 ? `${name.slice(0, 8)}…` : name
|
||
const preferenceTag = (index: number) => {
|
||
const interests = preferences.value?.interests || []
|
||
return interests[index % Math.max(interests.length, 1)] || '光明精选'
|
||
}
|
||
const goBack = () => uni.navigateBack()
|
||
const goPlanner = () => uni.redirectTo({ url: '/pages/planner/index' })
|
||
const copySource = (url: string) => {
|
||
uni.setClipboardData({ data: url, success: () => uni.showToast({ title: '来源链接已复制', icon: 'none' }) })
|
||
}
|
||
const saveRoute = () => {
|
||
if (!plan.value) return
|
||
savePlan(plan.value)
|
||
saved.value = true
|
||
uni.showToast({ title: '路线已保存', icon: 'success' })
|
||
}
|
||
const acceptRoute = () => {
|
||
saveRoute()
|
||
uni.showModal({
|
||
title: '路线已采纳',
|
||
content: '行程已保存到本机。真实地图导航将在下一阶段接入。',
|
||
showCancel: false,
|
||
})
|
||
}
|
||
const sendAdjustment = async (quick?: string) => {
|
||
if (!plan.value || adjusting.value) return
|
||
const content = (quick || message.value).trim()
|
||
if (!content) return
|
||
adjusting.value = true
|
||
try {
|
||
const response = await adjustPlan(plan.value.conversationId, content)
|
||
plan.value = response
|
||
savePlan(response)
|
||
saved.value = false
|
||
message.value = ''
|
||
uni.pageScrollTo({ scrollTop: 0, duration: 300 })
|
||
} catch (error) {
|
||
uni.showModal({ title: '调整未完成', content: error instanceof Error ? error.message : '上一次行程已为你保留', showCancel: false })
|
||
} finally {
|
||
adjusting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
page { background: #f4f8f6; }
|
||
.page { min-height: 100vh; padding-bottom: calc(150rpx + env(safe-area-inset-bottom)); color: #17221d; background: radial-gradient(circle at 8% 8%,rgba(9,151,89,.06),transparent 340rpx),#f4f8f6; font-family: 'PingFang SC','Microsoft YaHei',sans-serif; }
|
||
.nav { position: sticky; z-index: 30; top: 0; height: 100rpx; display: flex; align-items: center; justify-content: space-between; padding: calc(24rpx + env(safe-area-inset-top)) 28rpx 0; border-bottom: 1rpx solid #e7eeea; background: rgba(255,255,255,.96); }
|
||
.back { width: 76rpx; color: #14201a; font-family: Georgia,serif; font-size: 59rpx; font-weight: 300; }
|
||
.nav-title { position: absolute; left: 180rpx; right: 180rpx; text-align: center; font-size: 32rpx; font-weight: 800; }
|
||
.replan { color: #087d4d; font-size: 23rpx; font-weight: 700; }
|
||
.content { padding: 24rpx 20rpx 60rpx; }
|
||
.route-card,.list-card,.adjust-card,.detail-card { border: 2rpx solid #d9e7e0; border-radius: 26rpx; background: #fff; box-shadow: 0 14rpx 40rpx rgba(20,78,53,.07); }
|
||
.route-card { padding: 28rpx; border-radius: 26rpx 26rpx 0 0; }
|
||
.recommend { display: flex; align-items: center; gap: 10rpx; color: #0a9258; font-size: 21rpx; font-weight: 700; }
|
||
.recommend-icon { width: 34rpx; height: 34rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #087b4b; background: #c9f0dc; }
|
||
.route-title { display: block; margin-top: 19rpx; font-size: 38rpx; line-height: 1.35; font-weight: 800; letter-spacing: 1rpx; }
|
||
.tags,.place-tags { display: flex; flex-wrap: wrap; gap: 10rpx; }
|
||
.tags { margin-top: 17rpx; }
|
||
.tags text,.place-tags text { padding: 8rpx 14rpx; border-radius: 8rpx; color: #27825a; background: #eaf7f0; font-size: 18rpx; }
|
||
.route-meta { display: flex; gap: 48rpx; margin-top: 24rpx; }
|
||
.route-meta > view { display: flex; align-items: baseline; gap: 10rpx; }
|
||
.meta-label { color: #829089; font-size: 19rpx; }
|
||
.meta-value { color: #18231e; font-size: 29rpx; font-weight: 800; }
|
||
.route-summary { display: block; margin-top: 15rpx; color: #718078; font-size: 20rpx; line-height: 1.7; }
|
||
.change-note { margin-top: 18rpx; padding: 20rpx 22rpx; border: 1rpx solid #cce6d8; border-radius: 15rpx; color: #527061; background: #eaf7f0; font-size: 20rpx; }
|
||
.change-note text { margin-right: 12rpx; color: #087d4d; font-weight: 800; }
|
||
.map-card { position: relative; height: 430rpx; overflow: hidden; border-right: 2rpx solid #d9e7e0; border-bottom: 2rpx solid #d9e7e0; border-left: 2rpx solid #d9e7e0; background: #edf4ed; }
|
||
.map-grid { position: absolute; inset: 0; opacity: .8; background-image: linear-gradient(23deg,transparent 46%,#d7ded7 47%,#fff 49%,#d7ded7 51%,transparent 53%),linear-gradient(115deg,transparent 45%,#dae1da 46%,#fff 49%,#dae1da 52%,transparent 53%); background-size: 180rpx 150rpx,230rpx 190rpx; }
|
||
.park { position: absolute; border-radius: 46% 54% 50% 50%; background: rgba(151,206,146,.33); }
|
||
.park-one { left: 38rpx; top: 48rpx; width: 190rpx; height: 110rpx; transform: rotate(-14deg); }
|
||
.park-two { right: 44rpx; bottom: 34rpx; width: 230rpx; height: 140rpx; transform: rotate(17deg); }
|
||
.water { position: absolute; left: -50rpx; top: 232rpx; width: 820rpx; height: 22rpx; border-radius: 50%; background: #b9dfec; transform: rotate(-10deg); }
|
||
.road { position: absolute; height: 15rpx; border: 3rpx solid #fff; border-radius: 999rpx; background: #d4d9d5; }
|
||
.road-one { left: -30rpx; top: 92rpx; width: 520rpx; transform: rotate(12deg); }
|
||
.road-two { right: -40rpx; top: 200rpx; width: 500rpx; transform: rotate(-18deg); }
|
||
.road-three { left: 110rpx; bottom: 70rpx; width: 530rpx; transform: rotate(8deg); }
|
||
.route-line { position: absolute; z-index: 3; height: 10rpx; border-radius: 99rpx; background: #1686df; box-shadow: 0 0 0 4rpx rgba(255,255,255,.8); transform-origin: left center; }
|
||
.route-one { left: 148rpx; top: 112rpx; width: 210rpx; transform: rotate(22deg); }
|
||
.route-two { left: 339rpx; top: 188rpx; width: 190rpx; transform: rotate(35deg); }
|
||
.route-three { left: 493rpx; top: 294rpx; width: 150rpx; transform: rotate(-30deg); }
|
||
.map-stop { position: absolute; z-index: 5; display: flex; align-items: center; gap: 8rpx; }
|
||
.map-stop-1 { left: 98rpx; top: 73rpx; }
|
||
.map-stop-2 { left: 315rpx; top: 156rpx; }
|
||
.map-stop-3 { left: 492rpx; top: 272rpx; }
|
||
.map-stop-4 { right: 38rpx; bottom: 64rpx; flex-direction: row-reverse; }
|
||
.map-pin { width: 50rpx; height: 50rpx; display: flex; align-items: center; justify-content: center; border: 5rpx solid #fff; border-radius: 50% 50% 50% 8rpx; color: #fff; background: #078b52; box-shadow: 0 5rpx 14rpx rgba(6,93,56,.28); font-size: 21rpx; font-weight: 800; transform: rotate(-45deg); }
|
||
.map-pin::first-letter { transform: rotate(45deg); }
|
||
.map-label { max-width: 190rpx; padding: 8rpx 12rpx; border-radius: 8rpx; color: #28342e; background: rgba(255,255,255,.92); box-shadow: 0 4rpx 12rpx rgba(40,75,57,.12); font-size: 17rpx; font-weight: 700; }
|
||
.map-badge { position: absolute; left: 18rpx; bottom: 16rpx; z-index: 7; display: flex; flex-direction: column; gap: 3rpx; padding: 10rpx 14rpx; border-radius: 10rpx; color: #315543; background: rgba(255,255,255,.9); font-size: 16rpx; }
|
||
.map-badge text:last-child { color: #8a9690; font-size: 13rpx; }
|
||
.list-card { margin-top: 18rpx; overflow: hidden; }
|
||
.place-row { display: flex; gap: 20rpx; padding: 24rpx; border-bottom: 1rpx solid #e8eeeb; }
|
||
.place-row:last-child { border-bottom: 0; }
|
||
.place-art { position: relative; flex-shrink: 0; width: 190rpx; height: 150rpx; overflow: hidden; border-radius: 16rpx; background: linear-gradient(#b8e5f2 0 48%,#a8d58c 49%); }
|
||
.place-art.art-1 { background: linear-gradient(#cce8f2 0 49%,#99c7a0 50%); }
|
||
.place-art.art-2 { background: linear-gradient(#c1e2f4 0 48%,#8ec69a 49%); }
|
||
.place-art.art-3 { background: linear-gradient(#f4d8b4 0 48%,#c18a55 49%); }
|
||
.place-art > text { position: absolute; right: 9rpx; top: 7rpx; color: rgba(255,255,255,.88); font-family: Georgia,serif; font-size: 19rpx; font-weight: 700; }
|
||
.sun { position: absolute; right: 25rpx; top: 21rpx; width: 30rpx; height: 30rpx; border-radius: 50%; background: #fff3a2; }
|
||
.hill { position: absolute; border-radius: 50% 50% 0 0; }
|
||
.hill-back { left: -28rpx; bottom: 38rpx; width: 150rpx; height: 58rpx; background: #74b986; transform: rotate(12deg); }
|
||
.hill-front { right: -34rpx; bottom: 22rpx; width: 160rpx; height: 70rpx; background: #4b9b68; transform: rotate(-7deg); }
|
||
.building { position: absolute; z-index: 2; left: 65rpx; bottom: 22rpx; width: 65rpx; height: 58rpx; display: flex; justify-content: space-around; padding: 13rpx 7rpx 0; box-sizing: border-box; background: #f0eee4; box-shadow: 0 4rpx 8rpx rgba(0,0,0,.18); }
|
||
.building::before { content: ''; position: absolute; left: -8rpx; top: -20rpx; border-right: 40rpx solid transparent; border-bottom: 22rpx solid #557d67; border-left: 40rpx solid transparent; }
|
||
.building view { width: 8rpx; height: 34rpx; background: #759682; }
|
||
.place-copy { min-width: 0; flex: 1; }
|
||
.place-head { display: flex; align-items: flex-start; gap: 10rpx; }
|
||
.place-number { flex-shrink: 0; width: 37rpx; height: 37rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #fff; background: #177fce; font-size: 18rpx; font-weight: 800; }
|
||
.place-name { flex: 1; padding-top: 2rpx; font-size: 27rpx; line-height: 1.35; font-weight: 800; }
|
||
.place-tags { margin: 12rpx 0 10rpx 47rpx; }
|
||
.place-tags text { padding: 5rpx 10rpx; font-size: 15rpx; }
|
||
.activity { display: block; margin-left: 47rpx; color: #68766f; font-size: 20rpx; line-height: 1.55; }
|
||
.place-foot { display: flex; justify-content: space-between; margin: 12rpx 0 0 47rpx; color: #858f8a; font-size: 17rpx; }
|
||
.reason { display: flex; gap: 8rpx; margin: 14rpx 0 0 47rpx; padding: 12rpx; border-radius: 9rpx; color: #426351; background: #f0f8f4; font-size: 17rpx; line-height: 1.45; }
|
||
.reason text { color: #07935a; }
|
||
.cost-card { display: flex; gap: 18rpx; margin-top: 18rpx; padding: 24rpx; border-radius: 20rpx; color: #fff; background: linear-gradient(135deg,#0a965b,#077647); box-shadow: 0 12rpx 28rpx rgba(5,118,69,.17); }
|
||
.cost-icon { flex-shrink: 0; width: 52rpx; height: 52rpx; display: flex; align-items: center; justify-content: center; border: 1rpx solid rgba(255,255,255,.5); border-radius: 50%; font-size: 23rpx; }
|
||
.cost-card > view:last-child { display: flex; flex-direction: column; gap: 7rpx; font-size: 18rpx; line-height: 1.5; }
|
||
.cost-card > view:last-child text:first-child { font-size: 22rpx; font-weight: 800; }
|
||
.adjust-card,.detail-card { margin-top: 18rpx; padding: 28rpx; }
|
||
.section-kicker { display: block; color: #078e55; font-size: 18rpx; font-weight: 800; letter-spacing: 2rpx; }
|
||
.section-title { display: block; margin-top: 8rpx; font-size: 31rpx; font-weight: 800; }
|
||
.quick-list { display: flex; flex-direction: column; margin-top: 20rpx; border: 1rpx solid #e1eae5; border-radius: 14rpx; }
|
||
.quick { display: flex; justify-content: space-between; padding: 21rpx 20rpx; border-bottom: 1rpx solid #e8eeeb; font-size: 21rpx; }
|
||
.quick:last-child { border-bottom: 0; }
|
||
.quick text:last-child { color: #078e55; font-size: 30rpx; line-height: 20rpx; }
|
||
.composer { display: flex; margin-top: 18rpx; overflow: hidden; border: 2rpx solid #dce7e1; border-radius: 14rpx; }
|
||
.composer input { flex: 1; height: 82rpx; padding: 0 20rpx; font-size: 20rpx; }
|
||
.placeholder { color: #a5afaa; }
|
||
.composer button { width: 112rpx; height: 82rpx; margin: 0; border-radius: 0; color: #fff; background: #078e55; font-size: 20rpx; line-height: 82rpx; }
|
||
.composer button::after { border: 0; }
|
||
.composer button[disabled] { opacity: .55; }
|
||
.detail-section + .detail-section { margin-top: 30rpx; padding-top: 26rpx; border-top: 1rpx solid #e5ece8; }
|
||
.note { display: block; margin-top: 13rpx; color: #68766f; font-size: 19rpx; line-height: 1.6; }
|
||
.source { display: flex; align-items: center; justify-content: space-between; gap: 18rpx; padding: 18rpx 0; border-bottom: 1rpx solid #e8eeeb; color: #078e55; font-size: 17rpx; }
|
||
.source view { flex: 1; display: flex; flex-direction: column; gap: 5rpx; color: #34473d; }
|
||
.source view text:last-child { color: #919b96; font-size: 15rpx; }
|
||
.bottom-bar { position: fixed; z-index: 40; right: 0; bottom: 0; left: 0; display: flex; gap: 18rpx; padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #dfe8e3; background: rgba(255,255,255,.96); box-shadow: 0 -10rpx 30rpx rgba(26,70,49,.07); }
|
||
.bottom-bar button { height: 88rpx; display: flex; align-items: center; justify-content: center; gap: 10rpx; margin: 0; border-radius: 18rpx; font-size: 23rpx; font-weight: 800; }
|
||
.bottom-bar button::after { border: 0; }
|
||
.save-button { width: 230rpx; color: #087d4d; border: 2rpx solid #11915b; background: #fff; }
|
||
.save-button text { font-size: 33rpx; font-weight: 400; }
|
||
.accept-button { flex: 1; color: #fff; background: linear-gradient(100deg,#079c5c,#067d4a); box-shadow: 0 10rpx 22rpx rgba(3,129,75,.2); }
|
||
.accept-button text { font-size: 26rpx; transform: rotate(-26deg); }
|
||
.empty { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 28rpx; color: #26372e; background: #f4f8f6; font-size: 31rpx; font-weight: 700; }
|
||
.empty-icon { width: 100rpx; height: 100rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; color: #078e55; background: #e4f5ec; font-size: 56rpx; }
|
||
.empty button { padding: 0 34rpx; border-radius: 16rpx; color: #fff; background: #078e55; font-size: 21rpx; }
|
||
</style>
|