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

322 lines
20 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.
<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>