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

322 lines
20 KiB
Vue
Raw Normal View History

2026-07-30 16:04:34 +08:00
<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>