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

1089 lines
26 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, PlanningOrigin, PlanResponse, TravelPreferences } from '@/domain/travel'
import TravelPoiCard from '@/components/travel/TravelPoiCard.vue'
import { getPoiRepository } from '@/data/poi'
import { adjustPlan, getSessionPlanningOrigin, 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 }
}
interface RoutePoint {
latitude: number
longitude: 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 planningOrigin = shallowRef<PlanningOrigin | null>(null)
const quickPrompts = computed(() => plan.value?.itinerary.planningMode === 'custom'
? ['节奏慢一点', '改为步行', '改成半日游']
: ['节奏慢一点', '换成室内场馆', '增加亲子体验'])
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 poiRouteMarkers = 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: String(index + 1),
color: '#18201d',
fontSize: 11,
borderRadius: 7,
bgColor: '#ffffff',
padding: 5,
display: 'ALWAYS',
textAlign: 'center',
},
})))
const originMarker = computed<RouteMarker | null>(() => {
if (!plan.value?.itinerary.startsFromCurrentLocation || !planningOrigin.value)
return null
return {
id: 900000001,
latitude: planningOrigin.value.latitude,
longitude: planningOrigin.value.longitude,
iconPath: '/static/markers/default-selected.png',
width: uni.upx2px(56),
height: uni.upx2px(66),
anchor: { x: 0.5, y: 1 },
callout: {
content: '起点',
color: '#ffffff',
fontSize: 11,
borderRadius: 7,
bgColor: '#b45a1b',
padding: 5,
display: 'ALWAYS',
textAlign: 'center',
},
}
})
const routeMarkers = computed<RouteMarker[]>(() => [
...(originMarker.value ? [originMarker.value] : []),
...poiRouteMarkers.value,
])
const routePoints = computed<RoutePoint[]>(() => [
...(originMarker.value
? [{ latitude: originMarker.value.latitude, longitude: originMarker.value.longitude }]
: []),
...resolvedItems.value.map(({ poi }) => ({
latitude: poi.latitude,
longitude: poi.longitude,
})),
])
const routePolyline = computed(() => [{
points: routePoints.value,
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 '本地 POC 规划'
})
const planningBasisLabel = computed(() => {
const location = plan.value?.itinerary.startsFromCurrentLocation
? planningOrigin.value ? '已结合本次当前位置' : '已结合定位(起点坐标未保留)'
: '光明区全域'
const selection = plan.value?.itinerary.planningMode === 'custom'
? '完整保留自选点位并优化顺序'
: '按偏好、时长和推荐指数自动选点'
return `${location} · ${selection}`
})
const durationFitNotice = computed(() => plan.value ? getDurationFitNotice(plan.value.itinerary.durationFitStatus) : null)
function loadStoredPlan() {
plan.value = loadPlan()
preferences.value = loadPreferences()
planningOrigin.value = plan.value ? getSessionPlanningOrigin(plan.value.conversationId) : null
}
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 || routePoints.value.length === 0)
return
const context = mapContext.value
nextTick(() => {
if (!mapReady.value || mapContext.value !== context)
return
try {
context.includePoints({
points: routePoints.value,
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 openFullRouteOnMap() {
mapStore.setCategory(null)
mapStore.selectPoi(null)
mapStore.showPlannedRoute()
uni.switchTab({ url: '/pages/map/index' })
}
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
planningOrigin.value = getSessionPlanningOrigin(response.conversationId)
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 class="route-hero__basis">
<text>本次规划依据</text>
<text>{{ planningBasisLabel }}</text>
<text>{{ plan.assistantMessage }}</text>
</view>
</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">
{{ originMarker ? '含当前位置起点及全部游览节点' : '展示全部游览节点' }}按规划顺序直线连接
</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-node-sequence">
<text v-if="originMarker" class="route-node-sequence__origin">起点 · 当前位置</text>
<text v-for="({ poi }, index) in resolvedItems" :key="poi.id">{{ index + 1 }} · {{ poi.name }}</text>
</view>
<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="plan.itinerary.planningMode === 'custom' ? '例如:节奏慢一点,或改为步行' : '例如:换成室内场馆'"
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="openFullRouteOnMap">在全域地图查看完整路线</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%);
}
.route-hero__basis {
display: flex;
flex-direction: column;
gap: 5rpx;
padding: 16rpx 18rpx;
margin-top: 18rpx;
background: rgb(255 255 255 / 9%);
border: 1rpx solid rgb(255 255 255 / 15%);
border-radius: 14rpx;
}
.route-hero__basis text {
font-size: 19rpx;
line-height: 30rpx;
color: rgb(238 255 245 / 78%);
}
.route-hero__basis text:first-child {
font-size: 18rpx;
font-weight: 800;
color: #b9f0d0;
}
.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-node-sequence {
display: flex;
gap: 10rpx;
padding: 16rpx 20rpx 4rpx;
overflow-x: auto;
white-space: nowrap;
background: #f8faf9;
}
.route-node-sequence text {
flex: none;
padding: 7rpx 13rpx;
font-size: 18rpx;
line-height: 28rpx;
color: #285542;
background: #e8f4ee;
border-radius: 999rpx;
}
.route-node-sequence .route-node-sequence__origin {
color: #8a431d;
background: #fff0e5;
}
.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>