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

975 lines
23 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 { PoiResolved } from '@/domain/poi'
import type { ItineraryItem, PlanResponse, TravelPreferences } from '@/domain/travel'
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
import { getPoiRepository } from '@/data/poi'
import { adjustPlan, loadPlan, loadPreferences, savePlan } from '@/services/travel-assistant'
import { useMapStore } from '@/stores'
interface ResolvedItineraryItem {
item: ItineraryItem
poi: PoiResolved
}
interface RouteMarker {
id: number
latitude: number
longitude: number
iconPath: string
width: number
height: number
callout: {
content: string
color: string
fontSize: number
borderRadius: number
bgColor: string
padding: number
display: 'ALWAYS'
textAlign: 'center'
}
anchor: { x: number, y: number }
}
type DurationFitStatus = PlanResponse['itinerary']['durationFitStatus']
interface DurationFitNotice {
title: string
content: string
}
const MAP_ID = 'guangming-itinerary-map'
const mapStore = useMapStore()
const plan = ref<PlanResponse | null>(null)
const preferences = ref<TravelPreferences | null>(null)
const message = ref('')
const adjusting = ref(false)
const mapReady = ref(false)
const mapError = ref(false)
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
const quickPrompts = ['节奏慢一点', '换成室内场馆', '增加亲子体验']
const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
if (!plan.value)
return []
const repository = getPoiRepository()
return plan.value.itinerary.items.flatMap((item) => {
const poi = repository.getPoiById(item.placeId)
return poi ? [{ item, poi }] : []
})
})
const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
id: index + 1,
latitude: poi.latitude,
longitude: poi.longitude,
iconPath: '/static/markers/default-selected.png',
width: uni.upx2px(64),
height: uni.upx2px(76),
anchor: { x: 0.5, y: 1 },
callout: {
content: `${index + 1} · ${poi.name}`,
color: '#18201d',
fontSize: 11,
borderRadius: 7,
bgColor: '#ffffff',
padding: 5,
display: 'ALWAYS',
textAlign: 'center',
},
})))
const routePolyline = computed(() => [{
points: resolvedItems.value.map(({ poi }) => ({
latitude: poi.latitude,
longitude: poi.longitude,
})),
color: '#167a5bcc',
width: 6,
dottedLine: false,
arrowLine: true,
borderColor: '#ffffffcc',
borderWidth: 2,
}])
const mapCenter = computed(() => {
const first = resolvedItems.value[0]?.poi
return {
longitude: first?.longitude ?? 113.9275,
latitude: first?.latitude ?? 22.76,
}
})
const routeTags = computed(() => {
const current = preferences.value
if (!current)
return ['光明文旅', 'POC 路线']
const duration = current.duration === 'half_day' ? '半日游' : '一日游'
const pace = { relaxed: '轻松', moderate: '适中', compact: '紧凑' }[current.pace]
return [duration, current.themes[0], pace, current.interests[0]].filter((tag): tag is string => Boolean(tag))
})
const planningModeLabel = computed(() => plan.value?.itinerary.planningMode === 'custom' ? '自选规划' : '快速规划')
const planningSourceLabel = computed(() => {
if (plan.value?.source === 'remote_ai')
return '真实 AI 推荐'
if (plan.value?.source === 'remote_mock')
return '远端演示规划'
return '本地智能规划'
})
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
function loadStoredPlan() {
plan.value = loadPlan()
preferences.value = loadPreferences()
}
function formatMinutes(minutes: number): string {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (!hours)
return `${rest} 分钟`
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
}
function formatItemTime(item: ItineraryItem, index: number): string {
const startsFromCurrentLocation = index === 0
&& plan.value?.itinerary.startsFromCurrentLocation
&& item.transferFromPreviousMinutes !== null
const transfer = startsFromCurrentLocation
? ` · 从当前位置出发约${item.transferFromPreviousMinutes}分钟`
: index > 0
? item.transferFromPreviousMinutes === null
? ' · 交通待确认'
: ` · 转场约 ${item.transferFromPreviousMinutes} 分钟`
: ''
return `${item.startTime}${item.endTime}${transfer}`
}
function getDurationFitNotice(status: DurationFitStatus): DurationFitNotice | null {
if (status === 'overflow') {
return {
title: '行程可能超出所选时长',
content: '当前安排较为充实,建议调整点位、节奏或出行时间后再出发。',
}
}
if (status === 'underfilled') {
return {
title: '行程安排较为宽松',
content: '当前安排少于所选时长,可继续添加点位或保留机动时间。',
}
}
return null
}
function showDurationFitNotice(status: DurationFitStatus): Promise<void> {
const notice = getDurationFitNotice(status)
if (!notice)
return Promise.resolve()
return new Promise((resolve) => {
uni.showModal({
...notice,
showCancel: false,
confirmText: '我知道了',
complete: () => resolve(),
})
})
}
function formatDate(value: string): string {
const timestamp = Date.parse(value)
if (!Number.isFinite(timestamp))
return '待确认'
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function fitRoutePoints() {
if (!mapReady.value || !mapContext.value || resolvedItems.value.length === 0)
return
const context = mapContext.value
nextTick(() => {
if (!mapReady.value || mapContext.value !== context)
return
try {
context.includePoints({
points: resolvedItems.value.map(({ poi }) => ({ latitude: poi.latitude, longitude: poi.longitude })),
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(70), uni.upx2px(70)],
fail: handleMapError,
})
}
catch (error) {
handleMapError(error)
}
})
}
function initializeMap() {
mapError.value = false
try {
mapContext.value = uni.createMapContext(MAP_ID)
mapReady.value = true
fitRoutePoints()
}
catch (error) {
handleMapError(error)
}
}
function handleMapError(error: unknown) {
console.error('Failed to render itinerary map', error)
mapReady.value = false
mapError.value = true
mapContext.value = null
}
function openPlanner() {
uni.switchTab({ url: '/pages/planner/index' })
}
function openDetail(poiId: string) {
uni.navigateTo({ url: `/pages/poi/detail?poiId=${encodeURIComponent(poiId)}` })
}
function focusOnMap(poiId: string) {
const poi = getPoiRepository().getPoiById(poiId)
if (!poi)
return
mapStore.setCategory(null)
mapStore.selectPoi(poi.id)
mapStore.updateViewport({ longitude: poi.longitude, latitude: poi.latitude, scale: 14 })
uni.switchTab({ url: '/pages/map/index' })
}
function focusFirstOnMap() {
const poi = resolvedItems.value[0]?.poi
if (poi)
focusOnMap(poi.id)
}
function saveRoute() {
if (!plan.value)
return
try {
savePlan(plan.value)
uni.showToast({ title: '已保存到本机', icon: 'success' })
}
catch (error) {
uni.showModal({ title: '保存失败', content: error instanceof Error ? error.message : '请稍后重试', showCancel: false })
}
}
async function sendAdjustment(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)
savePlan(response)
plan.value = response
preferences.value = loadPreferences()
message.value = ''
await showDurationFitNotice(response.itinerary.durationFitStatus)
fitRoutePoints()
uni.pageScrollTo({ scrollTop: 0, duration: 300 })
}
catch (error) {
uni.showModal({
title: '调整未完成',
content: error instanceof Error ? error.message : '上一次路线已为你保留',
showCancel: false,
})
}
finally {
adjusting.value = false
}
}
onLoad(loadStoredPlan)
onReady(initializeMap)
</script>
<template>
<view v-if="plan" class="itinerary-page">
<view class="route-hero">
<view class="route-hero__top">
<view class="route-hero__label">
<text class="route-hero__spark"></text>
{{ planningModeLabel }} · {{ planningSourceLabel }}
</view>
<button @click="openPlanner">重新规划</button>
</view>
<text class="route-hero__title">{{ plan.itinerary.title }}</text>
<view class="route-hero__tags">
<text v-for="tag in routeTags" :key="tag">{{ tag }}</text>
</view>
<view class="route-hero__meta">
<view><text>用户选择时长</text><text>{{ formatMinutes(plan.itinerary.requestedMinutes) }}</text></view>
<view><text>预计总时长</text><text>{{ formatMinutes(plan.itinerary.totalMinutes) }}</text></view>
<view><text>行程点位</text><text>{{ resolvedItems.length }} </text></view>
</view>
<text class="route-hero__summary">{{ plan.itinerary.summary }}</text>
</view>
<view v-if="plan.changeSummary" class="change-notice">
<text>已更新</text>{{ plan.changeSummary }}
</view>
<view
v-if="durationFitNotice"
class="duration-fit-notice"
:class="`duration-fit-notice--${plan.itinerary.durationFitStatus}`"
>
<text class="duration-fit-notice__title">{{ durationFitNotice.title }}</text>
<text class="duration-fit-notice__content">{{ durationFitNotice.content }}</text>
</view>
<view class="itinerary-content">
<view class="route-map-card">
<view class="route-map-card__heading">
<view>
<text class="route-map-card__title">路线点位分布</text>
<text class="route-map-card__description">按推荐顺序直线连接并非实际道路导航</text>
</view>
<button @click="fitRoutePoints">查看全程</button>
</view>
<view v-if="mapError" class="route-map-card__error">
<text>地图暂时无法显示</text>
<button @click="initializeMap">重试地图</button>
</view>
<map
v-else
:id="MAP_ID"
class="route-map-card__map"
:longitude="mapCenter.longitude"
:latitude="mapCenter.latitude"
:scale="11"
:markers="routeMarkers"
:polyline="routePolyline"
:enable-rotate="false"
:enable-overlooking="false"
:show-compass="false"
@error="handleMapError"
/>
<view class="route-map-card__notice">推荐顺序 · 非实时导航</view>
</view>
<view class="route-section">
<view class="route-section__heading">
<view>
<text class="route-section__kicker">ITINERARY</text>
<text class="route-section__title">行程安排</text>
</view>
<text class="route-section__count">{{ String(resolvedItems.length).padStart(2, '0') }}</text>
</view>
<view class="route-list">
<TravelPoiCard
v-for="({ item, poi }, index) in resolvedItems"
:key="item.placeId"
:poi="poi"
:index="index + 1"
:time-text="formatItemTime(item, index)"
:activity="item.activity"
:reason="item.reason"
@detail="openDetail"
@map="focusOnMap"
/>
</view>
</view>
<view class="cost-card">
<view class="cost-card__icon">¥</view>
<view><text>费用提示</text><text>{{ plan.itinerary.estimatedCostText }}</text></view>
</view>
<view class="adjust-card">
<text class="route-section__kicker">ROUTE ADJUSTMENT</text>
<text class="adjust-card__title">还想怎么调整</text>
<view class="quick-list">
<button v-for="prompt in quickPrompts" :key="prompt" :disabled="adjusting" @click="sendAdjustment(prompt)">
<text>{{ prompt }}</text><text></text>
</button>
</view>
<view class="composer">
<input
v-model="message"
:disabled="adjusting"
maxlength="500"
placeholder="例如:第二个地点换成室内项目"
placeholder-class="composer__placeholder"
confirm-type="send"
@confirm="sendAdjustment()"
>
<button :disabled="adjusting || !message.trim()" @click="sendAdjustment()">{{ adjusting ? '调整中' : '发送' }}</button>
</view>
</view>
<view class="details-card">
<view class="details-card__section">
<text class="route-section__kicker">出行提醒</text>
<text v-for="note in plan.itinerary.notes" :key="note" class="details-card__note">· {{ note }}</text>
</view>
<view class="details-card__section">
<text class="route-section__kicker">资料来源</text>
<view v-for="source in plan.itinerary.sources" :key="source.placeId" class="source-row">
<text>{{ source.sourceName }}</text>
<text>{{ formatDate(source.updatedAt) }} 更新</text>
</view>
</view>
</view>
</view>
<view class="bottom-actions">
<button class="bottom-actions__save" @click="saveRoute">保存路线</button>
<button class="bottom-actions__map" @click="focusFirstOnMap">在全域地图查看</button>
</view>
</view>
<view v-else class="empty-route">
<view class="empty-route__icon"></view>
<text class="empty-route__title">还没有生成路线</text>
<text class="empty-route__copy">先选择出行主题时长和兴趣偏好助手会基于本地点位为你安排</text>
<button @click="openPlanner">开始规划</button>
</view>
</template>
<route lang="yaml">
layout: map
style:
navigationBarTitleText: 推荐路线
navigationBarBackgroundColor: '#ffffff'
navigationBarTextStyle: black
backgroundColor: '#f4f8f6'
</route>
<style scoped lang="scss">
.itinerary-page {
min-height: 100vh;
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
color: #17221d;
background: #f4f8f6;
}
.route-hero {
padding: 34rpx 32rpx 38rpx;
color: #fff;
background: linear-gradient(145deg, #123d35, #08724d);
}
.route-hero__top {
display: flex;
align-items: center;
justify-content: space-between;
}
.route-hero__label {
display: flex;
gap: 8rpx;
align-items: center;
font-size: 21rpx;
font-weight: 700;
color: #b9f0d0;
}
.route-hero__spark {
font-size: 30rpx;
}
.route-hero__top button {
width: auto;
height: 58rpx;
padding: 0 20rpx;
margin: 0;
font-size: 20rpx;
line-height: 58rpx;
color: #eafff2;
background: rgb(255 255 255 / 11%);
border: 1rpx solid rgb(255 255 255 / 24%);
border-radius: 29rpx;
}
.route-hero__top button::after,
.route-map-card button::after,
.quick-list button::after,
.composer button::after,
.bottom-actions button::after,
.empty-route button::after {
border: 0;
}
.route-hero__title {
display: block;
margin-top: 24rpx;
font-size: 42rpx;
font-weight: 800;
line-height: 58rpx;
}
.route-hero__tags {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 18rpx;
}
.route-hero__tags text {
padding: 7rpx 13rpx;
font-size: 18rpx;
line-height: 28rpx;
color: #eafff2;
background: rgb(255 255 255 / 10%);
border: 1rpx solid rgb(255 255 255 / 22%);
border-radius: 999rpx;
}
.route-hero__meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20rpx;
margin-top: 25rpx;
}
.route-hero__meta view {
display: flex;
flex-direction: column;
}
.route-hero__meta text:first-child {
font-size: 18rpx;
color: rgb(238 255 245 / 65%);
}
.route-hero__meta text:last-child {
margin-top: 5rpx;
font-size: 29rpx;
font-weight: 800;
}
.route-hero__summary {
display: block;
margin-top: 22rpx;
font-size: 21rpx;
line-height: 35rpx;
color: rgb(238 255 245 / 75%);
}
.change-notice {
padding: 18rpx 28rpx;
font-size: 21rpx;
line-height: 32rpx;
color: #486656;
background: #e4f5ec;
border-bottom: 1rpx solid #cce6d8;
}
.change-notice text {
padding: 4rpx 9rpx;
margin-right: 10rpx;
font-size: 17rpx;
font-weight: 800;
color: #fff;
background: #16805c;
border-radius: 8rpx;
}
.duration-fit-notice {
display: flex;
flex-direction: column;
gap: 6rpx;
padding: 20rpx 28rpx;
color: #78551f;
background: #fff8e8;
border-bottom: 1rpx solid #f0dfb8;
}
.duration-fit-notice--overflow {
color: #8a432f;
background: #fff1ec;
border-bottom-color: #f1d1c6;
}
.duration-fit-notice__title {
font-size: 22rpx;
font-weight: 800;
line-height: 32rpx;
}
.duration-fit-notice__content {
font-size: 19rpx;
line-height: 30rpx;
opacity: 0.85;
}
.itinerary-content {
padding: 24rpx 20rpx 48rpx;
}
.route-map-card,
.adjust-card,
.details-card {
overflow: hidden;
background: #fff;
border: 1rpx solid #d9e7e0;
border-radius: 24rpx;
box-shadow: 0 12rpx 34rpx rgb(20 78 53 / 6%);
}
.route-map-card__heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 24rpx;
}
.route-map-card__heading > view {
display: flex;
flex-direction: column;
}
.route-map-card__title {
font-size: 27rpx;
font-weight: 800;
line-height: 38rpx;
}
.route-map-card__description {
margin-top: 3rpx;
font-size: 17rpx;
line-height: 28rpx;
color: #85928b;
}
.route-map-card__heading button,
.route-map-card__error button {
width: auto;
height: 54rpx;
padding: 0 17rpx;
margin: 0;
font-size: 19rpx;
line-height: 54rpx;
color: #167a5b;
background: #eaf5f0;
border: 0;
border-radius: 27rpx;
}
.route-map-card__map {
width: 100%;
height: 430rpx;
}
.route-map-card__error {
display: flex;
flex-direction: column;
gap: 20rpx;
align-items: center;
justify-content: center;
height: 430rpx;
font-size: 24rpx;
color: #637069;
background: #eef3f0;
}
.route-map-card__notice {
padding: 12rpx 20rpx;
font-size: 18rpx;
line-height: 28rpx;
color: #718078;
text-align: center;
background: #f8faf9;
}
.route-section {
margin-top: 44rpx;
}
.route-section__heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 22rpx;
}
.route-section__kicker {
display: block;
font-size: 17rpx;
font-weight: 700;
line-height: 27rpx;
color: #16805c;
letter-spacing: 2rpx;
}
.route-section__title {
display: block;
margin-top: 6rpx;
font-size: 34rpx;
font-weight: 800;
line-height: 48rpx;
}
.route-section__count {
font-family: Georgia, serif;
font-size: 50rpx;
font-style: italic;
color: rgb(23 56 47 / 18%);
}
.route-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.cost-card {
display: flex;
gap: 18rpx;
padding: 24rpx;
margin-top: 22rpx;
color: #fff;
background: linear-gradient(135deg, #0a965b, #077647);
border-radius: 20rpx;
box-shadow: 0 12rpx 28rpx rgb(5 118 69 / 17%);
}
.cost-card__icon {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 52rpx;
height: 52rpx;
font-size: 23rpx;
border: 1rpx solid rgb(255 255 255 / 50%);
border-radius: 50%;
}
.cost-card > view:last-child {
display: flex;
flex-direction: column;
gap: 7rpx;
font-size: 18rpx;
line-height: 30rpx;
}
.cost-card > view:last-child text:first-child {
font-size: 22rpx;
font-weight: 800;
}
.adjust-card,
.details-card {
padding: 26rpx;
margin-top: 22rpx;
}
.adjust-card__title {
display: block;
margin-top: 7rpx;
font-size: 30rpx;
font-weight: 800;
line-height: 42rpx;
}
.quick-list {
display: flex;
flex-direction: column;
margin-top: 18rpx;
overflow: hidden;
border: 1rpx solid #e1eae5;
border-radius: 14rpx;
}
.quick-list button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 76rpx;
padding: 0 19rpx;
margin: 0;
font-size: 21rpx;
line-height: 76rpx;
text-align: left;
background: #fff;
border: 0;
border-bottom: 1rpx solid #e8eeeb;
border-radius: 0;
}
.quick-list button:last-child {
border-bottom: 0;
}
.quick-list button text:last-child {
font-size: 30rpx;
color: #078e55;
}
.composer {
display: flex;
margin-top: 17rpx;
overflow: hidden;
border: 1rpx solid #dce7e1;
border-radius: 14rpx;
}
.composer input {
flex: 1;
height: 82rpx;
padding: 0 18rpx;
font-size: 20rpx;
}
.composer__placeholder {
color: #a5afaa;
}
.composer button {
width: 120rpx;
height: 82rpx;
padding: 0;
margin: 0;
font-size: 20rpx;
line-height: 82rpx;
color: #fff;
background: #078e55;
border: 0;
border-radius: 0;
}
.composer button[disabled],
.quick-list button[disabled] {
opacity: 0.55;
}
.details-card__section + .details-card__section {
padding-top: 24rpx;
margin-top: 25rpx;
border-top: 1rpx solid #e5ece8;
}
.details-card__note {
display: block;
margin-top: 12rpx;
font-size: 19rpx;
line-height: 31rpx;
color: #68766f;
}
.source-row {
display: flex;
gap: 18rpx;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
font-size: 17rpx;
line-height: 27rpx;
color: #34473d;
border-bottom: 1rpx solid #e8eeeb;
}
.source-row text:first-child {
flex: 1;
}
.source-row text:last-child {
flex: none;
color: #919b96;
}
.bottom-actions {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: flex;
gap: 16rpx;
padding: 18rpx 22rpx calc(18rpx + env(safe-area-inset-bottom));
background: rgb(255 255 255 / 96%);
border-top: 1rpx solid #dfe8e3;
box-shadow: 0 -10rpx 30rpx rgb(26 70 49 / 7%);
}
.bottom-actions button {
height: 82rpx;
margin: 0;
font-size: 22rpx;
font-weight: 800;
line-height: 82rpx;
border-radius: 18rpx;
}
.bottom-actions__save {
width: 220rpx;
color: #087d4d;
background: #fff;
border: 1rpx solid #11915b;
}
.bottom-actions__map {
flex: 1;
color: #fff;
background: linear-gradient(100deg, #079c5c, #067d4a);
border: 0;
box-shadow: 0 10rpx 22rpx rgb(3 129 75 / 20%);
}
.empty-route {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 48rpx;
text-align: center;
background: #f4f8f6;
}
.empty-route__icon {
display: flex;
align-items: center;
justify-content: center;
width: 104rpx;
height: 104rpx;
font-size: 58rpx;
color: #078e55;
background: #e4f5ec;
border-radius: 50%;
}
.empty-route__title {
margin-top: 26rpx;
font-size: 31rpx;
font-weight: 800;
line-height: 44rpx;
}
.empty-route__copy {
max-width: 560rpx;
margin-top: 12rpx;
font-size: 21rpx;
line-height: 34rpx;
color: #718078;
}
.empty-route button {
width: 300rpx;
height: 82rpx;
margin-top: 28rpx;
font-size: 23rpx;
font-weight: 700;
line-height: 82rpx;
color: #fff;
background: #078e55;
border: 0;
border-radius: 18rpx;
}
</style>