Add check-in feature and update travel planner UI

This commit is contained in:
周瑞哲
2026-07-31 12:50:14 +08:00
parent ac4179086c
commit 3ef7266591
44 changed files with 2919 additions and 144 deletions
+132 -18
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { PoiResolved } from '@/domain/poi'
import type { ItineraryItem, PlanResponse, TravelPreferences } from '@/domain/travel'
import type { ItineraryItem, PlanningOrigin, 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 { adjustPlan, getSessionPlanningOrigin, loadPlan, loadPreferences, savePlan } from '@/services/travel-assistant'
import { useMapStore } from '@/stores'
interface ResolvedItineraryItem {
@@ -31,6 +31,11 @@ interface RouteMarker {
anchor: { x: number, y: number }
}
interface RoutePoint {
latitude: number
longitude: number
}
type DurationFitStatus = PlanResponse['itinerary']['durationFitStatus']
interface DurationFitNotice {
@@ -47,7 +52,10 @@ const adjusting = ref(false)
const mapReady = ref(false)
const mapError = ref(false)
const mapContext = shallowRef<ReturnType<typeof uni.createMapContext> | null>(null)
const quickPrompts = ['节奏慢一点', '换成室内场馆', '增加亲子体验']
const planningOrigin = shallowRef<PlanningOrigin | null>(null)
const quickPrompts = computed(() => plan.value?.itinerary.planningMode === 'custom'
? ['节奏慢一点', '改为步行', '改成半日游']
: ['节奏慢一点', '换成室内场馆', '增加亲子体验'])
const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
if (!plan.value)
@@ -59,7 +67,7 @@ const resolvedItems = computed<ResolvedItineraryItem[]>(() => {
})
})
const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
const poiRouteMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ poi }, index) => ({
id: index + 1,
latitude: poi.latitude,
longitude: poi.longitude,
@@ -68,7 +76,7 @@ const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ po
height: uni.upx2px(76),
anchor: { x: 0.5, y: 1 },
callout: {
content: `${index + 1} · ${poi.name}`,
content: String(index + 1),
color: '#18201d',
fontSize: 11,
borderRadius: 7,
@@ -79,11 +87,47 @@ const routeMarkers = computed<RouteMarker[]>(() => resolvedItems.value.map(({ po
},
})))
const routePolyline = computed(() => [{
points: resolvedItems.value.map(({ poi }) => ({
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,
@@ -115,13 +159,23 @@ const planningSourceLabel = computed(() => {
return '真实 AI 推荐'
if (plan.value?.source === 'remote_mock')
return '远端演示规划'
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 {
@@ -186,7 +240,7 @@ function formatDate(value: string): string {
}
function fitRoutePoints() {
if (!mapReady.value || !mapContext.value || resolvedItems.value.length === 0)
if (!mapReady.value || !mapContext.value || routePoints.value.length === 0)
return
const context = mapContext.value
@@ -195,7 +249,7 @@ function fitRoutePoints() {
return
try {
context.includePoints({
points: resolvedItems.value.map(({ poi }) => ({ latitude: poi.latitude, longitude: poi.longitude })),
points: routePoints.value,
padding: [uni.upx2px(70), uni.upx2px(70), uni.upx2px(70), uni.upx2px(70)],
fail: handleMapError,
})
@@ -243,10 +297,11 @@ function focusOnMap(poiId: string) {
uni.switchTab({ url: '/pages/map/index' })
}
function focusFirstOnMap() {
const poi = resolvedItems.value[0]?.poi
if (poi)
focusOnMap(poi.id)
function openFullRouteOnMap() {
mapStore.setCategory(null)
mapStore.selectPoi(null)
mapStore.showPlannedRoute()
uni.switchTab({ url: '/pages/map/index' })
}
function saveRoute() {
@@ -273,6 +328,7 @@ async function sendAdjustment(quick?: string) {
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)
@@ -315,6 +371,11 @@ onReady(initializeMap)
<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">
@@ -335,7 +396,9 @@ onReady(initializeMap)
<view class="route-map-card__heading">
<view>
<text class="route-map-card__title">路线点位分布</text>
<text class="route-map-card__description">按推荐顺序直线连接并非实际道路导航</text>
<text class="route-map-card__description">
{{ originMarker ? '含当前位置起点及全部游览节点' : '展示全部游览节点' }}按规划顺序直线连接
</text>
</view>
<button @click="fitRoutePoints">查看全程</button>
</view>
@@ -357,7 +420,11 @@ onReady(initializeMap)
:show-compass="false"
@error="handleMapError"
/>
<view class="route-map-card__notice">推荐顺序 · 非实时导航</view>
<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">
@@ -401,7 +468,7 @@ onReady(initializeMap)
v-model="message"
:disabled="adjusting"
maxlength="500"
placeholder="例如:第二个地点换成室内项目"
:placeholder="plan.itinerary.planningMode === 'custom' ? '例如:节奏慢一点,或改为步行' : '例如:换成室内场馆'"
placeholder-class="composer__placeholder"
confirm-type="send"
@confirm="sendAdjustment()"
@@ -427,7 +494,7 @@ onReady(initializeMap)
<view class="bottom-actions">
<button class="bottom-actions__save" @click="saveRoute">保存路线</button>
<button class="bottom-actions__map" @click="focusFirstOnMap">在全域地图查看</button>
<button class="bottom-actions__map" @click="openFullRouteOnMap">在全域地图查看完整路线</button>
</view>
</view>
@@ -559,6 +626,29 @@ style:
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;
@@ -676,6 +766,30 @@ style:
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;