This commit is contained in:
@@ -9,13 +9,26 @@
|
||||
</view>
|
||||
|
||||
<scroll-view class="explain-scroll" scroll-y :show-scrollbar="false" @scroll="handleScroll" @scrolltolower="requestMore">
|
||||
<view v-if="loading" class="state-block"><text class="state-title">正在加载讲解对象</text><text class="state-desc">稍后将展示该展厅的讲解对象。</text></view>
|
||||
<GuideLoadingState v-if="loading" title="正在加载讲解对象" description="请稍候" />
|
||||
<view v-else-if="error" class="state-block error"><text class="state-title">讲解对象加载失败</text><text class="state-desc">{{ error }}</text><button class="state-retry" @tap="emit('retry')">重新加载</button></view>
|
||||
<template v-else>
|
||||
<view v-if="guideStops.length" class="stop-list">
|
||||
<view v-for="stop in guideStops" :key="stop.id" class="stop-card" @tap="emit('guideStopClick', stop)">
|
||||
<image v-if="stop.coverImageUrl" class="stop-cover" :src="stop.coverImageUrl" mode="aspectFit" />
|
||||
<view v-else class="stop-cover placeholder" />
|
||||
<image
|
||||
v-if="shouldLoadCover(stop)"
|
||||
class="stop-cover"
|
||||
:src="stop.coverImageUrl"
|
||||
mode="aspectFit"
|
||||
@error="handleImageError(stop.id)"
|
||||
/>
|
||||
<view
|
||||
v-else-if="stop.coverImageUrl && !failedImageIds.has(stop.id)"
|
||||
:ref="(element) => registerCoverSlot(stop.id, element)"
|
||||
class="stop-cover loading"
|
||||
/>
|
||||
<view v-else class="stop-cover placeholder">
|
||||
<text class="stop-cover-fallback">{{ stop.name.slice(0, 1) }}</text>
|
||||
</view>
|
||||
<view class="stop-copy">
|
||||
<text class="stop-name">{{ stop.name }}</text>
|
||||
</view>
|
||||
@@ -34,8 +47,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||
import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
|
||||
|
||||
export interface ExplainGuideStopCatalogItem {
|
||||
id: string
|
||||
@@ -67,9 +81,70 @@ const props = withDefaults(defineProps<{
|
||||
const emit = defineEmits<{ guideStopClick: [stop: ExplainGuideStopCatalogItem], back: [], retry: [], retryMore: [] }>()
|
||||
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
||||
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
|
||||
const failedImageIds = ref(new Set<string>())
|
||||
const visibleCoverIds = ref(new Set<string>())
|
||||
const coverSlots = new Map<string, Element>()
|
||||
const supportsIntersectionObserver = typeof IntersectionObserver !== 'undefined'
|
||||
let coverObserver: IntersectionObserver | null = null
|
||||
|
||||
const shouldLoadCover = (stop: ExplainGuideStopCatalogItem) => Boolean(
|
||||
stop.coverImageUrl
|
||||
&& !failedImageIds.value.has(stop.id)
|
||||
&& (!supportsIntersectionObserver || visibleCoverIds.value.has(stop.id))
|
||||
)
|
||||
|
||||
const resolveDomElement = (element: unknown): Element | null => {
|
||||
if (element instanceof Element) return element
|
||||
if (element && typeof element === 'object' && '$el' in element) {
|
||||
const componentElement = (element as { $el?: unknown }).$el
|
||||
if (componentElement instanceof Element) return componentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const registerCoverSlot = (stopId: string, instance: unknown) => {
|
||||
const element = resolveDomElement(instance)
|
||||
if (!element) {
|
||||
coverSlots.delete(stopId)
|
||||
return
|
||||
}
|
||||
|
||||
coverSlots.set(stopId, element)
|
||||
coverObserver?.observe(element)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!supportsIntersectionObserver) return
|
||||
|
||||
coverObserver = new IntersectionObserver((entries) => {
|
||||
const nextVisibleIds = new Set(visibleCoverIds.value)
|
||||
let changed = false
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return
|
||||
const stopId = [...coverSlots.entries()].find(([, element]) => element === entry.target)?.[0]
|
||||
if (!stopId || nextVisibleIds.has(stopId)) return
|
||||
nextVisibleIds.add(stopId)
|
||||
coverObserver?.unobserve(entry.target)
|
||||
changed = true
|
||||
})
|
||||
if (changed) visibleCoverIds.value = nextVisibleIds
|
||||
}, { rootMargin: '160px 0px' })
|
||||
|
||||
coverSlots.forEach((element) => coverObserver?.observe(element))
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
coverObserver?.disconnect()
|
||||
coverObserver = null
|
||||
coverSlots.clear()
|
||||
})
|
||||
|
||||
const handleImageError = (stopId: string) => {
|
||||
failedImageIds.value = new Set([...failedImageIds.value, stopId])
|
||||
}
|
||||
|
||||
const availabilityLabel = (stop: ExplainGuideStopCatalogItem) => (
|
||||
stop.audioStatus === 'READY' || stop.hasAudio ? '音频' : stop.hasTextRecord ? '图文' : '暂无内容'
|
||||
stop.audioStatus === 'READY' || stop.hasAudio ? '讲解' : stop.hasTextRecord ? '图文' : '暂无内容'
|
||||
)
|
||||
const requestMore = () => { if (props.hasMore && !props.loading && !props.loadingMore && !props.loadMoreError) emit('retryMore') }
|
||||
const handleScroll = (event: Event) => { const target = event.target as HTMLElement | null; if (target && target.scrollTop + target.clientHeight >= target.scrollHeight - 8) requestMore() }
|
||||
@@ -81,6 +156,6 @@ const handleScroll = (event: Event) => { const target = event.target as HTMLElem
|
||||
.header-back { position: absolute; left: 12px; top: 10px; width: 44px; height: 44px; display: flex; align-items: center; }.header-back-icon { width: 20px; height: 20px; position: relative; } .header-back-icon::before { content: ''; position: absolute; left: 3px; top: 4px; width: 10px; height: 10px; border-left: 1.5px solid #262421; border-bottom: 1.5px solid #262421; transform: rotate(45deg); }
|
||||
.header-back-text { display: none; }.header-title { max-width: calc(100% - 152px); padding: 0 8px; font-size: 18px; line-height: 26px; font-weight: 700; color: #262421; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.explain-scroll { height: 100%; padding: 80px 16px calc(16px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; }.host-navigation .explain-scroll { padding-top: 16px; }
|
||||
.stop-list { display: flex; width: 100%; min-width: 0; flex-direction: column; gap: 4px; }.stop-card { width: 100%; min-width: 0; height: 80px; padding: 8px 0; display: flex; align-items: center; gap: 12px; box-sizing: border-box; overflow: hidden; border: 0; border-bottom: 1px solid #dbded4; border-radius: 0; background: transparent; }.stop-card:active { background: rgba(227, 235, 201, 0.4); }.stop-cover { flex: 0 0 64px; width: 64px; height: 64px; overflow: hidden; border-radius: 6px; background: #f5f5ed; }.stop-copy { min-width: 0; display: flex; flex: 1; overflow: hidden; }.stop-name { display: -webkit-box; max-height: 40px; font-size: 14px; line-height: 20px; font-weight: 700; color: #262421; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.stop-status { flex: 0 0 44px; width: 44px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: #e3ebc9; }.stop-status-text { font-size: 11px; line-height: 16px; color: #262421; white-space: nowrap; }
|
||||
.stop-list { display: flex; width: 100%; min-width: 0; flex-direction: column; gap: 4px; }.stop-card { width: 100%; min-width: 0; height: 80px; padding: 8px 0; display: flex; align-items: center; gap: 12px; box-sizing: border-box; overflow: hidden; border: 0; border-bottom: 1px solid #dbded4; border-radius: 0; background: transparent; }.stop-card:active { background: rgba(227, 235, 201, 0.4); }.stop-cover { flex: 0 0 64px; width: 64px; height: 64px; overflow: hidden; border-radius: 6px; background: #f5f5ed; }.stop-cover-fallback { font-size: 22px; line-height: 28px; color: #a9b29b; }.stop-copy { min-width: 0; display: flex; flex: 1; overflow: hidden; }.stop-name { display: -webkit-box; max-height: 40px; font-size: 14px; line-height: 20px; font-weight: 700; color: #262421; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.stop-status { flex: 0 0 44px; width: 44px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: #e3ebc9; }.stop-status-text { font-size: 11px; line-height: 16px; color: #262421; white-space: nowrap; }
|
||||
.state-block { margin: 34px 0; padding: 22px 16px; box-sizing: border-box; text-align: center; border: 1px solid #e4e6df; border-radius: 8px; background: #fff; }.state-block.error { border-color: #e5c2c2; background: #fff7f7; }.state-title,.state-desc { display: block; }.state-title { font-size: 15px; line-height: 21px; font-weight: 700; color: #141412; }.state-desc { margin-top: 6px; font-size: 12px; line-height: 18px; color: #68725d; }.state-retry { margin-top: 12px; padding: 0 14px; font-size: 13px; line-height: 32px; color: #141412; background: #e0df00; border: 0; border-radius: 6px; }.load-more-state { min-height: 52px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); text-align: center; }
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
class="explain-scroll"
|
||||
:class="{
|
||||
'hall-stage': stage === 'hall',
|
||||
'unit-stage': stage === 'unit',
|
||||
'stop-stage': stage === 'stop'
|
||||
}"
|
||||
scroll-y
|
||||
@@ -19,10 +20,11 @@
|
||||
@scrolltolower="handleScrollToLower"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view v-if="loading" class="state-block">
|
||||
<text class="state-title">{{ loadingTitle }}</text>
|
||||
<text class="state-desc">{{ loadingDescription }}</text>
|
||||
</view>
|
||||
<GuideLoadingState
|
||||
v-if="loading"
|
||||
:title="loadingTitle"
|
||||
:description="loadingDescription"
|
||||
/>
|
||||
|
||||
<view v-else-if="error" class="state-block error">
|
||||
<text class="state-title">讲解对象加载失败</text>
|
||||
@@ -39,35 +41,57 @@
|
||||
:style="{ '--hall-card-color': hallCardTheme(hall).color }"
|
||||
@tap="handleHallClick(hall.id)"
|
||||
>
|
||||
<view
|
||||
v-if="hallPreviewUrl(hall)"
|
||||
class="hall-card-art hall-thumb-shell"
|
||||
:class="{
|
||||
'is-image-loaded': isHallPreviewLoaded(hall.id),
|
||||
'is-image-error': isHallPreviewError(hall.id)
|
||||
}"
|
||||
>
|
||||
<view class="hall-thumb-fallback">
|
||||
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||
<view
|
||||
v-if="isCustomHallCard(hall)"
|
||||
class="hall-full-card-art"
|
||||
:class="{
|
||||
'is-image-loaded': isHallPreviewLoaded(hall.id),
|
||||
'is-image-error': isHallPreviewError(hall.id)
|
||||
}"
|
||||
>
|
||||
<image
|
||||
v-if="!isHallPreviewError(hall.id)"
|
||||
class="hall-full-card-image"
|
||||
:src="hallPreviewUrl(hall)"
|
||||
mode="aspectFill"
|
||||
:lazy-load="isHallPreviewLazy(index)"
|
||||
@load="handleHallPreviewLoad(hall.id)"
|
||||
@error="handleHallPreviewError(hall.id)"
|
||||
/>
|
||||
<view v-else class="hall-full-card-fallback">
|
||||
<text class="hall-name">{{ hall.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<image
|
||||
v-if="!isHallPreviewError(hall.id)"
|
||||
class="hall-card-art-image"
|
||||
:src="hallPreviewUrl(hall)"
|
||||
mode="aspectFit"
|
||||
:lazy-load="isHallPreviewLazy(index)"
|
||||
@load="handleHallPreviewLoad(hall.id)"
|
||||
@error="handleHallPreviewError(hall.id)"
|
||||
/>
|
||||
<template v-else>
|
||||
<view
|
||||
v-if="hallPreviewUrl(hall)"
|
||||
class="hall-card-art hall-thumb-shell"
|
||||
:class="{
|
||||
'is-image-loaded': isHallPreviewLoaded(hall.id),
|
||||
'is-image-error': isHallPreviewError(hall.id)
|
||||
}"
|
||||
>
|
||||
<view class="hall-thumb-fallback">
|
||||
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="!isHallPreviewError(hall.id)"
|
||||
class="hall-card-art-image"
|
||||
:src="hallPreviewUrl(hall)"
|
||||
mode="aspectFit"
|
||||
:lazy-load="isHallPreviewLazy(index)"
|
||||
@load="handleHallPreviewLoad(hall.id)"
|
||||
@error="handleHallPreviewError(hall.id)"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="hall-card-art placeholder">
|
||||
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||
</view>
|
||||
<view class="hall-overview-copy">
|
||||
<text class="hall-name">{{ hall.name }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<view v-else class="hall-card-art placeholder">
|
||||
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="hall-overview-copy">
|
||||
<text class="hall-name">{{ hall.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="stage === 'stop' && guideStops.length" class="hall-list">
|
||||
@@ -106,6 +130,34 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="stage === 'unit' && businessUnits.length" class="hall-list">
|
||||
<view
|
||||
v-for="unit in businessUnits"
|
||||
:key="unit.id"
|
||||
class="hall-card business-unit-card"
|
||||
@tap="handleBusinessUnitClick(unit.id)"
|
||||
>
|
||||
<image
|
||||
v-if="unit.previewImageUrl"
|
||||
class="hall-thumb"
|
||||
:src="unit.previewImageUrl"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view v-else class="hall-thumb placeholder">
|
||||
<text class="hall-thumb-text">{{ hallIconText(unit.name) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="hall-main">
|
||||
<text class="hall-name">{{ unit.name }}</text>
|
||||
<view class="hall-meta-row">
|
||||
<text class="hall-meta-text">{{ unit.guideStopCount }} 个讲解点</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="hall-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="state-block empty">
|
||||
<text class="state-title">{{ emptyTitle }}</text>
|
||||
<text class="state-desc">{{ emptyDescription }}</text>
|
||||
@@ -126,6 +178,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||
import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
|
||||
|
||||
export interface ExplainHallSelectItem {
|
||||
id: string
|
||||
@@ -155,10 +208,19 @@ export interface ExplainGuideStopSelectItem {
|
||||
playTargetId?: string
|
||||
}
|
||||
|
||||
export type ExplainSelectStage = 'hall' | 'stop'
|
||||
export interface ExplainBusinessUnitSelectItem {
|
||||
id: string
|
||||
name: string
|
||||
hallId?: string
|
||||
previewImageUrl?: string
|
||||
guideStopCount: number
|
||||
}
|
||||
|
||||
export type ExplainSelectStage = 'hall' | 'unit' | 'stop'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
halls?: ExplainHallSelectItem[]
|
||||
businessUnits?: ExplainBusinessUnitSelectItem[]
|
||||
guideStops?: ExplainGuideStopSelectItem[]
|
||||
stage?: ExplainSelectStage
|
||||
selectedHallName?: string
|
||||
@@ -170,6 +232,7 @@ const props = withDefaults(defineProps<{
|
||||
forceInternalHeader?: boolean
|
||||
}>(), {
|
||||
halls: () => [],
|
||||
businessUnits: () => [],
|
||||
guideStops: () => [],
|
||||
stage: 'hall',
|
||||
selectedHallName: '',
|
||||
@@ -183,27 +246,28 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
hallClick: [hallId: string]
|
||||
businessUnitClick: [unitId: string]
|
||||
guideStopClick: [stop: ExplainGuideStopSelectItem]
|
||||
back: []
|
||||
retry: []
|
||||
retryMore: []
|
||||
}>()
|
||||
|
||||
const HALL_PREVIEW_BASE = '/static/icons/halls'
|
||||
const HALL_PREVIEW_BASE = '/static/icons/halls/custom-cards'
|
||||
const HALL_PREVIEW_EAGER_COUNT = 4
|
||||
const loadedHallPreviewIds = ref(new Set<string>())
|
||||
const failedHallPreviewIds = ref(new Set<string>())
|
||||
|
||||
const hallPreviewMap: Record<string, string> = {
|
||||
宇宙厅: `${HALL_PREVIEW_BASE}/normalized/universe.png`,
|
||||
地球厅: `${HALL_PREVIEW_BASE}/normalized/earth.png`,
|
||||
演化厅: `${HALL_PREVIEW_BASE}/normalized/evolution.png`,
|
||||
恐龙厅: `${HALL_PREVIEW_BASE}/normalized/dinosaur.png`,
|
||||
人类厅: `${HALL_PREVIEW_BASE}/normalized/human.png`,
|
||||
动物厅: `${HALL_PREVIEW_BASE}/normalized/biology.png`,
|
||||
生物厅: `${HALL_PREVIEW_BASE}/normalized/biology.png`,
|
||||
生态厅: `${HALL_PREVIEW_BASE}/normalized/ecology.png`,
|
||||
家园厅: `${HALL_PREVIEW_BASE}/normalized/homeland.png`
|
||||
宇宙厅: `${HALL_PREVIEW_BASE}/universe.png`,
|
||||
地球厅: `${HALL_PREVIEW_BASE}/earth.png`,
|
||||
演化厅: `${HALL_PREVIEW_BASE}/evolution.png`,
|
||||
恐龙厅: `${HALL_PREVIEW_BASE}/dinosaur.png`,
|
||||
人类厅: `${HALL_PREVIEW_BASE}/human.png`,
|
||||
动物厅: `${HALL_PREVIEW_BASE}/biology.png`,
|
||||
生物厅: `${HALL_PREVIEW_BASE}/biology.png`,
|
||||
生态厅: `${HALL_PREVIEW_BASE}/ecology.png`,
|
||||
家园厅: `${HALL_PREVIEW_BASE}/homeland.png`
|
||||
}
|
||||
|
||||
const hallCardThemeMap: Record<string, { color: string; englishName: string }> = {
|
||||
@@ -223,29 +287,36 @@ const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
||||
const showInternalHeader = computed(() => props.forceInternalHeader || !shouldUseHostNavigation.value)
|
||||
const headerTitle = computed(() => {
|
||||
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
|
||||
if (props.stage === 'unit') return props.selectedHallName || '讲解单元'
|
||||
return '免费讲解'
|
||||
})
|
||||
const loadingTitle = computed(() => {
|
||||
if (props.stage === 'stop') return '正在加载讲解对象'
|
||||
return '正在加载展厅讲解'
|
||||
if (props.stage === 'unit') return '正在加载讲解单元'
|
||||
return '正在加载免费讲解'
|
||||
})
|
||||
const loadingDescription = computed(() => {
|
||||
if (props.stage === 'stop') return '稍后将展示该展厅的讲解对象。'
|
||||
if (props.stage === 'unit') return '稍后将展示该展厅的讲解单元。'
|
||||
return '稍后将展示可选择的展厅。'
|
||||
})
|
||||
const emptyTitle = computed(() => {
|
||||
if (props.stage === 'stop') return '该展厅暂无讲解对象'
|
||||
if (props.stage === 'unit') return '该展厅暂无讲解单元'
|
||||
return '未找到相关展厅'
|
||||
})
|
||||
const emptyDescription = computed(() => {
|
||||
if (props.stage === 'stop') return '可返回选择其他展厅。'
|
||||
return '暂无可选择的展厅讲解。'
|
||||
if (props.stage === 'unit') return '可返回选择其他展厅。'
|
||||
return '暂无可选择的免费讲解。'
|
||||
})
|
||||
|
||||
const hallPreviewUrl = (hall: ExplainHallSelectItem) => {
|
||||
return hallPreviewMap[hall.name.trim()] || hall.image || ''
|
||||
}
|
||||
|
||||
const isCustomHallCard = (hall: ExplainHallSelectItem) => Boolean(hallPreviewMap[hall.name.trim()])
|
||||
|
||||
const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲'
|
||||
|
||||
const isHallPreviewLoaded = (hallId: string) => loadedHallPreviewIds.value.has(hallId)
|
||||
@@ -280,6 +351,10 @@ const handleHallClick = (hallId: string) => {
|
||||
emit('hallClick', hallId)
|
||||
}
|
||||
|
||||
const handleBusinessUnitClick = (unitId: string) => {
|
||||
emit('businessUnitClick', unitId)
|
||||
}
|
||||
|
||||
const handleGuideStopClick = (stopId: string) => {
|
||||
const stop = props.guideStops.find((item) => item.id === stopId)
|
||||
if (stop) {
|
||||
@@ -831,11 +906,13 @@ const handleBack = () => {
|
||||
}
|
||||
|
||||
.hall-overview-list {
|
||||
grid-auto-rows: 138px;
|
||||
// Keep the eight-hall overview balanced on tall phones while preserving a
|
||||
// fixed minimum row height for compact screens.
|
||||
grid-auto-rows: max(138px, calc((100vh - 148px) / 4));
|
||||
}
|
||||
|
||||
.hall-overview-card {
|
||||
min-height: 138px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.hall-overview-card .hall-card-art {
|
||||
@@ -850,6 +927,28 @@ const handleBack = () => {
|
||||
filter: grayscale(1) brightness(0.72) contrast(1.65);
|
||||
}
|
||||
|
||||
.hall-full-card-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: var(--hall-card-color);
|
||||
}
|
||||
|
||||
.hall-full-card-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hall-full-card-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hall-overview-copy {
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
@@ -49,10 +49,11 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="state-block">
|
||||
<text class="state-title">正在加载讲解内容</text>
|
||||
<text class="state-desc">稍后将展示当前楼层的场景空间与展品讲解。</text>
|
||||
</view>
|
||||
<GuideLoadingState
|
||||
v-if="loading"
|
||||
title="正在加载讲解内容"
|
||||
description="请稍候"
|
||||
/>
|
||||
|
||||
<view v-else-if="error" class="state-block error">
|
||||
<text class="state-title">讲解内容加载失败</text>
|
||||
@@ -232,6 +233,7 @@ import { computed, ref } from 'vue'
|
||||
import type {
|
||||
ExplainCardViewModel
|
||||
} from '@/view-models/explainViewModels'
|
||||
import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
|
||||
|
||||
export interface ExplainFilterOption {
|
||||
id: 'all' | 'hall' | 'theme' | 'nearby' | 'playable'
|
||||
|
||||
@@ -30,15 +30,35 @@
|
||||
</view>
|
||||
<text v-if="description" class="poi-card-description">{{ description }}</text>
|
||||
<view v-if="errorMessage" class="poi-card-error" data-testid="facility-location-error"><text class="poi-card-error-text">{{ errorMessage }}</text></view>
|
||||
<view v-if="actionText" class="poi-card-actions"><view class="poi-action primary" @tap="emit('action')"><text class="poi-action-text">{{ actionText }}</text></view></view>
|
||||
<view v-if="visibleActions.length" class="poi-card-actions" :class="`count-${visibleActions.length}`">
|
||||
<view
|
||||
v-for="action in visibleActions"
|
||||
:key="action.id"
|
||||
class="poi-action"
|
||||
:class="[action.variant || 'primary', { disabled: action.disabled }]"
|
||||
:data-action-id="action.id"
|
||||
:aria-disabled="action.disabled ? 'true' : 'false'"
|
||||
@tap="handleActionTap(action)"
|
||||
>
|
||||
<text class="poi-action-text">{{ action.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface GuidePoiSummaryMetaItem { label: string; value: string }
|
||||
import { computed } from 'vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
interface GuidePoiSummaryMetaItem { label: string; value: string }
|
||||
export interface GuidePoiSummaryAction {
|
||||
id: string
|
||||
label: string
|
||||
variant?: 'primary' | 'secondary'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
collapsed?: boolean
|
||||
@@ -47,12 +67,26 @@ withDefaults(defineProps<{
|
||||
description?: string
|
||||
errorMessage?: string
|
||||
actionText?: string
|
||||
actions?: GuidePoiSummaryAction[]
|
||||
closeTestId?: string
|
||||
}>(), {
|
||||
subtitle: '', collapsed: false, collapsible: false, metaItems: () => [], description: '', errorMessage: '', actionText: '', closeTestId: ''
|
||||
subtitle: '', collapsed: false, collapsible: false, metaItems: () => [], description: '', errorMessage: '', actionText: '', actions: () => [], closeTestId: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ close: []; collapse: []; expand: []; toggle: []; action: [] }>()
|
||||
const emit = defineEmits<{ close: []; collapse: []; expand: []; toggle: []; action: [actionId: string] }>()
|
||||
|
||||
const visibleActions = computed<GuidePoiSummaryAction[]>(() => (
|
||||
props.actions.length
|
||||
? props.actions
|
||||
: props.actionText
|
||||
? [{ id: 'primary', label: props.actionText }]
|
||||
: []
|
||||
))
|
||||
|
||||
const handleActionTap = (action: GuidePoiSummaryAction) => {
|
||||
if (action.disabled) return
|
||||
emit('action', action.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -69,11 +103,11 @@ const emit = defineEmits<{ close: []; collapse: []; expand: []; toggle: []; acti
|
||||
.poi-card-soft-action, .poi-card-close, .poi-card-mini-action { display: flex; align-items: center; justify-content: center; border-radius: 8px; }
|
||||
.poi-card-soft-action, .poi-card-close { height: 28px; box-sizing: border-box; background: #f5f7f2; border: 1px solid #e4e5df; }
|
||||
.poi-card-soft-action { padding: 0 10px; }.poi-card-close { width: 28px; }.poi-card-soft-text { color: #545861; font-size: 12px; font-weight: 500; line-height: 16px; }.poi-card-close-text { color: #545861; font-size: 18px; line-height: 20px; }
|
||||
.poi-card-mini-action { height: 30px; padding: 0 10px; background: #151713; }.poi-card-mini-text { color: var(--museum-accent); font-size: 12px; font-weight: 500; line-height: 16px; }
|
||||
.poi-card-mini-action { height: 30px; padding: 0 10px; background: #1565c0; }.poi-card-mini-text { color: #ffffff; font-size: 12px; font-weight: 500; line-height: 16px; }
|
||||
.poi-card-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
|
||||
.poi-card-meta-item { display: flex; min-width: 0; flex-direction: column; gap: 2px; padding: 8px 10px; box-sizing: border-box; background: #f7f8f3; border: 1px solid #e5e6de; border-radius: 8px; }
|
||||
.poi-card-meta-value { overflow: hidden; color: #1f2329; font-size: 14px; font-weight: 600; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.poi-card-description { display: block; margin-top: 10px; color: #424754; font-size: 13px; line-height: 19px; }.poi-card-error { margin-top: 10px; padding: 9px 10px; background: #fff4e8; border: 1px solid #f1c99e; border-radius: 8px; }.poi-card-error-text { color: #7a3e05; font-size: 13px; line-height: 18px; }
|
||||
.poi-card-actions { display: grid; grid-template-columns: minmax(0, 1fr); margin-top: 12px; }.poi-action { display: flex; align-items: center; justify-content: center; min-width: 0; height: 38px; padding: 0 16px; box-sizing: border-box; border: 1px solid #151713; border-radius: 8px; background: #151713; }.poi-action-text { color: var(--museum-accent); font-size: 12px; font-weight: 500; line-height: 16px; text-align: center; }
|
||||
.poi-card-actions { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; margin-top: 12px; }.poi-card-actions.count-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }.poi-action { display: flex; align-items: center; justify-content: center; min-width: 0; height: 38px; padding: 0 12px; box-sizing: border-box; border: 1px solid #1565c0; border-radius: 8px; background: #1565c0; }.poi-action.secondary { border-color: #cbd2e8; background: #f4f6fc; }.poi-action.disabled { border-color: #e5e6de; background: #f1f2ee; opacity: 1; }.poi-action-text { color: #ffffff; font-size: 12px; font-weight: 600; line-height: 16px; text-align: center; }.poi-action.secondary .poi-action-text { color: #1a237e; }.poi-action.disabled .poi-action-text { color: #9a9d96; }
|
||||
@media (max-width: 360px) { .poi-card-meta { gap: 8px; } }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1513
src/components/map/WeakNetworkGuideFallback.vue
Normal file
1513
src/components/map/WeakNetworkGuideFallback.vue
Normal file
File diff suppressed because it is too large
Load Diff
32
src/components/map/floorPoiLoader.ts
Normal file
32
src/components/map/floorPoiLoader.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { GuideModelSource, GuideRenderPoi } from '@/domain/guideModel'
|
||||
|
||||
export interface InitialFloorPoiLoadResult {
|
||||
floorPois: GuideRenderPoi[]
|
||||
dataTier: 'fast' | 'full'
|
||||
fastLoadError?: unknown
|
||||
}
|
||||
|
||||
export const loadFloorPoisForInitialRender = async (
|
||||
modelSource: GuideModelSource,
|
||||
floorId: string
|
||||
): Promise<InitialFloorPoiLoadResult> => {
|
||||
if (!modelSource.loadFloorPoisFast) {
|
||||
return {
|
||||
floorPois: await modelSource.loadFloorPois(floorId),
|
||||
dataTier: 'full'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
floorPois: await modelSource.loadFloorPoisFast(floorId),
|
||||
dataTier: 'fast'
|
||||
}
|
||||
} catch (fastLoadError) {
|
||||
return {
|
||||
floorPois: await modelSource.loadFloorPois(floorId),
|
||||
dataTier: 'full',
|
||||
fastLoadError
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/components/map/focusMaterialIsolation.ts
Normal file
25
src/components/map/focusMaterialIsolation.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
export interface IsolatedFocusMaterialState {
|
||||
mesh: THREE.Mesh
|
||||
originalMaterial: THREE.Material | THREE.Material[]
|
||||
clonedMaterials: THREE.Material[]
|
||||
}
|
||||
|
||||
export const isolateMeshMaterialsForFocus = (
|
||||
mesh: THREE.Mesh
|
||||
): IsolatedFocusMaterialState => {
|
||||
const originalMaterial = mesh.material
|
||||
const clonedMaterial = Array.isArray(originalMaterial)
|
||||
? originalMaterial.map((material) => material.clone())
|
||||
: originalMaterial.clone()
|
||||
const clonedMaterials = Array.isArray(clonedMaterial) ? clonedMaterial : [clonedMaterial]
|
||||
|
||||
mesh.material = clonedMaterial
|
||||
return { mesh, originalMaterial, clonedMaterials }
|
||||
}
|
||||
|
||||
export const restoreIsolatedFocusMaterials = (state: IsolatedFocusMaterialState) => {
|
||||
state.mesh.material = state.originalMaterial
|
||||
state.clonedMaterials.forEach((material) => material.dispose())
|
||||
}
|
||||
@@ -21,6 +21,7 @@ interface GuideAutoSwitchStateMachineOptions {
|
||||
cooldownMs?: number
|
||||
intentTimeoutMs?: number
|
||||
reverseToleranceRatio?: number
|
||||
overviewEntryDistance?: number | (() => number)
|
||||
}
|
||||
|
||||
interface DistanceUpdateOptions {
|
||||
@@ -52,6 +53,7 @@ export class GuideAutoSwitchStateMachine {
|
||||
private readonly cooldownMs: number
|
||||
private readonly intentTimeoutMs: number
|
||||
private readonly reverseToleranceRatio: number
|
||||
private readonly configuredOverviewEntryDistance: () => number
|
||||
|
||||
// 外观以连续缩放起点为基准,楼层以相机拟合完成后的距离为基准。
|
||||
private view: GuideAutoSwitchView = 'overview'
|
||||
@@ -77,6 +79,13 @@ export class GuideAutoSwitchStateMachine {
|
||||
this.cooldownMs = options.cooldownMs ?? 1500
|
||||
this.intentTimeoutMs = options.intentTimeoutMs ?? 2000
|
||||
this.reverseToleranceRatio = options.reverseToleranceRatio ?? 0.008
|
||||
const overviewEntryDistance = options.overviewEntryDistance
|
||||
const staticOverviewEntryDistance = typeof overviewEntryDistance === 'number'
|
||||
? overviewEntryDistance
|
||||
: 0
|
||||
this.configuredOverviewEntryDistance = typeof overviewEntryDistance === 'function'
|
||||
? overviewEntryDistance
|
||||
: () => isValidDistance(staticOverviewEntryDistance) ? staticOverviewEntryDistance : 0
|
||||
}
|
||||
|
||||
setView(view: GuideAutoSwitchView) {
|
||||
@@ -213,7 +222,7 @@ export class GuideAutoSwitchStateMachine {
|
||||
}
|
||||
this.overviewLastInputAt = now
|
||||
|
||||
const shouldEnter = distance <= this.overviewStartDistance * (1 - this.enterRatio)
|
||||
const shouldEnter = distance <= this.getOverviewEntryThreshold()
|
||||
this.updateCandidate('enter-floor', shouldEnter, isReverseJitter)
|
||||
}
|
||||
|
||||
@@ -287,9 +296,9 @@ export class GuideAutoSwitchStateMachine {
|
||||
|
||||
private isConditionMet(direction: GuideAutoSwitchDirection, candidateDistance: number) {
|
||||
if (direction === 'enter-floor') {
|
||||
const thresholdDistance = this.overviewStartDistance * (1 - this.enterRatio)
|
||||
const thresholdDistance = this.getOverviewEntryThreshold()
|
||||
return this.view === 'overview'
|
||||
&& this.overviewStartDistance > 0
|
||||
&& thresholdDistance > 0
|
||||
&& (
|
||||
this.currentDistance <= thresholdDistance
|
||||
|| this.currentDistance <= candidateDistance * (1 + this.reverseToleranceRatio)
|
||||
@@ -305,6 +314,12 @@ export class GuideAutoSwitchStateMachine {
|
||||
)
|
||||
}
|
||||
|
||||
private getOverviewEntryThreshold() {
|
||||
const configuredDistance = this.configuredOverviewEntryDistance()
|
||||
if (isValidDistance(configuredDistance)) return configuredDistance
|
||||
return this.overviewStartDistance * (1 - this.enterRatio)
|
||||
}
|
||||
|
||||
private cancelCandidate(direction?: GuideAutoSwitchDirection) {
|
||||
if (!this.candidate || (direction && this.candidate.direction !== direction)) return
|
||||
|
||||
|
||||
192
src/components/map/routeStartCandidateResolver.ts
Normal file
192
src/components/map/routeStartCandidateResolver.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
export interface RouteStartSelectablePoint {
|
||||
routeTargetId?: string
|
||||
poiId: string
|
||||
sourceId?: string
|
||||
name?: string
|
||||
floorId: string
|
||||
positionGltf?: [number, number, number]
|
||||
routeNodeId?: string
|
||||
}
|
||||
|
||||
export interface RouteStartCandidatePayload {
|
||||
routeTargetId?: string
|
||||
poiId: string
|
||||
sourceId?: string
|
||||
sourceName?: string
|
||||
floorId?: string
|
||||
positionGltf?: [number, number, number]
|
||||
routeNodeId?: string
|
||||
coordinateFallback?: boolean
|
||||
}
|
||||
|
||||
export interface ResolveRouteStartCandidateInput {
|
||||
floorId: string
|
||||
position: [number, number, number]
|
||||
sourceName?: string
|
||||
sourcePoiIds?: Array<string | number | null | undefined>
|
||||
points: RouteStartSelectablePoint[]
|
||||
maxDistanceMeters: number
|
||||
requireObjectMatch?: boolean
|
||||
}
|
||||
|
||||
export interface ResolvedRouteStartCandidate {
|
||||
mode: 'linked' | 'coordinate'
|
||||
point: RouteStartSelectablePoint
|
||||
distance?: number
|
||||
}
|
||||
|
||||
const addIdentity = (identities: Set<string>, value: unknown) => {
|
||||
if (value === null || typeof value === 'undefined') return
|
||||
|
||||
const text = String(value).trim()
|
||||
if (!text) return
|
||||
|
||||
identities.add(text)
|
||||
|
||||
// Map POI ids intentionally carry a presentation prefix (hall-123 / space-123),
|
||||
// while SDK navigable places return the numeric source id. Treat both as one
|
||||
// business identity without weakening the match to arbitrary nearby objects.
|
||||
const prefixedId = text.match(/^(?:hall|space|poi|place)-(.+)$/i)
|
||||
if (prefixedId?.[1]) identities.add(prefixedId[1])
|
||||
|
||||
const routeTargetParts = text.split(':')
|
||||
if (routeTargetParts.length > 1 && routeTargetParts[0]) {
|
||||
identities.add(routeTargetParts[0])
|
||||
}
|
||||
}
|
||||
|
||||
const identitiesFor = (values: Array<string | number | null | undefined>) => {
|
||||
const identities = new Set<string>()
|
||||
values.forEach((value) => addIdentity(identities, value))
|
||||
return identities
|
||||
}
|
||||
|
||||
const normalizedRouteName = (value?: string) => {
|
||||
if (!value) return ''
|
||||
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[\s()()【】{}._-]/g, '')
|
||||
.replace(/\[/g, '')
|
||||
.replace(/\]/g, '')
|
||||
.replace(/(?:主)?(?:出入口|入口|出口|门点?)\d*$/u, '')
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
const hasNameMatch = (sourceName: string | undefined, targetName: string | undefined) => {
|
||||
const source = normalizedRouteName(sourceName)
|
||||
const target = normalizedRouteName(targetName)
|
||||
if (!source || !target || source.length < 2 || target.length < 2) return false
|
||||
return source === target || source.includes(target) || target.includes(source)
|
||||
}
|
||||
|
||||
const distanceBetween = (
|
||||
point: RouteStartSelectablePoint,
|
||||
position: [number, number, number]
|
||||
) => {
|
||||
if (!point.positionGltf) return Number.POSITIVE_INFINITY
|
||||
|
||||
return Math.hypot(
|
||||
point.positionGltf[0] - position[0],
|
||||
point.positionGltf[2] - position[2]
|
||||
)
|
||||
}
|
||||
|
||||
const pickNearest = (
|
||||
points: RouteStartSelectablePoint[],
|
||||
position: [number, number, number]
|
||||
) => points
|
||||
.map((point) => ({
|
||||
point,
|
||||
distance: distanceBetween(point, position)
|
||||
}))
|
||||
.sort((left, right) => left.distance - right.distance)[0]
|
||||
|
||||
export const resolveRouteStartCandidate = ({
|
||||
floorId,
|
||||
position,
|
||||
sourceName,
|
||||
sourcePoiIds = [],
|
||||
points,
|
||||
maxDistanceMeters,
|
||||
requireObjectMatch = false
|
||||
}: ResolveRouteStartCandidateInput): ResolvedRouteStartCandidate | null => {
|
||||
const floorPoints = points.filter((point) => (
|
||||
point.poiId
|
||||
&& String(point.floorId) === String(floorId)
|
||||
))
|
||||
|
||||
const sourceIdentities = identitiesFor(sourcePoiIds)
|
||||
const linkedPoints = floorPoints.filter((point) => {
|
||||
const pointIdentities = identitiesFor([
|
||||
point.routeTargetId,
|
||||
point.poiId,
|
||||
point.sourceId
|
||||
])
|
||||
|
||||
return Array.from(sourceIdentities).some((identity) => pointIdentities.has(identity))
|
||||
})
|
||||
|
||||
const namedPoints = sourceName
|
||||
? floorPoints.filter((point) => hasNameMatch(sourceName, point.name))
|
||||
: []
|
||||
const matchedPoints = linkedPoints.length ? linkedPoints : namedPoints
|
||||
|
||||
if (matchedPoints.length) {
|
||||
const nearest = pickNearest(matchedPoints, position)
|
||||
if (nearest && (requireObjectMatch || nearest.distance <= maxDistanceMeters)) {
|
||||
return {
|
||||
mode: 'linked',
|
||||
point: nearest.point,
|
||||
distance: nearest.distance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A clicked business object can be a valid route endpoint even when the
|
||||
// published navigable-place list has no explicit anchor for it. The route
|
||||
// API accepts its GLB coordinate and performs the authoritative graph snap.
|
||||
// Keep this fallback object-scoped; blank map taps still require a nearby
|
||||
// published route target and never synthesize an arbitrary one.
|
||||
if (requireObjectMatch && sourceName && position.every(Number.isFinite)) {
|
||||
const objectId = sourcePoiIds.find((value) => value !== null && value !== undefined && String(value).trim())
|
||||
const poiId = objectId ? String(objectId) : `map-coordinate:${floorId}:${position[0]}:${position[2]}`
|
||||
|
||||
return {
|
||||
mode: 'coordinate',
|
||||
point: {
|
||||
routeTargetId: `coordinate:${poiId}`,
|
||||
poiId,
|
||||
sourceId: objectId ? String(objectId) : undefined,
|
||||
name: sourceName,
|
||||
floorId,
|
||||
positionGltf: position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nearest = pickNearest(floorPoints, position)
|
||||
if (!nearest || nearest.distance > maxDistanceMeters) return null
|
||||
|
||||
return {
|
||||
mode: 'linked',
|
||||
point: nearest.point,
|
||||
distance: nearest.distance
|
||||
}
|
||||
}
|
||||
|
||||
export const toRouteStartCandidatePayload = (
|
||||
resolved: ResolvedRouteStartCandidate,
|
||||
sourceName?: string
|
||||
): RouteStartCandidatePayload => ({
|
||||
routeTargetId: resolved.point.routeTargetId,
|
||||
poiId: resolved.point.poiId,
|
||||
sourceId: resolved.point.sourceId,
|
||||
sourceName: sourceName || resolved.point.name,
|
||||
floorId: resolved.point.floorId,
|
||||
positionGltf: resolved.point.positionGltf,
|
||||
routeNodeId: resolved.point.routeNodeId,
|
||||
coordinateFallback: resolved.mode === 'coordinate'
|
||||
})
|
||||
124
src/components/map/routeSurfaceProjection.ts
Normal file
124
src/components/map/routeSurfaceProjection.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
export type RoutePosition = readonly [number, number, number]
|
||||
|
||||
const WALKABLE_NORMAL_Y_MIN = 0.5
|
||||
const SURFACE_CLUSTER_TOLERANCE = 0.35
|
||||
const HEIGHT_EPSILON = 0.001
|
||||
|
||||
const isVisibleWithin = (object: THREE.Object3D, root: THREE.Object3D) => {
|
||||
let current: THREE.Object3D | null = object
|
||||
while (current) {
|
||||
if (!current.visible) return false
|
||||
if (current === root) return true
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const uniqueSortedHeights = (values: number[]) => values
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b)
|
||||
.filter((value, index, sorted) => index === 0 || Math.abs(value - sorted[index - 1]) > HEIGHT_EPSILON)
|
||||
|
||||
export const getWalkableSurfaceHits = (
|
||||
floorModel: THREE.Object3D,
|
||||
bounds: THREE.Box3,
|
||||
position: RoutePosition
|
||||
) => {
|
||||
if (bounds.isEmpty()) return []
|
||||
|
||||
floorModel.updateWorldMatrix(true, true)
|
||||
const rayStart = new THREE.Vector3(position[0], bounds.max.y + 2, position[2])
|
||||
const rayLength = Math.max(bounds.max.y - bounds.min.y + 4, 8)
|
||||
const intersections = new THREE.Raycaster(
|
||||
rayStart,
|
||||
new THREE.Vector3(0, -1, 0),
|
||||
0,
|
||||
rayLength
|
||||
).intersectObject(floorModel, true)
|
||||
|
||||
return uniqueSortedHeights(intersections.flatMap((hit) => {
|
||||
if (!hit.face || !isVisibleWithin(hit.object, floorModel)) return []
|
||||
const normal = hit.face.normal.clone()
|
||||
.applyMatrix3(new THREE.Matrix3().getNormalMatrix(hit.object.matrixWorld))
|
||||
.normalize()
|
||||
return normal.y > WALKABLE_NORMAL_Y_MIN ? [hit.point.y] : []
|
||||
}))
|
||||
}
|
||||
|
||||
const median = (values: number[]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle]
|
||||
}
|
||||
|
||||
export const resolveDominantRouteSurfaceY = (
|
||||
floorModel: THREE.Object3D,
|
||||
bounds: THREE.Box3,
|
||||
positions: RoutePosition[]
|
||||
): number | null => {
|
||||
const samples = positions.flatMap((position) => {
|
||||
const hits = getWalkableSurfaceHits(floorModel, bounds, position)
|
||||
// The lowest upward-facing hit avoids furniture and equipment above the deck.
|
||||
return hits.length ? [hits[0]] : []
|
||||
}).sort((a, b) => a - b)
|
||||
|
||||
if (!samples.length) return null
|
||||
|
||||
const clusters: number[][] = []
|
||||
samples.forEach((sample) => {
|
||||
const cluster = clusters.find((candidate) => (
|
||||
Math.abs(sample - median(candidate)) <= SURFACE_CLUSTER_TOLERANCE
|
||||
))
|
||||
if (cluster) {
|
||||
cluster.push(sample)
|
||||
} else {
|
||||
clusters.push([sample])
|
||||
}
|
||||
})
|
||||
|
||||
const dominant = clusters.sort((a, b) => (
|
||||
b.length - a.length || median(a) - median(b)
|
||||
))[0]
|
||||
return dominant?.length ? median(dominant) : null
|
||||
}
|
||||
|
||||
export const resolveRoutePointSurfaceY = (
|
||||
floorModel: THREE.Object3D,
|
||||
bounds: THREE.Box3,
|
||||
position: RoutePosition,
|
||||
preferredSurfaceY?: number
|
||||
): number | null => {
|
||||
const hits = getWalkableSurfaceHits(floorModel, bounds, position)
|
||||
if (!hits.length) {
|
||||
return Number.isFinite(preferredSurfaceY) ? preferredSurfaceY! : null
|
||||
}
|
||||
if (!Number.isFinite(preferredSurfaceY)) return hits[0]
|
||||
|
||||
return hits.reduce((closest, height) => (
|
||||
Math.abs(height - preferredSurfaceY!) < Math.abs(closest - preferredSurfaceY!)
|
||||
? height
|
||||
: closest
|
||||
), hits[0])
|
||||
}
|
||||
|
||||
export const projectRoutePositionToSurface = (
|
||||
floorModel: THREE.Object3D,
|
||||
bounds: THREE.Box3,
|
||||
position: RoutePosition,
|
||||
preferredSurfaceY?: number,
|
||||
lift = 0.24
|
||||
) => {
|
||||
const surfaceY = resolveRoutePointSurfaceY(
|
||||
floorModel,
|
||||
bounds,
|
||||
position,
|
||||
preferredSurfaceY
|
||||
)
|
||||
return surfaceY === null
|
||||
? null
|
||||
: new THREE.Vector3(position[0], surfaceY + lift, position[2])
|
||||
}
|
||||
143
src/components/navigation/GuideFeedbackState.vue
Normal file
143
src/components/navigation/GuideFeedbackState.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<view class="guide-feedback-state" :class="{ 'is-fullscreen': fullscreen, 'is-error': tone === 'error' }">
|
||||
<view class="feedback-icon" aria-hidden="true">
|
||||
<text class="feedback-icon-mark">!</text>
|
||||
</view>
|
||||
<text class="feedback-title">{{ title }}</text>
|
||||
<text v-if="description" class="feedback-description">{{ description }}</text>
|
||||
<view v-if="primaryLabel || secondaryLabel" class="feedback-actions">
|
||||
<button
|
||||
v-if="primaryLabel"
|
||||
class="feedback-action primary"
|
||||
@tap="emit('primary')"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="secondaryLabel"
|
||||
class="feedback-action secondary"
|
||||
@tap="emit('secondary')"
|
||||
>
|
||||
{{ secondaryLabel }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
primaryLabel?: string
|
||||
secondaryLabel?: string
|
||||
fullscreen?: boolean
|
||||
tone?: 'error' | 'neutral'
|
||||
}>(), {
|
||||
title: '暂时无法加载内容',
|
||||
description: '请检查网络后重试',
|
||||
primaryLabel: '',
|
||||
secondaryLabel: '',
|
||||
fullscreen: false,
|
||||
tone: 'error'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
primary: []
|
||||
secondary: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.guide-feedback-state {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
padding: 32px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #f7f9f2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-feedback-state.is-fullscreen {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.feedback-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d6b5ae;
|
||||
border-radius: 50%;
|
||||
background: #fff6f3;
|
||||
color: #a54a3b;
|
||||
}
|
||||
|
||||
.feedback-icon-mark {
|
||||
font-size: 18px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.feedback-title {
|
||||
margin-top: 14px;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 600;
|
||||
color: #262421;
|
||||
}
|
||||
|
||||
.feedback-description {
|
||||
max-width: 300px;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: #68725d;
|
||||
}
|
||||
|
||||
.feedback-actions {
|
||||
width: 100%;
|
||||
max-width: 264px;
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-action {
|
||||
min-width: 112px;
|
||||
height: 38px;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feedback-action::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.feedback-action.primary {
|
||||
background: #1565c0;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.feedback-action.secondary {
|
||||
border-color: #b8c7d6;
|
||||
background: #ffffff;
|
||||
color: #1a5b9f;
|
||||
}
|
||||
</style>
|
||||
83
src/components/navigation/GuideLoadingState.vue
Normal file
83
src/components/navigation/GuideLoadingState.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<view class="guide-loading-state" :class="{ 'is-fullscreen': fullscreen }">
|
||||
<view class="loading-indicator" aria-hidden="true">
|
||||
<view class="loading-indicator-dot"></view>
|
||||
</view>
|
||||
<text class="loading-title">{{ title }}</text>
|
||||
<text v-if="description" class="loading-description">{{ description }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
fullscreen?: boolean
|
||||
}>(), {
|
||||
title: '正在加载',
|
||||
description: '请稍候',
|
||||
fullscreen: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.guide-loading-state {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
padding: 32px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #f7f9f2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-loading-state.is-fullscreen {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
border: 2px solid #dfe6d4;
|
||||
border-top-color: #6c8d4e;
|
||||
border-radius: 50%;
|
||||
animation: guide-loading-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.loading-indicator-dot {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 50%;
|
||||
background: #e0df00;
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
font-weight: 600;
|
||||
color: #262421;
|
||||
}
|
||||
|
||||
.loading-description {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #68725d;
|
||||
}
|
||||
|
||||
@keyframes guide-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,10 @@
|
||||
class="indoor-three-map"
|
||||
:asset-base-url="indoorAssetBaseUrl"
|
||||
:model-source="effectiveIndoorModelSource"
|
||||
:active-floor="activeFloorId"
|
||||
:initial-floor-id="activeFloorId"
|
||||
:initial-view="indoorInitialView"
|
||||
:scene-view="indoorView"
|
||||
:show-controls="false"
|
||||
:show-poi="shouldShowIndoorPois"
|
||||
:visible-poi-ids="visiblePoiIds"
|
||||
@@ -19,15 +21,28 @@
|
||||
:target-focus-distance-factor="targetFocusDistanceFactor"
|
||||
:route-preview="routePreview"
|
||||
:show-route="showRoute"
|
||||
:route-navigation-active="routeNavigationActive"
|
||||
:route-start-selection-active="routeStartSelectionActive"
|
||||
:route-selectable-poi-ids="routeSelectablePoiIds"
|
||||
:route-selectable-points="routeSelectablePoints"
|
||||
:render-mode="indoorRenderMode"
|
||||
:scene-revision="sceneRevision"
|
||||
:scene-viewport="sceneViewport"
|
||||
:disable-auto-exit="disableAutoExit"
|
||||
@floor-change="handleThreeFloorChange"
|
||||
@poi-click="handlePoiClick"
|
||||
@route-start-candidate="handleRouteStartCandidate"
|
||||
@route-start-candidate-rejected="handleRouteStartCandidateRejected"
|
||||
@route-roaming-progress="handleRouteRoamingProgress"
|
||||
@route-roaming-transfer="handleRouteRoamingTransfer"
|
||||
@selection-clear="handleSelectionClear"
|
||||
@target-focus="handleTargetFocus"
|
||||
@auto-switch="handleAutoSwitch"
|
||||
@initial-model-progress="handleInitialModelProgress"
|
||||
@initial-model-ready="handleInitialModelReady"
|
||||
@initial-model-failed="handleInitialModelFailed"
|
||||
@render-mode-fallback="handleRenderModeFallback"
|
||||
@scene-viewport-change="emit('sceneViewportChange', $event)"
|
||||
/>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
@@ -124,7 +139,7 @@
|
||||
<slot name="overlay"></slot>
|
||||
|
||||
<view
|
||||
v-if="showIndoorRightControls && showLayerModeToggle"
|
||||
v-if="showIndoorRightControls && showLayerModeToggle && indoorRenderMode !== 'two-d'"
|
||||
class="layer-mode-toggle"
|
||||
:style="layerModeToggleStyle"
|
||||
@tap="handleLayerModeToggle"
|
||||
@@ -137,17 +152,19 @@
|
||||
class="floor-switcher"
|
||||
:class="`side-${floorSide}`"
|
||||
:style="floorSwitcherStyle"
|
||||
>
|
||||
<view
|
||||
v-if="showFloorHeader"
|
||||
class="floor-header"
|
||||
:class="{ active: layerMode === 'multi' }"
|
||||
@tap.stop="handleFloorHeaderTap"
|
||||
>
|
||||
<text class="floor-header-icon">▱</text>
|
||||
<text class="floor-header-label">{{ layerModeActionLabel }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="showFloorHeader && !showRoute && indoorRenderMode !== 'two-d'"
|
||||
class="floor-header"
|
||||
:class="{ active: effectiveLayerMode === 'multi' }"
|
||||
@tap.stop="handleFloorHeaderTap"
|
||||
@click.stop="handleFloorHeaderTap"
|
||||
>
|
||||
<text class="floor-header-icon" @tap.stop="handleFloorHeaderTap">▱</text>
|
||||
<text class="floor-header-label" @tap.stop="handleFloorHeaderTap">{{ layerModeActionLabel }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="effectiveLayerMode !== 'multi' || routeStartSelectionActive"
|
||||
class="floor-list"
|
||||
>
|
||||
<view
|
||||
@@ -167,13 +184,32 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showZoomControls" class="zoom-controls" :style="zoomControlsStyle">
|
||||
<view class="zoom-btn" @tap="handleZoomClick('in')">
|
||||
<text class="zoom-text">+</text>
|
||||
<view
|
||||
v-if="showZoomControls || (showIndoorRenderModeToggle && mapType === 'indoor')"
|
||||
class="map-zoom-stack"
|
||||
:style="zoomControlsStyle"
|
||||
>
|
||||
<view
|
||||
v-if="showIndoorRenderModeToggle && mapType === 'indoor'"
|
||||
class="indoor-render-mode-toggle"
|
||||
data-testid="indoor-render-mode-toggle"
|
||||
role="switch"
|
||||
:aria-checked="indoorRenderMode === 'three-d'"
|
||||
:aria-label="indoorRenderModeToggleLabel"
|
||||
:title="indoorRenderModeToggleLabel"
|
||||
@tap.stop="toggleIndoorRenderMode"
|
||||
@click.stop="toggleIndoorRenderMode"
|
||||
>
|
||||
<text>{{ indoorRenderMode === 'three-d' ? '3D' : '2D' }}</text>
|
||||
</view>
|
||||
<view class="zoom-divider"></view>
|
||||
<view class="zoom-btn" @tap="handleZoomClick('out')">
|
||||
<text class="zoom-text">−</text>
|
||||
<view v-if="showZoomControls" class="zoom-controls">
|
||||
<view class="zoom-btn" data-testid="guide-zoom-in" @tap.stop="handleZoomClick('in')" @click.stop="handleZoomClick('in')">
|
||||
<text class="zoom-text">+</text>
|
||||
</view>
|
||||
<view class="zoom-divider"></view>
|
||||
<view class="zoom-btn" data-testid="guide-zoom-out" @tap.stop="handleZoomClick('out')" @click.stop="handleZoomClick('out')">
|
||||
<text class="zoom-text">−</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -252,6 +288,12 @@ import type {
|
||||
import {
|
||||
isIndoorNavigableFloor
|
||||
} from '@/domain/guideFloor'
|
||||
import { startGuidePerformance } from '@/services/performance/guidePerformance'
|
||||
import type {
|
||||
RouteStartCandidatePayload,
|
||||
RouteStartSelectablePoint
|
||||
} from '@/components/map/routeStartCandidateResolver'
|
||||
import type { GuideViewportState } from '@/composables/useGuideSceneState'
|
||||
|
||||
interface GuideFloorOption {
|
||||
id: string
|
||||
@@ -301,6 +343,7 @@ interface TargetPoiFocusResult {
|
||||
type IndoorViewMode = 'overview' | 'floor' | 'multi'
|
||||
type LayerDisplayMode = 'single' | 'multi'
|
||||
type TouchGestureMode = 'orbit' | 'pan'
|
||||
type IndoorRenderMode = 'three-d' | 'two-d'
|
||||
|
||||
interface InitialModelProgressEvent {
|
||||
progress: number
|
||||
@@ -343,6 +386,10 @@ const props = withDefaults(defineProps<{
|
||||
modeLayout?: 'full' | 'status'
|
||||
modeStatus?: string
|
||||
modeStatusTone?: 'solid' | 'glass'
|
||||
indoorRenderMode?: IndoorRenderMode
|
||||
sceneRevision?: number
|
||||
sceneViewport?: GuideViewportState | null
|
||||
showIndoorRenderModeToggle?: boolean
|
||||
mapType?: 'indoor' | 'outdoor'
|
||||
outdoorVariant?: 'home' | 'entrance'
|
||||
indoorAssetBaseUrl?: string
|
||||
@@ -356,6 +403,10 @@ const props = withDefaults(defineProps<{
|
||||
targetFocusDistanceFactor?: number
|
||||
routePreview?: GuideRouteResult | null
|
||||
showRoute?: boolean
|
||||
routeNavigationActive?: boolean
|
||||
routeStartSelectionActive?: boolean
|
||||
routeSelectablePoiIds?: string[]
|
||||
routeSelectablePoints?: RouteStartSelectablePoint[]
|
||||
disableAutoExit?: boolean
|
||||
outdoorNavPolylines?: OutdoorNavPolyline[]
|
||||
outdoorMarkers?: OutdoorMapMarker[]
|
||||
@@ -394,6 +445,10 @@ const props = withDefaults(defineProps<{
|
||||
modeLayout: 'full',
|
||||
modeStatus: '',
|
||||
modeStatusTone: 'solid',
|
||||
indoorRenderMode: 'three-d',
|
||||
sceneRevision: 0,
|
||||
sceneViewport: null,
|
||||
showIndoorRenderModeToggle: false,
|
||||
mapType: 'indoor',
|
||||
outdoorVariant: 'home',
|
||||
indoorAssetBaseUrl: '',
|
||||
@@ -407,6 +462,10 @@ const props = withDefaults(defineProps<{
|
||||
targetFocusDistanceFactor: 0.36,
|
||||
routePreview: null,
|
||||
showRoute: false,
|
||||
routeNavigationActive: false,
|
||||
routeStartSelectionActive: false,
|
||||
routeSelectablePoiIds: () => [] as string[],
|
||||
routeSelectablePoints: () => [],
|
||||
disableAutoExit: false,
|
||||
outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
|
||||
outdoorMarkers: () => [] as OutdoorMapMarker[],
|
||||
@@ -417,20 +476,51 @@ const props = withDefaults(defineProps<{
|
||||
const emit = defineEmits<{
|
||||
searchTap: []
|
||||
modeChange: [mode: '2d' | '3d']
|
||||
indoorRenderModeChange: [mode: IndoorRenderMode]
|
||||
sceneViewportChange: [viewport: GuideViewportState]
|
||||
floorRequest: [event: FloorSwitchEvent]
|
||||
floorChange: [floor: string]
|
||||
floorChange: [floor: string, sceneRevision?: number]
|
||||
floorSwitchFailed: [event: FloorSwitchEvent]
|
||||
toolClick: [tool: string]
|
||||
moreClick: []
|
||||
indoorViewChange: [view: IndoorViewMode]
|
||||
indoorViewChange: [view: IndoorViewMode, sceneRevision?: number]
|
||||
layerModeChange: [mode: LayerDisplayMode]
|
||||
poiClick: [poi: GuideRenderPoi]
|
||||
routeStartCandidate: [candidate: RouteStartCandidatePayload]
|
||||
routeStartCandidateRejected: []
|
||||
routeRoamingProgress: [event: { remainingMeters: number; progress: number }]
|
||||
routeRoamingTransfer: [event: {
|
||||
fromFloorId: string
|
||||
toFloorId: string
|
||||
transferType: string
|
||||
connectorName?: string
|
||||
}]
|
||||
selectionClear: []
|
||||
targetFocus: [result: TargetPoiFocusResult]
|
||||
autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }]
|
||||
autoSwitch: [event: {
|
||||
from: 'overview' | 'floor'
|
||||
to: 'overview' | 'floor'
|
||||
trigger: string
|
||||
distance: number
|
||||
sceneRevision?: number
|
||||
}]
|
||||
initialModelProgress: [event: InitialModelProgressEvent]
|
||||
initialModelReady: [event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }]
|
||||
initialModelFailed: [event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }]
|
||||
initialModelFailed: [event: {
|
||||
view: IndoorViewMode
|
||||
floorId?: string
|
||||
message: string
|
||||
elapsedMs?: number
|
||||
fallbackAvailable?: boolean
|
||||
actualRenderMode?: IndoorRenderMode
|
||||
}]
|
||||
indoorRenderModeFallback: [event: {
|
||||
mode: 'two-d'
|
||||
view: 'overview' | 'floor'
|
||||
floorId?: string
|
||||
reason: string
|
||||
sceneRevision: number
|
||||
}]
|
||||
mapTap: [location: { latitude: number; longitude: number }]
|
||||
outdoorMarkerClick: [markerId: string]
|
||||
}>()
|
||||
@@ -454,8 +544,8 @@ watch(() => props.cameraView, (view) => {
|
||||
|
||||
const indoorRendererRef = ref<{
|
||||
switchFloor?: (floorId: string) => Promise<void> | void
|
||||
showOverview?: () => Promise<void> | void
|
||||
showMultiFloor?: () => Promise<void> | void
|
||||
showOverview?: () => Promise<boolean | void> | boolean | void
|
||||
showMultiFloor?: () => Promise<boolean | void> | boolean | void
|
||||
resetCamera?: () => void
|
||||
setCameraPreset?: (preset: 'top' | 'oblique') => void
|
||||
zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
|
||||
@@ -468,6 +558,7 @@ const indoorRendererRef = ref<{
|
||||
}) => Promise<ResetViewBaselineResult> | ResetViewBaselineResult
|
||||
resetToInitialState?: () => Promise<ResetViewBaselineResult> | ResetViewBaselineResult
|
||||
disableAutoSwitchTemporarily?: (durationMs: number) => void
|
||||
getGuideViewportState?: () => GuideViewportState | null
|
||||
} | null>(null)
|
||||
|
||||
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
|
||||
@@ -476,9 +567,46 @@ const floorItems = computed(() => indoorFloors.value)
|
||||
const effectiveIndoorModelSource = computed(() => props.indoorModelSource)
|
||||
// #endif
|
||||
const showIndoorRightControls = computed(() => (
|
||||
props.mapType === 'indoor' && props.indoorView !== 'overview'
|
||||
props.mapType === 'indoor'
|
||||
&& props.indoorView !== 'overview'
|
||||
))
|
||||
|
||||
const indoorRenderModeToggleLabel = computed(() => (
|
||||
props.indoorRenderMode === 'three-d'
|
||||
? '当前三维地图,切换到二维地图'
|
||||
: '当前二维地图,切换到三维地图'
|
||||
))
|
||||
|
||||
const isCurrentRendererRevision = (sceneRevision: number | undefined) => (
|
||||
sceneRevision === undefined || sceneRevision === props.sceneRevision
|
||||
)
|
||||
|
||||
const emitFloorSceneCommit = (floorId: string, sceneRevision?: number) => {
|
||||
if (sceneRevision === undefined) {
|
||||
emit('floorChange', floorId)
|
||||
} else {
|
||||
emit('floorChange', floorId, sceneRevision)
|
||||
}
|
||||
}
|
||||
|
||||
const emitViewSceneCommit = (view: IndoorViewMode, sceneRevision?: number) => {
|
||||
if (sceneRevision === undefined) {
|
||||
emit('indoorViewChange', view)
|
||||
} else {
|
||||
emit('indoorViewChange', view, sceneRevision)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleIndoorRenderMode = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastRenderModeToggleAt < 100) return
|
||||
lastRenderModeToggleAt = now
|
||||
emit('indoorRenderModeChange', props.indoorRenderMode === 'three-d' ? 'two-d' : 'three-d')
|
||||
}
|
||||
|
||||
let lastRenderModeToggleAt = 0
|
||||
let lastZoomActionAt = 0
|
||||
|
||||
const activeFloorId = computed(() => {
|
||||
const matchedFloor = indoorFloors.value.find((floor) => (
|
||||
floor.id === props.activeFloor || floor.label === props.activeFloor
|
||||
@@ -499,6 +627,33 @@ const activeFloorSwitchRequestSeq = ref(0)
|
||||
const renderedFloorSwitchRequestSeq = ref(0)
|
||||
const failedFloorId = ref('')
|
||||
let floorSwitchRequestSeq = 0
|
||||
let activeFloorSwitchPerformance: {
|
||||
requestSeq: number
|
||||
floorId: string
|
||||
finish: ReturnType<typeof startGuidePerformance>
|
||||
} | null = null
|
||||
|
||||
const startFloorSwitchPerformance = (requestSeq: number, floorId: string, source: string) => {
|
||||
activeFloorSwitchPerformance?.finish('cancelled', { reason: 'superseded' })
|
||||
activeFloorSwitchPerformance = {
|
||||
requestSeq,
|
||||
floorId,
|
||||
finish: startGuidePerformance('interaction', 'floor-switch', { floorId, source })
|
||||
}
|
||||
}
|
||||
|
||||
const finishFloorSwitchPerformance = (
|
||||
requestSeq: number,
|
||||
floorId: string,
|
||||
outcome: 'success' | 'failure' | 'cancelled',
|
||||
detail?: Record<string, unknown>
|
||||
) => {
|
||||
const measurement = activeFloorSwitchPerformance
|
||||
if (!measurement || measurement.requestSeq !== requestSeq || measurement.floorId !== floorId) return
|
||||
|
||||
activeFloorSwitchPerformance = null
|
||||
measurement.finish(outcome, detail)
|
||||
}
|
||||
|
||||
const searchFieldStyle = computed(() => ({
|
||||
top: props.searchTop
|
||||
@@ -533,8 +688,11 @@ const moreControlStyle = computed(() => ({
|
||||
const shouldShowIndoorPois = computed(() => (
|
||||
props.mapType === 'indoor'
|
||||
) || Boolean(props.targetFocusRequest))
|
||||
const effectiveLayerMode = computed<LayerDisplayMode>(() => (
|
||||
props.indoorView === 'multi' ? 'multi' : 'single'
|
||||
))
|
||||
const nextLayerMode = computed<LayerDisplayMode>(() => (
|
||||
props.layerMode === 'multi' ? 'single' : 'multi'
|
||||
effectiveLayerMode.value === 'multi' ? 'single' : 'multi'
|
||||
))
|
||||
const layerModeActionLabel = computed(() => (
|
||||
nextLayerMode.value === 'multi' ? '多层' : '单层'
|
||||
@@ -575,6 +733,7 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
|
||||
|
||||
failedFloorId.value = floorId
|
||||
clearFloorLoadingIfCurrent(floorId)
|
||||
finishFloorSwitchPerformance(requestSeq, floorId, 'failure', { reason: 'renderer-not-committed' })
|
||||
emit('floorSwitchFailed', {
|
||||
floorId,
|
||||
floorLabel: requestedFloorId.value === floorId
|
||||
@@ -583,21 +742,17 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
|
||||
})
|
||||
}
|
||||
|
||||
const requestFloorSwitch = (
|
||||
floor: { id: string; label: string },
|
||||
options: { force?: boolean } = {}
|
||||
) => {
|
||||
const handleFloorChange = (floor: { id: string; label: string }) => {
|
||||
const floorId = floor.id
|
||||
if (!floorId) return Promise.resolve()
|
||||
if (loadingFloorId.value === floorId) return Promise.resolve()
|
||||
if (!floorId || loadingFloorId.value) return
|
||||
if (
|
||||
!options.force && floorId === renderedFloorId.value
|
||||
floorId === renderedFloorId.value
|
||||
&& activeFloorId.value === floorId
|
||||
&& props.indoorView === 'floor'
|
||||
&& props.layerMode !== 'multi'
|
||||
) {
|
||||
emit('floorChange', floorId)
|
||||
return Promise.resolve()
|
||||
emit('floorChange', floorId, props.sceneRevision)
|
||||
return
|
||||
}
|
||||
|
||||
requestedFloorId.value = floorId
|
||||
@@ -605,13 +760,14 @@ const requestFloorSwitch = (
|
||||
loadingFloorId.value = floorId
|
||||
const requestSeq = ++floorSwitchRequestSeq
|
||||
activeFloorSwitchRequestSeq.value = requestSeq
|
||||
startFloorSwitchPerformance(requestSeq, floorId, 'manual')
|
||||
failedFloorId.value = ''
|
||||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
||||
emit('floorRequest', {
|
||||
floorId,
|
||||
floorLabel: floor.label
|
||||
})
|
||||
return Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
||||
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
||||
.then(() => {
|
||||
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
|
||||
})
|
||||
@@ -619,6 +775,9 @@ const requestFloorSwitch = (
|
||||
if (isStaleFloorSwitchError(error)) return
|
||||
failedFloorId.value = floorId
|
||||
clearFloorLoadingIfCurrent(floorId)
|
||||
finishFloorSwitchPerformance(requestSeq, floorId, 'failure', {
|
||||
error: error instanceof Error ? error.name : String(error)
|
||||
})
|
||||
emit('floorSwitchFailed', {
|
||||
floorId,
|
||||
floorLabel: floor.label
|
||||
@@ -627,11 +786,13 @@ const requestFloorSwitch = (
|
||||
})
|
||||
}
|
||||
|
||||
const handleFloorChange = (floor: { id: string; label: string }) => {
|
||||
void requestFloorSwitch(floor)
|
||||
}
|
||||
const handleLayerModeChange = async (mode: LayerDisplayMode) => {
|
||||
if (props.indoorRenderMode === 'two-d' && mode === 'multi') return
|
||||
// Cross-floor navigation owns its presentation. A vertically exploded
|
||||
// multi-floor model cannot keep route, marker and DOM-label coordinates in
|
||||
// one system, so visitors cannot enter that mode during route guidance.
|
||||
if (props.showRoute) return
|
||||
|
||||
const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
||||
// 手动切换展示层数时使用统一的短保护期。
|
||||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
||||
|
||||
@@ -639,14 +800,18 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
||||
loadingFloorId.value = ''
|
||||
requestedFloorId.value = ''
|
||||
requestedFloorLabel.value = ''
|
||||
void indoorRendererRef.value?.showMultiFloor?.()
|
||||
emit('indoorViewChange', 'multi')
|
||||
const committed = await indoorRendererRef.value?.showMultiFloor?.()
|
||||
if (committed === false) return
|
||||
emit('indoorViewChange', 'multi', props.sceneRevision)
|
||||
} else {
|
||||
const floorId = activeFloorId.value
|
||||
// The parent floor can lag while a search/detail transaction is closing or
|
||||
// while multi-floor is committing. The last renderer-confirmed floor is the
|
||||
// authoritative return target in that window.
|
||||
const floorId = activeFloorId.value || renderedFloorId.value
|
||||
const floorLabel = findFloorItemById(floorId)?.label || floorId
|
||||
if (!floorId) return
|
||||
if (floorId === renderedFloorId.value && props.layerMode !== 'multi') {
|
||||
emit('indoorViewChange', 'floor')
|
||||
if (floorId === renderedFloorId.value && props.indoorView === 'floor') {
|
||||
emit('indoorViewChange', 'floor', props.sceneRevision)
|
||||
emit('layerModeChange', mode)
|
||||
return
|
||||
}
|
||||
@@ -656,20 +821,62 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
||||
loadingFloorId.value = floorId
|
||||
const requestSeq = ++floorSwitchRequestSeq
|
||||
activeFloorSwitchRequestSeq.value = requestSeq
|
||||
startFloorSwitchPerformance(requestSeq, floorId, 'multi-to-single')
|
||||
failedFloorId.value = ''
|
||||
emit('floorRequest', {
|
||||
floorId,
|
||||
floorLabel
|
||||
})
|
||||
|
||||
// A multi-floor view owns a temporary exploded model group. Returning to
|
||||
// one floor must restore the renderer's floor baseline transaction so the
|
||||
// model, POI layer, camera, and floor state commit together.
|
||||
if (indoorRendererRef.value?.resetToViewBaseline) {
|
||||
try {
|
||||
const resetResult = await indoorRendererRef.value.resetToViewBaseline({
|
||||
view: 'floor',
|
||||
floorId,
|
||||
reason: 'floor-reset'
|
||||
})
|
||||
if (resetResult !== 'applied') {
|
||||
finishFloorSwitchPerformance(requestSeq, floorId, 'cancelled', { reason: resetResult })
|
||||
return
|
||||
}
|
||||
|
||||
renderedFloorId.value = floorId
|
||||
clearFloorLoadingIfCurrent(floorId)
|
||||
finishFloorSwitchPerformance(requestSeq, floorId, 'success', { source: 'view-baseline' })
|
||||
emit('floorChange', floorId, props.sceneRevision)
|
||||
emit('indoorViewChange', 'floor', props.sceneRevision)
|
||||
emit('layerModeChange', mode)
|
||||
} catch (error) {
|
||||
if (isStaleFloorSwitchError(error)) return
|
||||
failedFloorId.value = floorId
|
||||
clearFloorLoadingIfCurrent(floorId)
|
||||
finishFloorSwitchPerformance(requestSeq, floorId, 'failure', {
|
||||
error: error instanceof Error ? error.name : String(error)
|
||||
})
|
||||
emit('floorSwitchFailed', {
|
||||
floorId,
|
||||
floorLabel
|
||||
})
|
||||
console.error('恢复单层楼层失败:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
||||
.then(() => {
|
||||
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isStaleFloorSwitchError(error)) return
|
||||
failedFloorId.value = floorId
|
||||
clearFloorLoadingIfCurrent(floorId)
|
||||
emit('floorSwitchFailed', {
|
||||
if (isStaleFloorSwitchError(error)) return
|
||||
failedFloorId.value = floorId
|
||||
clearFloorLoadingIfCurrent(floorId)
|
||||
finishFloorSwitchPerformance(requestSeq, floorId, 'failure', {
|
||||
error: error instanceof Error ? error.name : String(error)
|
||||
})
|
||||
emit('floorSwitchFailed', {
|
||||
floorId,
|
||||
floorLabel
|
||||
})
|
||||
@@ -684,11 +891,17 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
||||
}
|
||||
|
||||
const handleLayerModeToggle = () => {
|
||||
handleLayerModeChange(nextLayerMode.value)
|
||||
void handleLayerModeChange(nextLayerMode.value)
|
||||
}
|
||||
|
||||
let lastFloorHeaderInteractionAt = 0
|
||||
const handleFloorHeaderTap = () => {
|
||||
handleLayerModeChange(props.layerMode === 'multi' ? 'single' : 'multi')
|
||||
const now = Date.now()
|
||||
if (now - lastFloorHeaderInteractionAt < 250) return
|
||||
lastFloorHeaderInteractionAt = now
|
||||
const currentLayerMode = effectiveLayerMode.value
|
||||
const nextMode = currentLayerMode === 'multi' ? 'single' : 'multi'
|
||||
void handleLayerModeChange(nextMode)
|
||||
}
|
||||
|
||||
const handleToolClick = (tool: string) => {
|
||||
@@ -718,6 +931,9 @@ const toolIconType = (tool: string) => {
|
||||
}
|
||||
|
||||
const handleZoomClick = (direction: 'in' | 'out') => {
|
||||
const now = Date.now()
|
||||
if (now - lastZoomActionAt < 100) return
|
||||
lastZoomActionAt = now
|
||||
indoorRendererRef.value?.zoomCamera?.(direction, { source: 'button' })
|
||||
emit('toolClick', direction === 'in' ? '放大' : '缩小')
|
||||
}
|
||||
@@ -728,9 +944,10 @@ const handleShowOverview = async () => {
|
||||
requestedFloorId.value = ''
|
||||
requestedFloorLabel.value = ''
|
||||
failedFloorId.value = ''
|
||||
await indoorRendererRef.value?.showOverview?.()
|
||||
const committed = await indoorRendererRef.value?.showOverview?.()
|
||||
if (committed === false) return
|
||||
emit('selectionClear')
|
||||
emit('indoorViewChange', 'overview')
|
||||
emit('indoorViewChange', 'overview', props.sceneRevision)
|
||||
emit('layerModeChange', 'single')
|
||||
}
|
||||
|
||||
@@ -738,17 +955,22 @@ const handleMoreTap = () => {
|
||||
emit('moreClick')
|
||||
}
|
||||
|
||||
const handleThreeFloorChange = (floorId: string) => {
|
||||
const handleThreeFloorChange = (floorId: string, sceneRevision?: number) => {
|
||||
if (!isCurrentRendererRevision(sceneRevision)) return
|
||||
// A foreground multi-floor commit invalidates any earlier floor request.
|
||||
// Do not let its late event roll the Shell back to single-floor controls.
|
||||
if (effectiveLayerMode.value === 'multi') return
|
||||
renderedFloorId.value = floorId
|
||||
if (loadingFloorId.value === floorId) {
|
||||
renderedFloorSwitchRequestSeq.value = activeFloorSwitchRequestSeq.value
|
||||
finishFloorSwitchPerformance(activeFloorSwitchRequestSeq.value, floorId, 'success')
|
||||
}
|
||||
if (failedFloorId.value === floorId) {
|
||||
failedFloorId.value = ''
|
||||
}
|
||||
clearFloorLoadingIfCurrent(floorId)
|
||||
emit('floorChange', floorId)
|
||||
emit('indoorViewChange', 'floor')
|
||||
emitFloorSceneCommit(floorId, sceneRevision)
|
||||
emitViewSceneCommit('floor', sceneRevision)
|
||||
emit('layerModeChange', 'single')
|
||||
}
|
||||
|
||||
@@ -756,6 +978,27 @@ const handlePoiClick = (poi: GuideRenderPoi) => {
|
||||
emit('poiClick', poi)
|
||||
}
|
||||
|
||||
const handleRouteRoamingProgress = (event: { remainingMeters: number; progress: number }) => {
|
||||
emit('routeRoamingProgress', event)
|
||||
}
|
||||
|
||||
const handleRouteRoamingTransfer = (event: {
|
||||
fromFloorId: string
|
||||
toFloorId: string
|
||||
transferType: string
|
||||
connectorName?: string
|
||||
}) => {
|
||||
emit('routeRoamingTransfer', event)
|
||||
}
|
||||
|
||||
const handleRouteStartCandidate = (candidate: RouteStartCandidatePayload) => {
|
||||
emit('routeStartCandidate', candidate)
|
||||
}
|
||||
|
||||
const handleRouteStartCandidateRejected = () => {
|
||||
emit('routeStartCandidateRejected')
|
||||
}
|
||||
|
||||
const handleSelectionClear = () => {
|
||||
emit('selectionClear')
|
||||
}
|
||||
@@ -764,7 +1007,14 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
||||
emit('targetFocus', result)
|
||||
}
|
||||
|
||||
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
|
||||
const handleAutoSwitch = (event: {
|
||||
from: 'overview' | 'floor'
|
||||
to: 'overview' | 'floor'
|
||||
trigger: string
|
||||
distance: number
|
||||
sceneRevision?: number
|
||||
}) => {
|
||||
if (!isCurrentRendererRevision(event.sceneRevision)) return
|
||||
emit('autoSwitch', event)
|
||||
}
|
||||
|
||||
@@ -773,13 +1023,33 @@ const handleInitialModelProgress = (event: InitialModelProgressEvent) => {
|
||||
}
|
||||
|
||||
const handleInitialModelReady = (event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }) => {
|
||||
if (event.view === 'floor' && event.floorId) renderedFloorId.value = event.floorId
|
||||
emit('initialModelReady', event)
|
||||
}
|
||||
|
||||
const handleInitialModelFailed = (event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }) => {
|
||||
const handleInitialModelFailed = (event: {
|
||||
view: IndoorViewMode
|
||||
floorId?: string
|
||||
message: string
|
||||
elapsedMs?: number
|
||||
fallbackAvailable?: boolean
|
||||
actualRenderMode?: IndoorRenderMode
|
||||
}) => {
|
||||
emit('initialModelFailed', event)
|
||||
}
|
||||
|
||||
const handleRenderModeFallback = (event: {
|
||||
mode: 'two-d'
|
||||
view: 'overview' | 'floor'
|
||||
floorId?: string
|
||||
reason: string
|
||||
sceneRevision: number
|
||||
}) => {
|
||||
if (!isCurrentRendererRevision(event.sceneRevision)) return
|
||||
if (event.view === 'floor' && event.floorId) renderedFloorId.value = event.floorId
|
||||
emit('indoorRenderModeFallback', event)
|
||||
}
|
||||
|
||||
const handleMapTap = (location: { latitude?: number; longitude?: number }) => {
|
||||
const { latitude, longitude } = location
|
||||
if (latitude !== undefined && longitude !== undefined) {
|
||||
@@ -796,12 +1066,16 @@ defineExpose({
|
||||
clearRoute: () => {
|
||||
indoorRendererRef.value?.clearRoute?.()
|
||||
},
|
||||
// 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。
|
||||
clearSelection: () => {
|
||||
indoorRendererRef.value?.clearSelection?.(false)
|
||||
},
|
||||
// 通过 Shell 的事务入口发起切换;父级仍以 floor-change 为唯一提交依据。
|
||||
switchFloor: (floorId: string) => {
|
||||
const floor = findFloorItemById(floorId)
|
||||
if (!floor) return Promise.resolve()
|
||||
return requestFloorSwitch(floor, { force: true })
|
||||
if (!floor) return
|
||||
return handleFloorChange(floor)
|
||||
},
|
||||
setLayerMode: handleLayerModeChange,
|
||||
showOverview: handleShowOverview,
|
||||
resetToViewBaseline: (options: {
|
||||
view: 'overview' | 'floor'
|
||||
@@ -1026,6 +1300,37 @@ defineExpose({
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.map-zoom-stack {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
z-index: 35;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
|
||||
.indoor-render-mode-toggle {
|
||||
width: 44px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
color: #ffffff;
|
||||
background: #3f6fc4;
|
||||
border: 1px solid rgba(21, 101, 192, 0.4);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 3px 10px rgba(26, 35, 126, 0.14);
|
||||
}
|
||||
|
||||
.indoor-render-mode-toggle text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.guide-mode-row.layout-full {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1109,7 +1414,8 @@ defineExpose({
|
||||
|
||||
.floor-switcher {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
/* Keep both side rails on the same outer width, including their borders. */
|
||||
width: 46px;
|
||||
height: auto;
|
||||
padding: 1px;
|
||||
box-sizing: border-box;
|
||||
@@ -1137,8 +1443,8 @@ defineExpose({
|
||||
|
||||
.floor-header {
|
||||
position: relative;
|
||||
min-height: 38px;
|
||||
padding: 5px 4px 4px;
|
||||
min-height: 50px;
|
||||
padding: 6px 0 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -1150,7 +1456,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
.floor-header.active {
|
||||
background: #000000;
|
||||
background: #edf1ff;
|
||||
box-shadow: inset 0 0 0 1px #c7d2f4;
|
||||
}
|
||||
|
||||
.floor-header::after {
|
||||
@@ -1164,13 +1471,15 @@ defineExpose({
|
||||
}
|
||||
|
||||
.floor-header-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
flex: 0 0 18px;
|
||||
width: 19px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
color: #151713;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.floor-header-icon::before,
|
||||
@@ -1183,32 +1492,48 @@ defineExpose({
|
||||
}
|
||||
|
||||
.floor-header-icon::before {
|
||||
left: 2px;
|
||||
top: 4px;
|
||||
width: 11px;
|
||||
height: 9px;
|
||||
left: 1px;
|
||||
top: 5px;
|
||||
width: 13px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.floor-header-icon::after {
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 11px;
|
||||
height: 9px;
|
||||
width: 13px;
|
||||
height: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.floor-header.active .floor-header-icon {
|
||||
color: var(--museum-accent);
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.floor-header.active .floor-header-icon::after {
|
||||
background: #edf1ff;
|
||||
}
|
||||
|
||||
.floor-header-label {
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
position: static;
|
||||
flex: 0 0 13px;
|
||||
width: 100%;
|
||||
height: 13px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
line-height: 13px;
|
||||
color: #545861;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.floor-header.active .floor-header-label {
|
||||
color: var(--museum-accent);
|
||||
color: #1a237e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.layer-mode-toggle {
|
||||
@@ -1266,7 +1591,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.floor-item.active {
|
||||
background: #000000;
|
||||
background: var(--museum-accent);
|
||||
}
|
||||
|
||||
.floor-item.pending {
|
||||
@@ -1299,7 +1624,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
.floor-item.active .floor-label {
|
||||
color: var(--museum-accent);
|
||||
color: #151713;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tool-stack {
|
||||
@@ -1345,15 +1671,13 @@ defineExpose({
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
width: 48px;
|
||||
width: 46px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
|
||||
@@ -1,726 +1,115 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="visible"
|
||||
class="route-planner-panel"
|
||||
:class="{ collapsed: isCollapsed }"
|
||||
@touchstart.stop="handlePanelTouchStart"
|
||||
@touchmove.stop="handlePanelTouchMove"
|
||||
@touchend.stop="handlePanelTouchEnd"
|
||||
@mousedown.stop="handlePanelMouseStart"
|
||||
@mousemove.stop="handlePanelMouseMove"
|
||||
@mouseup.stop="handlePanelMouseEnd"
|
||||
>
|
||||
<view class="panel-handle" @tap.stop="toggleCollapsed"></view>
|
||||
|
||||
<view v-if="isCollapsed" class="panel-collapsed" @tap="expandPanel">
|
||||
<view class="panel-collapsed-copy">
|
||||
<text class="panel-title">馆内导览</text>
|
||||
<text class="panel-summary">{{ collapsedSummary }}</text>
|
||||
<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="panel-expand">
|
||||
<text class="panel-expand-text">展开</text>
|
||||
<view class="route-panel-close" aria-label="关闭导览" @tap="emit('back')">
|
||||
<text class="route-panel-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="panel-header">
|
||||
<view class="panel-title-group">
|
||||
<text class="panel-title">馆内导览</text>
|
||||
<text v-if="summary" class="panel-summary">{{ summary }}</text>
|
||||
</view>
|
||||
<view class="panel-actions">
|
||||
<view class="panel-light-action" @tap="handleBack">
|
||||
<text class="panel-light-action-text">返回</text>
|
||||
<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="emit('cancelStart')">
|
||||
<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="emit('cancelStart')">
|
||||
<text class="route-confirm-action-text">取消</text>
|
||||
</view>
|
||||
<view class="panel-light-action" @tap="collapsePanel">
|
||||
<text class="panel-light-action-text">收起</text>
|
||||
</view>
|
||||
<view class="panel-light-action" @tap="handleClear">
|
||||
<text class="panel-light-action-text">清除</text>
|
||||
<view class="route-confirm-action primary" @tap="emit('confirmStart', startCandidate)">
|
||||
<text class="route-confirm-action-text">确定</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="point-row">
|
||||
<view class="point-card" @tap="openPicker('start')">
|
||||
<view class="point-dot start"></view>
|
||||
<text class="point-label">起点</text>
|
||||
<text class="point-name" :class="{ placeholder: !startPoint }">
|
||||
{{ startPoint?.name || '选择起点' }}
|
||||
</text>
|
||||
<text v-if="startPoint" class="point-meta">{{ pointMeta(startPoint) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="swap-btn" @tap="handleSwap">
|
||||
<text class="swap-text">⇅</text>
|
||||
</view>
|
||||
|
||||
<view class="point-card" @tap="openPicker('end')">
|
||||
<view class="point-dot end"></view>
|
||||
<text class="point-label">终点</text>
|
||||
<text class="point-name" :class="{ placeholder: !endPoint }">
|
||||
{{ endPoint?.name || '选择终点' }}
|
||||
</text>
|
||||
<text v-if="endPoint" class="point-meta">{{ pointMeta(endPoint) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="startPoint && endPoint" class="route-options">
|
||||
<view class="route-options-header">
|
||||
<text class="route-options-title">{{ routeOptionsTitle }}</text>
|
||||
<text class="route-options-note">偏好选择</text>
|
||||
</view>
|
||||
<view class="route-option-list">
|
||||
<view
|
||||
v-for="option in routeOptions"
|
||||
:key="option.id"
|
||||
class="route-option"
|
||||
:class="{ active: option.active, disabled: option.disabled }"
|
||||
>
|
||||
<text class="route-option-title">{{ option.title }}</text>
|
||||
<text class="route-option-meta">{{ option.meta }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading || error || summary" class="panel-status">
|
||||
<text v-if="loading" class="panel-status-text">{{ loadingText }}</text>
|
||||
<text v-else-if="error" class="panel-status-text error">{{ error }}</text>
|
||||
<text v-else class="panel-status-text">{{ summary }}</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="view-route-btn"
|
||||
:class="{ disabled: !canPrimaryAction }"
|
||||
@tap="handlePrimaryAction"
|
||||
>
|
||||
<text class="view-route-text">{{ primaryActionText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<RoutePointPicker
|
||||
:visible="pickerMode !== ''"
|
||||
:title="pickerTitle"
|
||||
:placeholder="pickerPlaceholder"
|
||||
:options="pointOptions"
|
||||
:selected-poi-id="activeSelectedPoiId"
|
||||
:loading="pickerLoading"
|
||||
:error="pickerError"
|
||||
:empty-text="pickerEmptyText"
|
||||
close-text="返回"
|
||||
@close="closePicker"
|
||||
@select="handlePointSelect"
|
||||
@search="handlePickerSearch"
|
||||
@keyword-change="handlePickerKeywordChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import RoutePointPicker, {
|
||||
type RoutePointOption
|
||||
} from '@/components/navigation/RoutePointPicker.vue'
|
||||
|
||||
type PickerMode = '' | 'start' | 'end'
|
||||
import { computed } 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
|
||||
pointOptions?: RoutePointOption[]
|
||||
startCandidate?: RoutePointOption | null
|
||||
hasRoutePreview?: boolean
|
||||
loading?: boolean
|
||||
error?: string
|
||||
summary?: string
|
||||
pickerLoading?: boolean
|
||||
pickerError?: string
|
||||
pickerEmptyText?: string
|
||||
routeReady?: boolean
|
||||
}>(), {
|
||||
visible: true,
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
pointOptions: () => [] as RoutePointOption[],
|
||||
startCandidate: null,
|
||||
hasRoutePreview: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
summary: '',
|
||||
pickerLoading: false,
|
||||
pickerError: '',
|
||||
pickerEmptyText: '暂无匹配地点',
|
||||
routeReady: false
|
||||
summary: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:startPoint': [point: RoutePointOption | null]
|
||||
'update:endPoint': [point: RoutePointOption | null]
|
||||
startChange: [point: RoutePointOption]
|
||||
endChange: [point: RoutePointOption]
|
||||
pickerOpen: [mode: 'start' | 'end']
|
||||
pickerClose: []
|
||||
search: [payload: { mode: 'start' | 'end'; keyword: string }]
|
||||
swap: []
|
||||
clear: []
|
||||
viewRoute: [payload: { startPoint: RoutePointOption; endPoint: RoutePointOption }]
|
||||
confirmStart: [point: RoutePointOption]
|
||||
cancelStart: []
|
||||
simulateGuide: []
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const pickerMode = ref<PickerMode>('')
|
||||
const isCollapsed = ref(false)
|
||||
const panelTouchStartY = ref(0)
|
||||
const panelTouchCurrentY = ref(0)
|
||||
|
||||
const pickerTitle = computed(() => (
|
||||
pickerMode.value === 'start' ? '选择起点' : '选择终点'
|
||||
))
|
||||
|
||||
const pickerPlaceholder = computed(() => (
|
||||
pickerMode.value === 'start' ? '搜索起点' : '搜索终点'
|
||||
))
|
||||
|
||||
const activeSelectedPoiId = computed(() => (
|
||||
pickerMode.value === 'start'
|
||||
? props.startPoint?.poiId || ''
|
||||
: props.endPoint?.poiId || ''
|
||||
))
|
||||
|
||||
const canViewRoute = computed(() => Boolean(
|
||||
props.startPoint
|
||||
&& props.endPoint
|
||||
&& !props.loading
|
||||
&& props.startPoint.poiId !== props.endPoint.poiId
|
||||
))
|
||||
|
||||
const canPrimaryAction = computed(() => canViewRoute.value && !props.error)
|
||||
|
||||
const primaryActionText = computed(() => '查看位置关系')
|
||||
|
||||
const routeOptionsTitle = computed(() => '馆内导览')
|
||||
|
||||
const loadingText = computed(() => '正在生成馆内位置关系')
|
||||
|
||||
const collapsedSummary = computed(() => {
|
||||
if (props.startPoint && props.endPoint) {
|
||||
return `${props.startPoint.name} → ${props.endPoint.name}`
|
||||
}
|
||||
|
||||
return '手动选择起点和终点'
|
||||
})
|
||||
|
||||
const routeOptions = computed(() => {
|
||||
const unavailable = Boolean(props.error)
|
||||
const summaryText = props.summary || '选择后查看位置关系'
|
||||
return [
|
||||
{
|
||||
id: 'recommended',
|
||||
title: '推荐路线',
|
||||
meta: unavailable
|
||||
? '当前暂不可用'
|
||||
: props.hasRoutePreview
|
||||
? summaryText
|
||||
: '查看起点终点位置关系',
|
||||
active: !unavailable,
|
||||
disabled: unavailable
|
||||
},
|
||||
{
|
||||
id: 'stairs',
|
||||
title: '少走楼梯',
|
||||
meta: '偏好预览,待路线数据验证',
|
||||
active: false,
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: 'elevator',
|
||||
title: '电梯优先',
|
||||
meta: '偏好预览,待路线数据验证',
|
||||
active: false,
|
||||
disabled: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
isCollapsed.value = false
|
||||
} else {
|
||||
pickerMode.value = ''
|
||||
}
|
||||
}
|
||||
const pointMeta = (point: RoutePointOption) => (
|
||||
point.categoryLabel ? `${point.floorLabel} · ${point.categoryLabel}` : point.floorLabel
|
||||
)
|
||||
|
||||
const pointMeta = (point: RoutePointOption) => {
|
||||
return 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 openPicker = (mode: 'start' | 'end') => {
|
||||
pickerMode.value = mode
|
||||
emit('search', { mode, keyword: '' })
|
||||
emit('pickerOpen', mode)
|
||||
}
|
||||
|
||||
const closePicker = () => {
|
||||
pickerMode.value = ''
|
||||
emit('pickerClose')
|
||||
}
|
||||
|
||||
const handlePointSelect = (point: RoutePointOption) => {
|
||||
if (pickerMode.value === 'start') {
|
||||
emit('update:startPoint', point)
|
||||
emit('startChange', point)
|
||||
} else if (pickerMode.value === 'end') {
|
||||
emit('update:endPoint', point)
|
||||
emit('endChange', point)
|
||||
const panelSubtitle = computed(() => {
|
||||
if (props.startPoint && props.endPoint) {
|
||||
return `从 ${pointTitle(props.startPoint)} 前往 ${pointTitle(props.endPoint)}`
|
||||
}
|
||||
|
||||
closePicker()
|
||||
}
|
||||
|
||||
const handlePickerSearch = (keyword: string) => {
|
||||
if (!pickerMode.value) return
|
||||
emit('search', { mode: pickerMode.value, keyword })
|
||||
}
|
||||
|
||||
const handlePickerKeywordChange = (keyword: string) => {
|
||||
if (!pickerMode.value) return
|
||||
emit('search', { mode: pickerMode.value, keyword })
|
||||
}
|
||||
|
||||
const handleSwap = () => {
|
||||
emit('swap')
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (pickerMode.value) {
|
||||
closePicker()
|
||||
return
|
||||
}
|
||||
|
||||
emit('back')
|
||||
}
|
||||
|
||||
const collapsePanel = () => {
|
||||
isCollapsed.value = true
|
||||
}
|
||||
|
||||
const expandPanel = () => {
|
||||
isCollapsed.value = false
|
||||
}
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
const getGestureClientY = (event: TouchEvent | MouseEvent) => {
|
||||
if ('changedTouches' in event) {
|
||||
return event.changedTouches?.[0]?.clientY
|
||||
?? event.touches?.[0]?.clientY
|
||||
?? 0
|
||||
}
|
||||
|
||||
return event.clientY
|
||||
}
|
||||
|
||||
const handlePanelTouchStart = (event: TouchEvent) => {
|
||||
if (pickerMode.value) return
|
||||
const clientY = getGestureClientY(event)
|
||||
panelTouchStartY.value = clientY
|
||||
panelTouchCurrentY.value = clientY
|
||||
}
|
||||
|
||||
const handlePanelTouchMove = (event: TouchEvent) => {
|
||||
if (pickerMode.value) return
|
||||
panelTouchCurrentY.value = getGestureClientY(event)
|
||||
}
|
||||
|
||||
const handlePanelTouchEnd = (event: TouchEvent) => {
|
||||
if (pickerMode.value) return
|
||||
panelTouchCurrentY.value = getGestureClientY(event)
|
||||
if (panelTouchCurrentY.value - panelTouchStartY.value > 48) {
|
||||
collapsePanel()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePanelMouseStart = (event: MouseEvent) => {
|
||||
if (pickerMode.value) return
|
||||
const clientY = getGestureClientY(event)
|
||||
panelTouchStartY.value = clientY
|
||||
panelTouchCurrentY.value = clientY
|
||||
}
|
||||
|
||||
const handlePanelMouseMove = (event: MouseEvent) => {
|
||||
if (pickerMode.value) return
|
||||
panelTouchCurrentY.value = getGestureClientY(event)
|
||||
}
|
||||
|
||||
const handlePanelMouseEnd = (event: MouseEvent) => {
|
||||
if (pickerMode.value) return
|
||||
panelTouchCurrentY.value = getGestureClientY(event)
|
||||
if (panelTouchCurrentY.value - panelTouchStartY.value > 48) {
|
||||
collapsePanel()
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewRoute = () => {
|
||||
if (!canViewRoute.value || !props.startPoint || !props.endPoint) return
|
||||
|
||||
emit('viewRoute', {
|
||||
startPoint: props.startPoint,
|
||||
endPoint: props.endPoint
|
||||
})
|
||||
}
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!canViewRoute.value) return
|
||||
if (props.hasRoutePreview) {
|
||||
emit('simulateGuide')
|
||||
return
|
||||
}
|
||||
|
||||
handleViewRoute()
|
||||
}
|
||||
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: 10px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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-planner-panel.collapsed {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.panel-handle {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0 auto 10px;
|
||||
background: #d8dbd2;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.panel-collapsed {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-collapsed-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.panel-expand {
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #151713;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-expand-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title-group {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.panel-light-action {
|
||||
flex: 0 0 auto;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #f5f7f2;
|
||||
border: 1px solid #e4e5df;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-light-action-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.point-row {
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.point-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
padding: 8px 10px 8px 34px;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
background: #f6f7f4;
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.point-dot {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 21px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.point-dot.start {
|
||||
background: #1fbf6b;
|
||||
}
|
||||
|
||||
.point-dot.end {
|
||||
background: #ef5552;
|
||||
}
|
||||
|
||||
.point-label {
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.point-name {
|
||||
margin-top: 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.point-name.placeholder {
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.point-meta {
|
||||
grid-column: 2;
|
||||
margin-top: -2px;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.swap-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 42px;
|
||||
height: 112px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d7dad3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.swap-text {
|
||||
font-size: 22px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
margin-top: 10px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.panel-status-text {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #696962;
|
||||
}
|
||||
|
||||
.panel-status-text.error {
|
||||
color: #b44b42;
|
||||
}
|
||||
|
||||
.view-route-btn {
|
||||
margin-top: 12px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #151713;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.view-route-btn.disabled {
|
||||
background: #d8dbd2;
|
||||
}
|
||||
|
||||
.view-route-text {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.view-route-btn.disabled .view-route-text {
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.route-options {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #ecede7;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.route-options-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.route-options-title {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.route-options-note {
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.route-option-list {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.route-option {
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
padding: 8px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.route-option.active {
|
||||
border-color: #151713;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.route-option.disabled {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.route-option-title {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.route-option-meta {
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: #696962;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -68,13 +68,13 @@
|
||||
<scroll-view v-else class="picker-list" scroll-y>
|
||||
<view
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.poiId"
|
||||
:key="option.routeTargetId || `${option.poiId}-${option.name}`"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedPoiId === option.poiId }"
|
||||
@tap="handleSelect(option)"
|
||||
>
|
||||
<view class="option-main">
|
||||
<text class="option-name">{{ option.name }}</text>
|
||||
<text class="option-name">{{ normalizeVisitorPoiDisplayName(option.name) }}</text>
|
||||
<text class="option-meta">{{ formatMeta(option) }}</text>
|
||||
</view>
|
||||
<view v-if="selectedPoiId === option.poiId" class="option-check">
|
||||
@@ -94,13 +94,20 @@ import {
|
||||
compareFloorsTopToBottom,
|
||||
isIndoorNavigableFloor
|
||||
} from '@/domain/guideFloor'
|
||||
import { normalizeVisitorPoiDisplayName } from '@/view-models/visitorPoiPresentation'
|
||||
|
||||
export interface RoutePointOption {
|
||||
routeTargetId?: string
|
||||
poiId: string
|
||||
sourceId?: string
|
||||
name: string
|
||||
floorId: string
|
||||
floorLabel: string
|
||||
categoryLabel?: string
|
||||
positionGltf?: [number, number, number]
|
||||
routeNodeId?: string
|
||||
/** 点击地图对象后由路线服务按坐标吸附到正式路网节点。 */
|
||||
coordinateFallback?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<view class="home-back-button" data-testid="poi-search-cancel" @tap.stop="handleCancel">
|
||||
<text class="home-back-icon">‹</text>
|
||||
</view>
|
||||
<text class="home-fullscreen-title">点位搜索</text>
|
||||
<text class="home-fullscreen-title">地图导览</text>
|
||||
<view class="home-nav-spacer"></view>
|
||||
</view>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
@input="handleSearchInput"
|
||||
@confirm="handleSearchConfirm"
|
||||
/>
|
||||
<view v-if="searchDraftKeyword" class="clear-button" @tap.stop="handleSearchClear">
|
||||
<view v-if="searchDraftKeyword && !isHomeCategoryMode" class="clear-button" @tap.stop="handleSearchClear">
|
||||
<text class="clear-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -55,6 +55,7 @@
|
||||
class="home-category-chip"
|
||||
:class="{ active: activeCategoryId === item.id, disabled: isCategoryDisabled(item) }"
|
||||
:data-testid="`poi-category-${item.id}`"
|
||||
:data-category="item.id"
|
||||
:aria-disabled="isCategoryDisabled(item)"
|
||||
@tap="handleFacilityShortcut(item)"
|
||||
>
|
||||
@@ -76,7 +77,7 @@
|
||||
<view class="home-category-results-copy">
|
||||
<text class="home-category-results-title">{{ activeCategory?.label || searchKeyword }}</text>
|
||||
<text class="home-category-results-meta">
|
||||
当前楼层 {{ activeFloor || '待确认' }} · {{ floorResults.length }} 个点位
|
||||
当前楼层 {{ activeFloor || '待确认' }} · {{ floorResults.length }} 处地点
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
@@ -139,9 +140,10 @@
|
||||
<view
|
||||
v-for="item in searchCategoryColumns"
|
||||
:key="item.id"
|
||||
class="category-item"
|
||||
:class="{ active: activeCategoryId === item.id, disabled: isCategoryDisabled(item) }"
|
||||
:data-testid="`poi-category-${item.id}`"
|
||||
class="category-item"
|
||||
:class="{ active: activeCategoryId === item.id, disabled: isCategoryDisabled(item) }"
|
||||
:data-testid="`poi-category-${item.id}`"
|
||||
:data-category="item.id"
|
||||
:aria-disabled="isCategoryDisabled(item)"
|
||||
@tap="handleFacilityShortcut(item)"
|
||||
>
|
||||
@@ -158,7 +160,7 @@
|
||||
<view class="result-card">
|
||||
<view class="result-card-header">
|
||||
<text class="museum-title">深圳自然博物馆</text>
|
||||
<text class="result-count">当前楼层 {{ floorResults.length }} 个点位</text>
|
||||
<text class="result-count">当前楼层 {{ floorResults.length }} 处地点</text>
|
||||
</view>
|
||||
|
||||
<text v-if="dataWarning" class="data-warning result-card-warning">{{ dataWarning }}</text>
|
||||
@@ -231,9 +233,6 @@ import {
|
||||
import {
|
||||
guideUseCase
|
||||
} from '@/usecases/guideUseCase'
|
||||
import {
|
||||
createVisitorPoiPresentations
|
||||
} from '@/view-models/visitorPoiPresentation'
|
||||
import {
|
||||
HOME_POI_CATEGORIES,
|
||||
POI_CATEGORIES,
|
||||
@@ -248,8 +247,8 @@ import type {
|
||||
PoiCategoryResultState,
|
||||
PoiSearchContext
|
||||
} from '@/domain/poiSearch'
|
||||
import { nextHomeSearchResultVersion } from './homeSearchResultVersion'
|
||||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||
import { normalizeVisitorPoiDisplayName } from '@/view-models/visitorPoiPresentation'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
initialKeyword?: string
|
||||
@@ -304,6 +303,7 @@ const duplicatePoiCount = ref(0)
|
||||
const resultListScrollTop = ref(0)
|
||||
const resultListRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null)
|
||||
let searchRequestSeq = 0
|
||||
let pendingSearchFloorId = ''
|
||||
|
||||
const collapseDragThreshold = 48
|
||||
const homeExpandTapGuardMs = 360
|
||||
@@ -328,16 +328,20 @@ const displayFloors = computed(() => [...floors.value]
|
||||
.filter(isIndoorNavigableFloor)
|
||||
.sort(compareFloorsTopToBottom))
|
||||
|
||||
const floorResults = computed(() => (
|
||||
activeFloor.value
|
||||
? pois.value.filter((poi) => poi.floorLabel === activeFloor.value)
|
||||
: []
|
||||
const activeFloorOption = computed(() => (
|
||||
displayFloors.value.find((floor) => floor.label === activeFloor.value) || null
|
||||
))
|
||||
|
||||
// GuidePoiSearchViewState.results is already the canonical, current-floor
|
||||
// render list. Result POIs may carry a backend floor alias, so filtering them
|
||||
// again through the temporarily selected UI option can incorrectly erase a
|
||||
// valid floor response during floor synchronization.
|
||||
const floorResults = computed(() => pois.value)
|
||||
|
||||
const emptyTitle = computed(() => {
|
||||
if (isLoading.value) return '正在读取点位'
|
||||
if (isLoading.value) return '正在读取地点'
|
||||
if (loadError.value) return loadError.value
|
||||
return '当前楼层暂无匹配点位'
|
||||
return '当前楼层暂无匹配地点'
|
||||
})
|
||||
|
||||
const emptyDesc = computed(() => {
|
||||
@@ -366,14 +370,9 @@ const dataWarning = computed(() => {
|
||||
const isCategoryDisabled = (category: PoiCategoryDefinition) => {
|
||||
const state = categoryStatesById.value.get(category.id)
|
||||
return isLoading.value
|
||||
|| !state
|
||||
|| state.disabled
|
||||
|| Boolean(state?.disabled)
|
||||
}
|
||||
|
||||
const activeFloorOption = computed(() => (
|
||||
displayFloors.value.find((floor) => floor.label === activeFloor.value) || null
|
||||
))
|
||||
|
||||
const createSearchContext = (): PoiSearchContext => {
|
||||
const visiblePoiIds = searchViewState.value?.visiblePoiIds || []
|
||||
const resultCount = pois.value.length
|
||||
@@ -398,8 +397,7 @@ const emitResultsState = () => {
|
||||
emit('results-change', {
|
||||
...context,
|
||||
visiblePoiIds: active ? context.visiblePoiIds : [],
|
||||
active,
|
||||
requestId: props.variant === 'home' ? nextHomeSearchResultVersion() : undefined
|
||||
active
|
||||
})
|
||||
}
|
||||
|
||||
@@ -413,8 +411,7 @@ const emitPendingHomeSearchResults = (floor?: Pick<MuseumFloor, 'id' | 'label'>)
|
||||
floorLabel: floor?.label || context.floorLabel,
|
||||
visiblePoiIds: [],
|
||||
active: true,
|
||||
pending: true,
|
||||
requestId: nextHomeSearchResultVersion()
|
||||
pending: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -466,36 +463,48 @@ const activeFloorId = () => (
|
||||
|
||||
const commitPoiSearchViewState = async (
|
||||
state: GuidePoiSearchViewState,
|
||||
requestSeq: number
|
||||
requestSeq: number,
|
||||
requestedFloorId: string
|
||||
) => {
|
||||
if (requestSeq !== searchRequestSeq) return
|
||||
if (
|
||||
requestSeq !== searchRequestSeq
|
||||
|| requestedFloorId !== pendingSearchFloorId
|
||||
|| state.floorId !== requestedFloorId
|
||||
) return false
|
||||
|
||||
searchViewState.value = state
|
||||
pois.value = state.results
|
||||
activeFloor.value = state.floorLabel
|
||||
pendingSearchFloorId = ''
|
||||
duplicatePoiCount.value = 0
|
||||
excludedPoiCount.value = 0
|
||||
await nextTick()
|
||||
if (requestSeq !== searchRequestSeq) return
|
||||
if (requestSeq !== searchRequestSeq) return false
|
||||
emitResultsState()
|
||||
return true
|
||||
}
|
||||
|
||||
const runPoiSearchRequest = async (
|
||||
loader: () => Promise<GuidePoiSearchViewState>
|
||||
loader: () => Promise<GuidePoiSearchViewState>,
|
||||
requestedFloorId = activeFloorId()
|
||||
) => {
|
||||
const requestSeq = ++searchRequestSeq
|
||||
pendingSearchFloorId = requestedFloorId
|
||||
isLoading.value = true
|
||||
searchError.value = ''
|
||||
|
||||
try {
|
||||
const state = await loader()
|
||||
await commitPoiSearchViewState(state, requestSeq)
|
||||
await commitPoiSearchViewState(state, requestSeq, requestedFloorId)
|
||||
return state
|
||||
} catch (error) {
|
||||
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
|
||||
return null
|
||||
} finally {
|
||||
if (requestSeq === searchRequestSeq) isLoading.value = false
|
||||
if (requestSeq === searchRequestSeq) {
|
||||
pendingSearchFloorId = ''
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,18 +530,23 @@ const handleLoadFailure = async (requestSeq: number, error: unknown, message: st
|
||||
}
|
||||
|
||||
const loadInitialSpacePoints = async () => {
|
||||
const requestedFloorId = activeFloorId()
|
||||
const requestSeq = ++searchRequestSeq
|
||||
pendingSearchFloorId = requestedFloorId
|
||||
isLoading.value = true
|
||||
searchError.value = ''
|
||||
|
||||
try {
|
||||
const state = await guideUseCase.createInitialPoiSearchState(activeFloorId())
|
||||
const state = await guideUseCase.createInitialPoiSearchState(requestedFloorId)
|
||||
if (requestSeq !== searchRequestSeq) return
|
||||
await commitPoiSearchViewState(state, requestSeq)
|
||||
await commitPoiSearchViewState(state, requestSeq, requestedFloorId)
|
||||
} catch (error) {
|
||||
await handleLoadFailure(requestSeq, error, '加载点位基础数据失败:')
|
||||
} finally {
|
||||
if (requestSeq === searchRequestSeq) isLoading.value = false
|
||||
if (requestSeq === searchRequestSeq) {
|
||||
pendingSearchFloorId = ''
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +556,9 @@ const loadPois = async (keyword = '') => {
|
||||
return
|
||||
}
|
||||
|
||||
const requestedFloorId = activeFloorId()
|
||||
const requestSeq = ++searchRequestSeq
|
||||
pendingSearchFloorId = requestedFloorId
|
||||
isLoading.value = true
|
||||
searchError.value = ''
|
||||
if (props.variant === 'home') {
|
||||
@@ -554,14 +570,17 @@ const loadPois = async (keyword = '') => {
|
||||
const state = await guideUseCase.searchPoiKeyword(
|
||||
searchViewState.value,
|
||||
keyword,
|
||||
activeFloorId()
|
||||
requestedFloorId
|
||||
)
|
||||
if (requestSeq !== searchRequestSeq) return
|
||||
await commitPoiSearchViewState(state, requestSeq)
|
||||
await commitPoiSearchViewState(state, requestSeq, requestedFloorId)
|
||||
} catch (error) {
|
||||
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
|
||||
} finally {
|
||||
if (requestSeq === searchRequestSeq) isLoading.value = false
|
||||
if (requestSeq === searchRequestSeq) {
|
||||
pendingSearchFloorId = ''
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +590,7 @@ const refreshCurrentPoiSearchFloor = async (floorLabel: string) => {
|
||||
|
||||
activeFloor.value = floor.label
|
||||
resultListScrollTop.value = 0
|
||||
pois.value = []
|
||||
const hasExplicitQuery = searchViewState.value?.mode === 'category'
|
||||
|| searchViewState.value?.mode === 'keyword'
|
||||
if (props.variant === 'home' && hasExplicitQuery) {
|
||||
@@ -580,7 +600,7 @@ const refreshCurrentPoiSearchFloor = async (floorLabel: string) => {
|
||||
await runPoiSearchRequest(() => guideUseCase.changePoiSearchFloor(
|
||||
searchViewState.value,
|
||||
floor.id
|
||||
))
|
||||
), floor.id)
|
||||
}
|
||||
|
||||
const focusSearchInput = async () => {
|
||||
@@ -754,7 +774,7 @@ const enterFullSearch = () => {
|
||||
if (props.variant !== 'home' || homeExpanded.value) return
|
||||
const cameFromCategory = homeCategoryMode.value
|
||||
expandHomePanel()
|
||||
if (cameFromCategory) {
|
||||
if (cameFromCategory || !searchViewState.value) {
|
||||
activeCategoryId.value = ''
|
||||
searchKeyword.value = ''
|
||||
searchDraftKeyword.value = ''
|
||||
@@ -806,7 +826,9 @@ const searchShortcut = async (
|
||||
: categoryInput
|
||||
if (!category || isCategoryDisabled(category)) return
|
||||
|
||||
const requestedFloorId = activeFloorId()
|
||||
const requestSeq = ++searchRequestSeq
|
||||
pendingSearchFloorId = requestedFloorId
|
||||
const useHomeResultList = props.variant === 'home'
|
||||
&& (options.homeCategoryResults ?? !homeExpanded.value)
|
||||
if (useHomeResultList) {
|
||||
@@ -832,14 +854,17 @@ const searchShortcut = async (
|
||||
const state = await guideUseCase.selectPoiSearchCategory(
|
||||
searchViewState.value,
|
||||
category.id,
|
||||
activeFloorId()
|
||||
requestedFloorId
|
||||
)
|
||||
if (requestSeq !== searchRequestSeq) return
|
||||
await commitPoiSearchViewState(state, requestSeq)
|
||||
await commitPoiSearchViewState(state, requestSeq, requestedFloorId)
|
||||
} catch (error) {
|
||||
await handleLoadFailure(requestSeq, error, '加载点位分类搜索结果失败:')
|
||||
} finally {
|
||||
if (requestSeq === searchRequestSeq) isLoading.value = false
|
||||
if (requestSeq === searchRequestSeq) {
|
||||
pendingSearchFloorId = ''
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -895,14 +920,15 @@ const restoreResultListScroll = async () => {
|
||||
if (resultListElement) resultListElement.scrollTop = scrollTop
|
||||
}
|
||||
|
||||
const poiPresentations = computed(() => createVisitorPoiPresentations(pois.value))
|
||||
const poiDisplayName = (poi: MuseumPoi) => (
|
||||
poiPresentations.value.get(poi.id)?.displayName || '未命名点位'
|
||||
normalizeVisitorPoiDisplayName(poi.name) || '未命名点位'
|
||||
)
|
||||
|
||||
const poiResultMeta = (poi: MuseumPoi) => {
|
||||
const categoryLabel = resolvePoiCategory(poi)?.label || poi.primaryCategory?.label || '其他'
|
||||
return isPoiLocatable(poi) ? categoryLabel : `${categoryLabel} · 暂无地图坐标`
|
||||
const floorLabel = poi.floorLabel?.trim() || poi.floorId
|
||||
const locationMeta = [floorLabel, categoryLabel].filter(Boolean).join(' · ')
|
||||
return isPoiLocatable(poi) ? locationMeta : `${locationMeta} · 暂无地图坐标`
|
||||
}
|
||||
|
||||
const encodeQueryValue = (value: string | number) => encodeURIComponent(String(value))
|
||||
@@ -989,7 +1015,42 @@ watch([() => props.currentFloorId, () => props.currentFloorLabel], () => {
|
||||
floor.id === props.currentFloorId
|
||||
|| floor.label === props.currentFloorLabel
|
||||
))
|
||||
if (!requestedFloor || requestedFloor.id === searchViewState.value?.floorId) return
|
||||
if (!requestedFloor) return
|
||||
|
||||
// 折叠态只同步下一次查询的目标楼层;若查询界面已有请求在途,
|
||||
// 则立即让旧楼层请求失效,并按当前搜索模式重发新楼层请求。
|
||||
if (!searchViewState.value) {
|
||||
activeFloor.value = requestedFloor.label
|
||||
if (pendingSearchFloorId && pendingSearchFloorId !== requestedFloor.id) {
|
||||
searchRequestSeq += 1
|
||||
pendingSearchFloorId = ''
|
||||
isLoading.value = false
|
||||
pois.value = []
|
||||
|
||||
if (activeCategory.value) {
|
||||
void searchShortcut(activeCategory.value, {
|
||||
homeCategoryResults: homeCategoryMode.value
|
||||
})
|
||||
} else if (searchKeyword.value) {
|
||||
void loadPois(searchKeyword.value)
|
||||
} else if (showSearchContent.value) {
|
||||
void loadInitialSpacePoints()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (requestedFloor.id === searchViewState.value.floorId) {
|
||||
if (pendingSearchFloorId && pendingSearchFloorId !== requestedFloor.id) {
|
||||
searchRequestSeq += 1
|
||||
pendingSearchFloorId = ''
|
||||
isLoading.value = false
|
||||
activeFloor.value = requestedFloor.label
|
||||
pois.value = searchViewState.value.results
|
||||
emitResultsState()
|
||||
}
|
||||
return
|
||||
}
|
||||
void refreshCurrentPoiSearchFloor(requestedFloor.label)
|
||||
})
|
||||
|
||||
@@ -1002,12 +1063,17 @@ watch(showSearchContent, (expanded) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFloors()
|
||||
await applyInitialKeyword(props.initialKeyword)
|
||||
// 首页默认是折叠态。先只同步楼层,避免首屏为了填充未展开的搜索结果而读取全馆点位。
|
||||
// 用户展开搜索、点击分类或提交关键词时再按需建立搜索状态。
|
||||
if (props.variant === 'page' || props.initialKeyword.trim()) {
|
||||
await applyInitialKeyword(props.initialKeyword)
|
||||
}
|
||||
if (props.autofocus) void focusSearchInput()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
searchRequestSeq += 1
|
||||
pendingSearchFloorId = ''
|
||||
// #ifdef H5
|
||||
setH5HomeSearchLock(false)
|
||||
// #endif
|
||||
@@ -1132,9 +1198,9 @@ defineExpose({
|
||||
}
|
||||
|
||||
.variant-home .search-box {
|
||||
background: #f5f5ed;
|
||||
border-color: rgba(224, 225, 0, 0.72);
|
||||
box-shadow: none;
|
||||
background: rgba(255, 255, 251, 0.9);
|
||||
border-color: rgba(224, 225, 0, 0.58);
|
||||
box-shadow: 0 5px 14px rgba(38, 49, 43, 0.07);
|
||||
}
|
||||
|
||||
.variant-home.is-collapsed .search-box {
|
||||
@@ -1164,23 +1230,23 @@ defineExpose({
|
||||
.home-category-chip {
|
||||
min-width: calc((100% - 24px) / 5);
|
||||
flex: 0 0 calc((100% - 24px) / 5);
|
||||
min-height: 68px;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 2px 5px;
|
||||
gap: 5px;
|
||||
padding: 6px 2px 4px;
|
||||
box-sizing: border-box;
|
||||
background: #f5f5ed;
|
||||
border: 1px solid #dfe2d7;
|
||||
background: var(--shortcut-surface, rgba(255, 255, 251, 0.76));
|
||||
border: 1px solid var(--shortcut-border, rgba(26, 35, 126, 0.12));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 5px rgba(21, 23, 19, 0.1);
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.14s ease;
|
||||
box-shadow: none;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.14s ease;
|
||||
}
|
||||
|
||||
.variant-home.is-collapsed .home-category-chip {
|
||||
min-height: 68px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.home-category-strip::-webkit-scrollbar {
|
||||
@@ -1189,32 +1255,93 @@ defineExpose({
|
||||
|
||||
.home-category-chip:active {
|
||||
transform: translateY(1px);
|
||||
background: #eef06d;
|
||||
border-color: #c8ca00;
|
||||
background: var(--shortcut-surface, #f5f8fb);
|
||||
border-color: var(--shortcut-color, #7190a8);
|
||||
}
|
||||
|
||||
.home-category-chip.active {
|
||||
background: #f2f48d;
|
||||
border-color: #bfc100;
|
||||
background: #edf5ff;
|
||||
border-color: #1565c0;
|
||||
box-shadow: inset 0 0 0 1px rgba(21, 101, 192, 0.08);
|
||||
}
|
||||
|
||||
.home-category-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
flex: 0 0 27px;
|
||||
}
|
||||
|
||||
.poi-category-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #151713;
|
||||
color: var(--shortcut-color, #2f5f82);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-category-chip.active .poi-category-icon,
|
||||
.category-item.active .poi-category-icon {
|
||||
color: #151713;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
[data-category='exhibition-hall'] {
|
||||
--shortcut-color: #356f9c;
|
||||
--shortcut-surface: #f1f7fb;
|
||||
--shortcut-border: #bfd4e4;
|
||||
}
|
||||
|
||||
[data-category='cinema'] {
|
||||
--shortcut-color: #78649f;
|
||||
--shortcut-surface: #f7f4fb;
|
||||
--shortcut-border: #d7cce7;
|
||||
}
|
||||
|
||||
[data-category='ticket-office'] {
|
||||
--shortcut-color: #a7752f;
|
||||
--shortcut-surface: #fcf8ef;
|
||||
--shortcut-border: #ead8b8;
|
||||
}
|
||||
|
||||
[data-category='dining'] {
|
||||
--shortcut-color: #b96647;
|
||||
--shortcut-surface: #fdf4ef;
|
||||
--shortcut-border: #edcbbb;
|
||||
}
|
||||
|
||||
[data-category='shopping'] {
|
||||
--shortcut-color: #398c79;
|
||||
--shortcut-surface: #eef8f5;
|
||||
--shortcut-border: #b9dfd4;
|
||||
}
|
||||
|
||||
[data-category='service-center'] {
|
||||
--shortcut-color: #527fa6;
|
||||
--shortcut-surface: #f0f6fb;
|
||||
--shortcut-border: #bfd5e6;
|
||||
}
|
||||
|
||||
[data-category='restroom'] {
|
||||
--shortcut-color: #6386ab;
|
||||
--shortcut-surface: #f2f6fa;
|
||||
--shortcut-border: #c4d5e4;
|
||||
}
|
||||
|
||||
[data-category='nursing-room'] {
|
||||
--shortcut-color: #b46d8a;
|
||||
--shortcut-surface: #fcf2f5;
|
||||
--shortcut-border: #ecc6d5;
|
||||
}
|
||||
|
||||
[data-category='elevator'] {
|
||||
--shortcut-color: #9b7860;
|
||||
--shortcut-surface: #faf5f0;
|
||||
--shortcut-border: #dfcbbd;
|
||||
}
|
||||
|
||||
[data-category='escalator'] {
|
||||
--shortcut-color: #557b9d;
|
||||
--shortcut-surface: #f0f5f9;
|
||||
--shortcut-border: #c0d2e0;
|
||||
}
|
||||
|
||||
.home-category-label {
|
||||
@@ -1473,26 +1600,27 @@ defineExpose({
|
||||
|
||||
.category-item {
|
||||
min-width: 0;
|
||||
min-height: 70px;
|
||||
min-height: 68px;
|
||||
position: relative;
|
||||
padding: 9px 4px 10px;
|
||||
padding: 8px 4px 9px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
background: #f5f5ed;
|
||||
border: 1px solid #dfe2d7;
|
||||
background: var(--shortcut-surface, rgba(255, 255, 251, 0.76));
|
||||
border: 1px solid var(--shortcut-border, rgba(26, 35, 126, 0.12));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 5px rgba(21, 23, 19, 0.08);
|
||||
box-shadow: none;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.14s ease;
|
||||
}
|
||||
|
||||
.category-item.active {
|
||||
background: #f2f48d;
|
||||
border-color: #bfc100;
|
||||
background: #edf5ff;
|
||||
border-color: #1565c0;
|
||||
box-shadow: inset 0 0 0 1px rgba(21, 101, 192, 0.08);
|
||||
}
|
||||
|
||||
.home-category-chip.disabled,
|
||||
@@ -1506,29 +1634,29 @@ defineExpose({
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: -1px;
|
||||
height: 3px;
|
||||
background: var(--museum-accent);
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: #1565c0;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.category-icon-shell {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
flex: 0 0 27px;
|
||||
}
|
||||
|
||||
.category-item:active {
|
||||
transform: translateY(1px);
|
||||
background: #eef06d;
|
||||
border-color: #c8ca00;
|
||||
background: var(--shortcut-surface, #f5f8fb);
|
||||
border-color: var(--shortcut-color, #7190a8);
|
||||
}
|
||||
|
||||
.variant-home .home-shortcut-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 72px);
|
||||
grid-template-rows: repeat(2, 68px);
|
||||
grid-auto-columns: auto;
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
@@ -1536,10 +1664,10 @@ defineExpose({
|
||||
}
|
||||
|
||||
.variant-home .category-item {
|
||||
min-height: 72px;
|
||||
height: 72px;
|
||||
padding: 6px 2px 5px;
|
||||
gap: 4px;
|
||||
min-height: 68px;
|
||||
height: 68px;
|
||||
padding: 6px 2px 4px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
|
||||
Reference in New Issue
Block a user