Files
frontend-miniapp/src/components/navigation/RoutePlannerPanel.vue
lyf 0380a4f875
Some checks failed
CI / verify (push) Has been cancelled
修复地图起点确认弹窗闪退
2026-07-28 20:16:28 +08:00

152 lines
8.9 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="visible" class="route-planner-panel">
<view class="route-panel-header">
<view class="route-panel-copy">
<text class="route-panel-title">馆内导览</text>
<text class="route-panel-subtitle">{{ panelSubtitle }}</text>
</view>
<view class="route-panel-close" aria-label="关闭导览" @tap="emit('back')">
<text class="route-panel-close-text">×</text>
</view>
</view>
<view v-if="startPoint && !hasRoutePreview && !loading" class="route-endpoint-row start-row">
<view class="route-point-dot start"></view>
<view class="route-point-copy">
<text class="route-point-name">{{ normalizeVisitorPoiDisplayName(startPoint.name) }}</text>
<text class="route-point-meta">起点 · {{ pointMeta(startPoint) }}</text>
</view>
</view>
<view v-if="loading" class="route-state-card">
<text class="route-state-text">正在生成路线...</text>
</view>
<view v-else-if="error" class="route-state-card error">
<text class="route-state-text">{{ error }}</text>
</view>
<view v-else-if="hasRoutePreview" class="route-ready-card">
<text class="route-ready-summary">{{ summary }}</text>
<view class="route-primary-action" @tap="emit('simulateGuide')">
<text class="route-primary-action-text">模拟导览</text>
</view>
</view>
<view v-else class="route-start-card">
<view class="route-point-dot start"></view>
<view class="route-start-copy">
<text class="route-start-title">请点击地图选择起点</text>
<text class="route-start-desc">仅可选择已接入馆内路网的地点</text>
</view>
</view>
<view v-if="startCandidate" class="route-confirm-mask" @tap.stop="handleCancelStart">
<view class="route-confirm-card" @tap.stop>
<text class="route-confirm-title">确认选择</text>
<text class="route-confirm-message">{{ normalizeVisitorPoiDisplayName(startCandidate.name) }} 为起点吗</text>
<view class="route-confirm-actions">
<view class="route-confirm-action secondary" @tap.stop="handleCancelStart">
<text class="route-confirm-action-text">取消</text>
</view>
<view class="route-confirm-action primary" @tap.stop="handleConfirmStart">
<text class="route-confirm-action-text">确定</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { RoutePointOption } from '@/components/navigation/RoutePointPicker.vue'
import { normalizeVisitorPoiDisplayName } from '@/view-models/visitorPoiPresentation'
const props = withDefaults(defineProps<{
visible?: boolean
startPoint?: RoutePointOption | null
endPoint?: RoutePointOption | null
startCandidate?: RoutePointOption | null
hasRoutePreview?: boolean
loading?: boolean
error?: string
summary?: string
}>(), {
visible: true,
startPoint: null,
endPoint: null,
startCandidate: null,
hasRoutePreview: false,
loading: false,
error: '',
summary: ''
})
const emit = defineEmits<{
confirmStart: [point: RoutePointOption]
cancelStart: []
simulateGuide: []
back: []
}>()
const confirmationInteractive = ref(false)
let confirmationInteractiveTimer: ReturnType<typeof window.setTimeout> | null = null
const confirmationInteractionGuardMs = 350
const clearConfirmationInteractiveTimer = () => {
if (confirmationInteractiveTimer === null) return
window.clearTimeout(confirmationInteractiveTimer)
confirmationInteractiveTimer = null
}
watch(() => props.startCandidate, (candidate) => {
clearConfirmationInteractiveTimer()
confirmationInteractive.value = false
if (!candidate) return
confirmationInteractiveTimer = window.setTimeout(() => {
confirmationInteractiveTimer = null
confirmationInteractive.value = Boolean(props.startCandidate)
}, confirmationInteractionGuardMs)
}, {
immediate: true,
flush: 'sync'
})
onBeforeUnmount(clearConfirmationInteractiveTimer)
const handleCancelStart = () => {
if (!confirmationInteractive.value) return
emit('cancelStart')
}
const handleConfirmStart = () => {
if (!confirmationInteractive.value || !props.startCandidate) return
emit('confirmStart', props.startCandidate)
}
const pointMeta = (point: RoutePointOption) => (
point.categoryLabel ? `${point.floorLabel} · ${point.categoryLabel}` : point.floorLabel
)
const pointTitle = (point: RoutePointOption) => {
const floorLabel = point.floorLabel?.trim()
const name = normalizeVisitorPoiDisplayName(point.name) || '未命名地点'
if (!floorLabel || name.startsWith(`${floorLabel} `)) return name
return `${floorLabel} ${name}`
}
const panelSubtitle = computed(() => {
if (props.startPoint && props.endPoint) {
return `${pointTitle(props.startPoint)} 前往 ${pointTitle(props.endPoint)}`
}
return props.endPoint ? `前往 ${pointTitle(props.endPoint)}` : '正在准备目的地'
})
</script>
<style scoped lang="scss">
.route-planner-panel { position: absolute; left: 12px; right: 12px; bottom: calc(env(safe-area-inset-bottom) + 24px); padding: 12px 14px 14px; box-sizing: border-box; background: rgba(255, 255, 255, 0.97); border: 1px solid #e5e6de; border-radius: 8px; box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12); z-index: 1003; }
.route-panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }.route-panel-copy { min-width: 0; display: flex; flex: 1; flex-direction: column; gap: 3px; }.route-panel-title { color: #151713; font-size: 17px; font-weight: 700; line-height: 24px; }.route-panel-subtitle { overflow: hidden; color: #696962; font-size: 12px; line-height: 17px; text-overflow: ellipsis; white-space: nowrap; }.route-panel-close { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 28px; height: 28px; background: #f5f7f2; border: 1px solid #e4e5df; border-radius: 8px; box-sizing: border-box; }.route-panel-close-text { color: #545861; font-size: 18px; line-height: 20px; }
.route-endpoint-row, .route-start-card { position: relative; display: flex; align-items: center; gap: 10px; margin-top: 12px; padding: 10px 12px 10px 34px; box-sizing: border-box; background: #f5f7fc; border: 1px solid #d9e0f2; border-radius: 8px; }.route-point-dot { position: absolute; left: 13px; width: 9px; height: 9px; border-radius: 50%; }.route-point-dot.start { background: #36a96c; }.route-point-dot.end { background: #d75b57; }.route-point-copy, .route-start-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }.route-point-name, .route-start-title { overflow: hidden; color: #1f2329; font-size: 14px; font-weight: 600; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }.route-point-meta, .route-start-desc { color: #696f7d; font-size: 12px; line-height: 17px; }.route-start-card { background: #f7f8f4; border-color: #e5e6de; }.route-start-title { font-weight: 600; }
.route-state-card { margin-top: 10px; padding: 10px 12px; background: #f7f8f4; border-radius: 8px; }.route-state-card.error { background: #fff4f1; }.route-state-text { color: #696962; font-size: 12px; line-height: 18px; }.route-state-card.error .route-state-text { color: #ad4a40; }.route-ready-card { margin-top: 10px; }.route-ready-summary { display: block; color: #1f2329; font-size: 15px; font-weight: 600; line-height: 21px; }.route-ready-note { display: block; margin-top: 4px; color: #697386; font-size: 12px; line-height: 17px; }.route-primary-action { height: 42px; margin-top: 10px; display: flex; align-items: center; justify-content: center; background: #1565c0; border-radius: 8px; }.route-primary-action-text { color: #ffffff; font-size: 14px; font-weight: 600; line-height: 19px; }
.route-confirm-mask { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; background: rgba(18, 27, 45, 0.3); z-index: 1010; }.route-confirm-card { width: min(300px, calc(100vw - 48px)); padding: 22px 20px 18px; box-sizing: border-box; background: #ffffff; border-radius: 8px; box-shadow: 0 18px 38px rgba(31, 42, 77, 0.24); }.route-confirm-title { display: block; color: #1f2329; font-size: 18px; font-weight: 700; line-height: 25px; text-align: center; }.route-confirm-message { display: block; margin-top: 8px; color: #4d5564; font-size: 15px; line-height: 22px; text-align: center; }.route-confirm-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 20px; }.route-confirm-action { height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 8px; box-sizing: border-box; }.route-confirm-action.secondary { background: #f5f7fb; border: 1px solid #d9e0f2; }.route-confirm-action.primary { background: #1565c0; border: 1px solid #1565c0; }.route-confirm-action-text { color: #1a237e; font-size: 14px; font-weight: 600; line-height: 19px; }.route-confirm-action.primary .route-confirm-action-text { color: #ffffff; }
</style>