152 lines
8.9 KiB
Vue
152 lines
8.9 KiB
Vue
<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>
|