1394 lines
32 KiB
Vue
1394 lines
32 KiB
Vue
<template>
|
||
<view class="guide-map-shell">
|
||
<view class="map-layer" :class="`map-${mapType}`">
|
||
<template v-if="mapType === 'indoor'">
|
||
<!-- #ifdef H5 -->
|
||
<ThreeMap
|
||
v-if="effectiveIndoorModelSource"
|
||
ref="indoorRendererRef"
|
||
class="indoor-three-map"
|
||
:asset-base-url="indoorAssetBaseUrl"
|
||
:model-source="effectiveIndoorModelSource"
|
||
:initial-floor-id="activeFloorId"
|
||
:initial-view="indoorView"
|
||
:show-controls="false"
|
||
:show-poi="shouldShowIndoorPois"
|
||
:target-focus="targetFocusRequest"
|
||
:touch-gesture-mode="touchGestureMode"
|
||
:target-focus-distance-factor="targetFocusDistanceFactor"
|
||
:route-preview="routePreview"
|
||
:show-route="showRoute"
|
||
:disable-auto-exit="disableAutoExit"
|
||
:auto-switch-threshold-low="autoSwitchThresholdLow"
|
||
:auto-switch-threshold-high="autoSwitchThresholdHigh"
|
||
@floor-change="handleThreeFloorChange"
|
||
@poi-click="handlePoiClick"
|
||
@selection-clear="handleSelectionClear"
|
||
@target-focus="handleTargetFocus"
|
||
@auto-switch="handleAutoSwitch"
|
||
@initial-model-progress="handleInitialModelProgress"
|
||
@initial-model-ready="handleInitialModelReady"
|
||
@initial-model-failed="handleInitialModelFailed"
|
||
/>
|
||
<!-- #endif -->
|
||
<!-- #ifndef H5 -->
|
||
<view class="indoor-platform-fallback">
|
||
<text class="indoor-platform-title">馆内三维导览</text>
|
||
<text class="indoor-platform-desc">当前端暂不支持 WebGL 三维展示,请在 H5 端查看。</text>
|
||
</view>
|
||
<!-- #endif -->
|
||
</template>
|
||
<view v-else class="outdoor-map-bg">
|
||
<TencentMap
|
||
class="outdoor-map"
|
||
:active-floor="activeFloor"
|
||
:floors="props.floors"
|
||
:polylines="outdoorNavPolylines"
|
||
@map-tap="handleMapTap"
|
||
/>
|
||
</view>
|
||
<view v-if="dimmed" class="dim-layer"></view>
|
||
</view>
|
||
|
||
<view
|
||
v-if="showSearch"
|
||
class="guide-search-field"
|
||
:class="`variant-${searchVariant}`"
|
||
:style="searchFieldStyle"
|
||
@tap="handleSearchTap"
|
||
>
|
||
<view v-if="searchVariant === 'home'" class="search-icon leading">
|
||
<view class="search-icon-circle"></view>
|
||
<view class="search-icon-handle"></view>
|
||
</view>
|
||
<text class="search-field-text">{{ searchText }}</text>
|
||
<view v-if="showVoiceIcon" class="voice-icon">
|
||
<view class="voice-head"></view>
|
||
<view class="voice-stem"></view>
|
||
<view class="voice-base"></view>
|
||
</view>
|
||
<view v-else-if="searchVariant !== 'home'" class="search-icon">
|
||
<view class="search-icon-circle"></view>
|
||
<view class="search-icon-handle"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view
|
||
v-if="showModeRow"
|
||
class="guide-mode-row"
|
||
:class="`layout-${modeLayout}`"
|
||
:style="modeRowStyle"
|
||
>
|
||
<template v-if="modeLayout === 'full'">
|
||
<view
|
||
class="mode-option"
|
||
:class="{ active: activeMode === '2d' }"
|
||
@tap="handleModeChange('2d')"
|
||
>
|
||
<text class="mode-label">馆外2D</text>
|
||
</view>
|
||
<view
|
||
class="mode-option"
|
||
:class="{ active: activeMode === '3d' }"
|
||
@tap="handleModeChange('3d')"
|
||
>
|
||
<text class="mode-label">馆内3D</text>
|
||
</view>
|
||
</template>
|
||
<template v-else>
|
||
<view class="compact-mode-switch">
|
||
<view
|
||
class="mode-option"
|
||
:class="{ active: activeMode === '2d' }"
|
||
@tap="handleModeChange('2d')"
|
||
>
|
||
<text class="mode-label">馆外2D</text>
|
||
</view>
|
||
<view
|
||
class="mode-option"
|
||
:class="{ active: activeMode === '3d' }"
|
||
@tap="handleModeChange('3d')"
|
||
>
|
||
<text class="mode-label">馆内3D</text>
|
||
</view>
|
||
</view>
|
||
<view class="mode-status-chip" :class="`tone-${modeStatusTone}`">
|
||
<text class="mode-status-text">{{ modeStatus }}</text>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
|
||
<slot name="overlay"></slot>
|
||
|
||
<view
|
||
v-if="showIndoorRightControls && showLayerModeToggle"
|
||
class="layer-mode-toggle"
|
||
:style="layerModeToggleStyle"
|
||
@tap="handleLayerModeToggle"
|
||
>
|
||
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
|
||
</view>
|
||
|
||
<view
|
||
v-if="showIndoorRightControls && showFloor"
|
||
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
|
||
class="floor-list"
|
||
>
|
||
<view
|
||
v-for="floor in floorItems"
|
||
:key="floor.id"
|
||
class="floor-item"
|
||
:class="{
|
||
active: activeFloorId === floor.id,
|
||
pending: loadingFloorId === floor.id,
|
||
failed: failedFloorId === floor.id,
|
||
disabled: isFloorSwitchDisabled(floor.id)
|
||
}"
|
||
@tap.stop="handleFloorChange(floor)"
|
||
>
|
||
<text class="floor-label">{{ floor.label }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showZoomControls" class="zoom-controls" :style="zoomControlsStyle">
|
||
<view class="zoom-btn" @tap="handleZoomClick('in')">
|
||
<text class="zoom-text">+</text>
|
||
</view>
|
||
<view class="zoom-divider"></view>
|
||
<view class="zoom-btn" @tap="handleZoomClick('out')">
|
||
<text class="zoom-text">−</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showMoreControl" class="more-control" :style="moreControlStyle" @tap="handleMoreTap">
|
||
<text class="more-control-text">更多</text>
|
||
</view>
|
||
|
||
<view v-if="tools.length" class="tool-stack" :style="toolStackStyle">
|
||
<view
|
||
v-for="tool in tools"
|
||
:key="tool"
|
||
class="tool-btn"
|
||
:aria-label="tool"
|
||
:title="tool"
|
||
@tap="handleToolClick(tool)"
|
||
>
|
||
<svg
|
||
v-if="toolIconType(tool) === 'reset'"
|
||
class="tool-icon-svg"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
>
|
||
<path d="M5 7v5h5" />
|
||
<path d="M6.2 13.4A6.2 6.2 0 1 0 7.4 6.7L5 9.1" />
|
||
</svg>
|
||
<svg
|
||
v-else-if="toolIconType(tool) === 'top'"
|
||
class="tool-icon-svg"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
>
|
||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||
<path d="M9 9h6v6H9z" />
|
||
</svg>
|
||
<svg
|
||
v-else-if="toolIconType(tool) === 'oblique'"
|
||
class="tool-icon-svg"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
>
|
||
<path d="M5 9.5 12 5l7 4.5-7 4.5-7-4.5Z" />
|
||
<path d="M5 9.5v5L12 19l7-4.5v-5" />
|
||
<path d="M12 14v5" />
|
||
</svg>
|
||
<svg
|
||
v-else-if="toolIconType(tool) === 'clear'"
|
||
class="tool-icon-svg"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
>
|
||
<path d="M7 7l10 10" />
|
||
<path d="M17 7 7 17" />
|
||
</svg>
|
||
<text v-else class="tool-label">{{ tool }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<slot></slot>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import TencentMap from '@/components/map/TencentMap.vue'
|
||
// #ifdef H5
|
||
import ThreeMap from '@/components/map/ThreeMap.vue'
|
||
// #endif
|
||
import type {
|
||
GuideModelSource,
|
||
GuideRenderPoi
|
||
} from '@/domain/guideModel'
|
||
import type {
|
||
GuideRouteResult
|
||
} from '@/domain/museum'
|
||
import {
|
||
isIndoorNavigableFloor
|
||
} from '@/domain/guideFloor'
|
||
|
||
interface GuideFloorOption {
|
||
id: string
|
||
label: string
|
||
}
|
||
|
||
interface FloorSwitchEvent {
|
||
floorId: string
|
||
floorLabel: string
|
||
}
|
||
|
||
interface OutdoorNavPolyline {
|
||
points: Array<{ latitude: number; longitude: number }>
|
||
color: string
|
||
width: number
|
||
}
|
||
|
||
type CameraViewMode = 'reset' | 'top' | 'oblique'
|
||
|
||
interface TargetPoiFocusRequest {
|
||
requestId: number | string
|
||
poiId: string
|
||
name?: string
|
||
floorId: string
|
||
floorLabel?: string
|
||
primaryCategoryZh?: string
|
||
positionGltf?: [number, number, number]
|
||
sourceObjectName?: string
|
||
}
|
||
|
||
interface TargetPoiFocusResult {
|
||
requestId: number | string
|
||
poiId: string
|
||
floorId: string
|
||
status: 'focused' | 'missing' | 'error'
|
||
message?: string
|
||
}
|
||
|
||
type IndoorViewMode = 'overview' | 'floor' | 'multi'
|
||
type LayerDisplayMode = 'single' | 'multi'
|
||
type TouchGestureMode = 'orbit' | 'pan'
|
||
|
||
interface InitialModelProgressEvent {
|
||
progress: number
|
||
message: string
|
||
view: IndoorViewMode
|
||
floorId?: string
|
||
elapsedMs?: number
|
||
}
|
||
|
||
const props = withDefaults(defineProps<{
|
||
searchText?: string
|
||
activeMode?: '2d' | '3d'
|
||
activeFloor?: string
|
||
indoorView?: IndoorViewMode
|
||
indoorInitialView?: IndoorViewMode
|
||
layerMode?: LayerDisplayMode
|
||
searchTop?: string
|
||
floorTop?: string
|
||
floorBottom?: string
|
||
floorMaxHeight?: string
|
||
layerModeToggleTop?: string
|
||
toolsTop?: string
|
||
tools?: string[]
|
||
cameraView?: CameraViewMode
|
||
searchVariant?: 'default' | 'home'
|
||
showVoiceIcon?: boolean
|
||
showModeRow?: boolean
|
||
showFloorHeader?: boolean
|
||
showZoomControls?: boolean
|
||
showLocationControl?: boolean
|
||
showMoreControl?: boolean
|
||
toolsBottom?: string
|
||
floorSide?: 'left' | 'right'
|
||
zoomControlsTop?: string
|
||
moreControlBottom?: string
|
||
showSearch?: boolean
|
||
showFloor?: boolean
|
||
showLayerModeToggle?: boolean
|
||
modeTop?: string
|
||
modeLayout?: 'full' | 'status'
|
||
modeStatus?: string
|
||
modeStatusTone?: 'solid' | 'glass'
|
||
mapType?: 'indoor' | 'outdoor'
|
||
outdoorVariant?: 'home' | 'entrance'
|
||
indoorAssetBaseUrl?: string
|
||
indoorModelSource?: GuideModelSource
|
||
floors?: GuideFloorOption[]
|
||
normalizeFloorId?: (labelOrId: string) => string
|
||
dimmed?: boolean
|
||
targetFocusRequest?: TargetPoiFocusRequest | null
|
||
touchGestureMode?: TouchGestureMode
|
||
targetFocusDistanceFactor?: number
|
||
routePreview?: GuideRouteResult | null
|
||
showRoute?: boolean
|
||
disableAutoExit?: boolean
|
||
autoSwitchThresholdLow?: number
|
||
autoSwitchThresholdHigh?: number
|
||
outdoorNavPolylines?: OutdoorNavPolyline[]
|
||
}>(), {
|
||
searchText: '请输入地点进行搜索',
|
||
activeMode: '3d',
|
||
activeFloor: '1F',
|
||
indoorView: 'floor',
|
||
indoorInitialView: 'floor',
|
||
layerMode: 'single',
|
||
searchTop: '16px',
|
||
floorTop: '164px',
|
||
floorBottom: '',
|
||
floorMaxHeight: '',
|
||
layerModeToggleTop: '154px',
|
||
toolsTop: '406px',
|
||
toolsBottom: '',
|
||
tools: () => [] as string[],
|
||
cameraView: 'reset',
|
||
searchVariant: 'default',
|
||
showVoiceIcon: false,
|
||
showModeRow: true,
|
||
showFloorHeader: false,
|
||
showZoomControls: false,
|
||
showLocationControl: false,
|
||
showMoreControl: false,
|
||
floorSide: 'right',
|
||
zoomControlsTop: '520px',
|
||
moreControlBottom: '34px',
|
||
showSearch: true,
|
||
showFloor: true,
|
||
showLayerModeToggle: false,
|
||
modeTop: '60px',
|
||
modeLayout: 'full',
|
||
modeStatus: '',
|
||
modeStatusTone: 'solid',
|
||
mapType: 'indoor',
|
||
outdoorVariant: 'home',
|
||
indoorAssetBaseUrl: '',
|
||
indoorModelSource: undefined,
|
||
floors: () => [] as GuideFloorOption[],
|
||
normalizeFloorId: (labelOrId: string) => labelOrId,
|
||
dimmed: false,
|
||
targetFocusRequest: null,
|
||
touchGestureMode: 'orbit',
|
||
targetFocusDistanceFactor: 0.36,
|
||
routePreview: null,
|
||
showRoute: false,
|
||
disableAutoExit: false,
|
||
autoSwitchThresholdLow: 1.0,
|
||
autoSwitchThresholdHigh: 1.3,
|
||
outdoorNavPolylines: () => [] as OutdoorNavPolyline[]
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
searchTap: []
|
||
modeChange: [mode: '2d' | '3d']
|
||
floorRequest: [event: FloorSwitchEvent]
|
||
floorChange: [floor: string]
|
||
floorSwitchFailed: [event: FloorSwitchEvent]
|
||
toolClick: [tool: string]
|
||
moreClick: []
|
||
indoorViewChange: [view: IndoorViewMode]
|
||
layerModeChange: [mode: LayerDisplayMode]
|
||
poiClick: [poi: GuideRenderPoi]
|
||
selectionClear: []
|
||
targetFocus: [result: TargetPoiFocusResult]
|
||
autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }]
|
||
initialModelProgress: [event: InitialModelProgressEvent]
|
||
initialModelReady: [event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }]
|
||
initialModelFailed: [event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }]
|
||
mapTap: [location: { latitude: number; longitude: number }]
|
||
}>()
|
||
|
||
// 监听视角切换,循环切换:reset -> top -> oblique -> reset
|
||
watch(() => props.cameraView, (view) => {
|
||
if (props.mapType !== 'indoor') return
|
||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
||
|
||
if (view === 'reset') {
|
||
indoorRendererRef.value?.resetCamera?.()
|
||
} else if (view === 'top') {
|
||
indoorRendererRef.value?.setCameraPreset?.('top')
|
||
} else if (view === 'oblique') {
|
||
indoorRendererRef.value?.setCameraPreset?.('oblique')
|
||
}
|
||
})
|
||
|
||
const indoorRendererRef = ref<{
|
||
switchFloor?: (floorId: string) => Promise<void> | void
|
||
showOverview?: () => Promise<void> | void
|
||
showMultiFloor?: () => Promise<void> | void
|
||
resetCamera?: () => void
|
||
setCameraPreset?: (preset: 'top' | 'oblique') => void
|
||
zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
|
||
clearSelection?: (shouldEmit?: boolean) => void
|
||
clearRoute?: () => Promise<void> | void
|
||
disableAutoSwitchTemporarily?: (durationMs: number) => void
|
||
} | null>(null)
|
||
|
||
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
|
||
const floorItems = computed(() => indoorFloors.value)
|
||
// #ifdef H5
|
||
const effectiveIndoorModelSource = computed(() => props.indoorModelSource)
|
||
// #endif
|
||
const showIndoorRightControls = computed(() => (
|
||
props.mapType === 'indoor' && props.indoorView !== 'overview'
|
||
))
|
||
|
||
const activeFloorId = computed(() => {
|
||
const matchedFloor = indoorFloors.value.find((floor) => (
|
||
floor.id === props.activeFloor || floor.label === props.activeFloor
|
||
))
|
||
if (matchedFloor) return matchedFloor.id
|
||
|
||
const normalizedFloorId = props.normalizeFloorId(props.activeFloor)
|
||
const normalizedFloor = indoorFloors.value.find((floor) => (
|
||
floor.id === normalizedFloorId || floor.label === normalizedFloorId
|
||
))
|
||
return normalizedFloor?.id || normalizedFloorId
|
||
})
|
||
const requestedFloorId = ref('')
|
||
const requestedFloorLabel = ref('')
|
||
const loadingFloorId = ref('')
|
||
const renderedFloorId = ref(activeFloorId.value)
|
||
const activeFloorSwitchRequestSeq = ref(0)
|
||
const renderedFloorSwitchRequestSeq = ref(0)
|
||
const failedFloorId = ref('')
|
||
let floorSwitchRequestSeq = 0
|
||
|
||
const searchFieldStyle = computed(() => ({
|
||
top: props.searchTop
|
||
}))
|
||
|
||
const floorSwitcherStyle = computed(() => ({
|
||
...(props.floorBottom ? { bottom: props.floorBottom } : { top: props.floorTop })
|
||
}))
|
||
|
||
const layerModeToggleStyle = computed(() => ({
|
||
top: props.layerModeToggleTop
|
||
}))
|
||
|
||
const modeRowStyle = computed(() => ({
|
||
top: props.modeTop
|
||
}))
|
||
|
||
const toolStackStyle = computed(() => ({
|
||
...(props.toolsTop ? { top: props.toolsTop } : {}),
|
||
...(props.toolsBottom ? { bottom: props.toolsBottom } : {}),
|
||
left: '18px'
|
||
}))
|
||
|
||
const zoomControlsStyle = computed(() => ({
|
||
top: props.zoomControlsTop
|
||
}))
|
||
|
||
const moreControlStyle = computed(() => ({
|
||
bottom: props.moreControlBottom
|
||
}))
|
||
|
||
const shouldShowIndoorPois = computed(() => (
|
||
props.mapType === 'indoor'
|
||
) || Boolean(props.targetFocusRequest))
|
||
const nextLayerMode = computed<LayerDisplayMode>(() => (
|
||
props.layerMode === 'multi' ? 'single' : 'multi'
|
||
))
|
||
const layerModeActionLabel = computed(() => (
|
||
nextLayerMode.value === 'multi' ? '多层' : '单层'
|
||
))
|
||
|
||
const handleSearchTap = () => {
|
||
emit('searchTap')
|
||
}
|
||
|
||
const handleModeChange = (mode: '2d' | '3d') => {
|
||
emit('modeChange', mode)
|
||
}
|
||
|
||
const isStaleFloorSwitchError = (error: unknown) => (
|
||
error instanceof Error && error.message === 'STALE_MODEL_LOAD'
|
||
)
|
||
|
||
const findFloorItemById = (floorId: string) => (
|
||
floorItems.value.find((floor) => floor.id === floorId)
|
||
)
|
||
|
||
const isFloorSwitchDisabled = (floorId: string) => (
|
||
Boolean(loadingFloorId.value) && loadingFloorId.value !== floorId
|
||
)
|
||
|
||
const clearFloorLoadingIfCurrent = (floorId: string) => {
|
||
if (loadingFloorId.value === floorId) {
|
||
loadingFloorId.value = ''
|
||
}
|
||
}
|
||
|
||
const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number) => {
|
||
if (
|
||
loadingFloorId.value !== floorId
|
||
|| activeFloorSwitchRequestSeq.value !== requestSeq
|
||
|| renderedFloorSwitchRequestSeq.value === requestSeq
|
||
) return
|
||
|
||
failedFloorId.value = floorId
|
||
clearFloorLoadingIfCurrent(floorId)
|
||
emit('floorSwitchFailed', {
|
||
floorId,
|
||
floorLabel: requestedFloorId.value === floorId
|
||
? requestedFloorLabel.value || findFloorItemById(floorId)?.label || floorId
|
||
: findFloorItemById(floorId)?.label || floorId
|
||
})
|
||
}
|
||
|
||
const handleFloorChange = (floor: { id: string; label: string }) => {
|
||
const floorId = floor.id
|
||
if (!floorId || loadingFloorId.value) return
|
||
if (floorId === renderedFloorId.value && activeFloorId.value === floorId && props.layerMode !== 'multi') {
|
||
emit('floorChange', floorId)
|
||
emit('indoorViewChange', 'floor')
|
||
emit('layerModeChange', 'single')
|
||
return
|
||
}
|
||
|
||
requestedFloorId.value = floorId
|
||
requestedFloorLabel.value = floor.label
|
||
loadingFloorId.value = floorId
|
||
const requestSeq = ++floorSwitchRequestSeq
|
||
activeFloorSwitchRequestSeq.value = requestSeq
|
||
failedFloorId.value = ''
|
||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
||
emit('floorRequest', {
|
||
floorId,
|
||
floorLabel: floor.label
|
||
})
|
||
emit('indoorViewChange', 'floor')
|
||
emit('layerModeChange', 'single')
|
||
|
||
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
||
.then(() => {
|
||
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
|
||
})
|
||
.catch((error) => {
|
||
if (isStaleFloorSwitchError(error)) return
|
||
failedFloorId.value = floorId
|
||
clearFloorLoadingIfCurrent(floorId)
|
||
emit('floorSwitchFailed', {
|
||
floorId,
|
||
floorLabel: floor.label
|
||
})
|
||
console.error('楼层切换失败:', error)
|
||
})
|
||
}
|
||
|
||
const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
||
// 手动切换展示层数时临时禁用缩放自动切换 10 秒
|
||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
||
|
||
if (mode === 'multi') {
|
||
loadingFloorId.value = ''
|
||
requestedFloorId.value = ''
|
||
requestedFloorLabel.value = ''
|
||
void indoorRendererRef.value?.showMultiFloor?.()
|
||
emit('indoorViewChange', 'multi')
|
||
} else {
|
||
const floorId = activeFloorId.value
|
||
const floorLabel = findFloorItemById(floorId)?.label || floorId
|
||
if (!floorId) return
|
||
if (floorId === renderedFloorId.value && props.layerMode !== 'multi') {
|
||
emit('indoorViewChange', 'floor')
|
||
emit('layerModeChange', mode)
|
||
return
|
||
}
|
||
|
||
requestedFloorId.value = floorId
|
||
requestedFloorLabel.value = floorLabel
|
||
loadingFloorId.value = floorId
|
||
const requestSeq = ++floorSwitchRequestSeq
|
||
activeFloorSwitchRequestSeq.value = requestSeq
|
||
failedFloorId.value = ''
|
||
emit('floorRequest', {
|
||
floorId,
|
||
floorLabel
|
||
})
|
||
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
||
.then(() => {
|
||
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
|
||
})
|
||
.catch((error) => {
|
||
if (isStaleFloorSwitchError(error)) return
|
||
failedFloorId.value = floorId
|
||
clearFloorLoadingIfCurrent(floorId)
|
||
emit('floorSwitchFailed', {
|
||
floorId,
|
||
floorLabel
|
||
})
|
||
console.error('楼层切换失败:', error)
|
||
})
|
||
emit('indoorViewChange', 'floor')
|
||
}
|
||
|
||
emit('layerModeChange', mode)
|
||
}
|
||
|
||
const handleLayerModeToggle = () => {
|
||
handleLayerModeChange(nextLayerMode.value)
|
||
}
|
||
|
||
const handleFloorHeaderTap = () => {
|
||
handleLayerModeChange(props.layerMode === 'multi' ? 'single' : 'multi')
|
||
}
|
||
|
||
const handleToolClick = (tool: string) => {
|
||
if (props.mapType === 'indoor') {
|
||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
||
|
||
if (tool === '复位') {
|
||
indoorRendererRef.value?.resetCamera?.()
|
||
} else if (tool === '俯视') {
|
||
indoorRendererRef.value?.setCameraPreset?.('top')
|
||
} else if (tool === '斜视') {
|
||
indoorRendererRef.value?.setCameraPreset?.('oblique')
|
||
} else if (tool === '清除') {
|
||
indoorRendererRef.value?.clearSelection?.()
|
||
}
|
||
}
|
||
|
||
emit('toolClick', tool)
|
||
}
|
||
|
||
const toolIconType = (tool: string) => {
|
||
if (tool === '复位') return 'reset'
|
||
if (tool === '俯视') return 'top'
|
||
if (tool === '斜视') return 'oblique'
|
||
if (tool === '清除') return 'clear'
|
||
return 'text'
|
||
}
|
||
|
||
const handleZoomClick = (direction: 'in' | 'out') => {
|
||
indoorRendererRef.value?.zoomCamera?.(direction, { source: 'button' })
|
||
emit('toolClick', direction === 'in' ? '放大' : '缩小')
|
||
}
|
||
|
||
const handleMoreTap = () => {
|
||
emit('moreClick')
|
||
}
|
||
|
||
const handleThreeFloorChange = (floorId: string) => {
|
||
renderedFloorId.value = floorId
|
||
if (loadingFloorId.value === floorId) {
|
||
renderedFloorSwitchRequestSeq.value = activeFloorSwitchRequestSeq.value
|
||
}
|
||
if (failedFloorId.value === floorId) {
|
||
failedFloorId.value = ''
|
||
}
|
||
clearFloorLoadingIfCurrent(floorId)
|
||
emit('floorChange', floorId)
|
||
emit('indoorViewChange', 'floor')
|
||
emit('layerModeChange', 'single')
|
||
}
|
||
|
||
const handlePoiClick = (poi: GuideRenderPoi) => {
|
||
emit('poiClick', poi)
|
||
}
|
||
|
||
const handleSelectionClear = () => {
|
||
emit('selectionClear')
|
||
}
|
||
|
||
const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
||
emit('targetFocus', result)
|
||
}
|
||
|
||
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
|
||
emit('autoSwitch', event)
|
||
}
|
||
|
||
const handleInitialModelProgress = (event: InitialModelProgressEvent) => {
|
||
emit('initialModelProgress', event)
|
||
}
|
||
|
||
const handleInitialModelReady = (event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }) => {
|
||
emit('initialModelReady', event)
|
||
}
|
||
|
||
const handleInitialModelFailed = (event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }) => {
|
||
emit('initialModelFailed', event)
|
||
}
|
||
|
||
const handleMapTap = (location: { latitude?: number; longitude?: number }) => {
|
||
const { latitude, longitude } = location
|
||
if (latitude !== undefined && longitude !== undefined) {
|
||
emit('mapTap', { latitude, longitude })
|
||
}
|
||
}
|
||
|
||
// 暴露 clearRoute 方法供父组件显式调用路线清除
|
||
defineExpose({
|
||
clearRoute: () => {
|
||
indoorRendererRef.value?.clearRoute?.()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.guide-map-shell {
|
||
position: relative;
|
||
z-index: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: #ffffff;
|
||
color: #000000;
|
||
}
|
||
|
||
.map-layer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
overflow: hidden;
|
||
background: var(--museum-bg-map);
|
||
border: 0;
|
||
box-sizing: border-box;
|
||
z-index: 0;
|
||
}
|
||
|
||
.indoor-three-map,
|
||
.outdoor-map {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.dim-layer {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.28);
|
||
z-index: 2;
|
||
}
|
||
|
||
.outdoor-map-bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
background: #f6f8f5;
|
||
}
|
||
|
||
.indoor-platform-fallback {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 24px;
|
||
box-sizing: border-box;
|
||
background: #f1f3ee;
|
||
text-align: center;
|
||
}
|
||
|
||
.indoor-platform-title {
|
||
font-size: 18px;
|
||
line-height: 24px;
|
||
font-weight: 700;
|
||
color: #1f2329;
|
||
}
|
||
|
||
.indoor-platform-desc {
|
||
max-width: 260px;
|
||
font-size: 13px;
|
||
line-height: 20px;
|
||
color: #626a73;
|
||
}
|
||
|
||
.guide-search-field {
|
||
position: absolute;
|
||
left: 16px;
|
||
right: 16px;
|
||
height: 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 10px;
|
||
box-sizing: border-box;
|
||
background: var(--museum-bg-cream);
|
||
border: 1px solid #ffffff;
|
||
border-radius: 6px;
|
||
z-index: 40;
|
||
}
|
||
|
||
.guide-search-field.variant-home {
|
||
left: 20px;
|
||
right: 20px;
|
||
height: 46px;
|
||
padding: 0 14px;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
border: 1px solid #d7dad3;
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.08);
|
||
}
|
||
|
||
.search-field-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 14px;
|
||
line-height: 19px;
|
||
color: #8b8b84;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.variant-home .search-field-text {
|
||
padding: 0 10px;
|
||
font-size: 15px;
|
||
line-height: 22px;
|
||
color: #8b9097;
|
||
}
|
||
|
||
.search-icon {
|
||
position: relative;
|
||
width: 16px;
|
||
height: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.search-icon.leading {
|
||
width: 22px;
|
||
height: 22px;
|
||
}
|
||
|
||
.search-icon.leading .search-icon-circle {
|
||
top: 2px;
|
||
left: 1px;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-width: 2px;
|
||
}
|
||
|
||
.search-icon.leading .search-icon-handle {
|
||
left: 14px;
|
||
top: 15px;
|
||
width: 10px;
|
||
height: 2px;
|
||
}
|
||
|
||
.voice-icon {
|
||
position: relative;
|
||
width: 22px;
|
||
height: 24px;
|
||
flex: 0 0 22px;
|
||
}
|
||
|
||
.voice-head {
|
||
position: absolute;
|
||
left: 6px;
|
||
top: 1px;
|
||
width: 10px;
|
||
height: 15px;
|
||
box-sizing: border-box;
|
||
border: 2px solid #151713;
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.voice-stem {
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 15px;
|
||
width: 2px;
|
||
height: 6px;
|
||
background: #151713;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.voice-base {
|
||
position: absolute;
|
||
left: 5px;
|
||
bottom: 0;
|
||
width: 12px;
|
||
height: 2px;
|
||
background: #151713;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.search-icon-circle {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 1px;
|
||
width: 9px;
|
||
height: 9px;
|
||
border: 1.4px solid #1f2329;
|
||
border-radius: 50%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.search-icon-handle {
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 10px;
|
||
width: 8px;
|
||
height: 1.4px;
|
||
background: #1f2329;
|
||
border-radius: 2px;
|
||
transform: rotate(45deg);
|
||
transform-origin: left center;
|
||
}
|
||
|
||
.guide-mode-row {
|
||
position: absolute;
|
||
left: 16px;
|
||
right: 16px;
|
||
height: 34px;
|
||
z-index: 40;
|
||
}
|
||
|
||
.guide-mode-row.layout-full {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 3px;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #e6e6dd;
|
||
border-radius: 17px;
|
||
}
|
||
|
||
.guide-mode-row.layout-status {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.compact-mode-switch {
|
||
width: 220px;
|
||
height: 34px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 4px;
|
||
box-sizing: border-box;
|
||
background: rgba(255, 255, 255, 0.72);
|
||
border: 1px solid rgba(230, 230, 230, 0.5);
|
||
border-radius: 17px;
|
||
}
|
||
|
||
.mode-option {
|
||
flex: 1;
|
||
height: 26px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 13px;
|
||
}
|
||
|
||
.layout-status .mode-option {
|
||
width: 106px;
|
||
flex: 0 0 106px;
|
||
}
|
||
|
||
.mode-option.active {
|
||
background: #000000;
|
||
}
|
||
|
||
.mode-label {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
font-weight: 500;
|
||
color: #1f2329;
|
||
}
|
||
|
||
.mode-option.active .mode-label {
|
||
color: var(--museum-accent);
|
||
}
|
||
|
||
.mode-status-chip {
|
||
width: 111px;
|
||
height: 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #e6e6e6;
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.mode-status-chip.tone-glass {
|
||
background: rgba(255, 255, 255, 0.42);
|
||
border-color: transparent;
|
||
}
|
||
|
||
.mode-status-text {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
font-weight: 500;
|
||
color: #696962;
|
||
}
|
||
|
||
.floor-switcher {
|
||
position: absolute;
|
||
width: 44px;
|
||
height: auto;
|
||
padding: 1px;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
background: rgba(255, 255, 255, 0.84);
|
||
border: 1px solid #dde5df;
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||
z-index: 35;
|
||
}
|
||
|
||
.floor-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: visible;
|
||
}
|
||
|
||
.floor-switcher.side-right {
|
||
right: 16px;
|
||
}
|
||
|
||
.floor-switcher.side-left {
|
||
left: 18px;
|
||
}
|
||
|
||
.floor-header {
|
||
position: relative;
|
||
min-height: 38px;
|
||
padding: 5px 4px 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 3px;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border-radius: 8px 8px 0 0;
|
||
}
|
||
|
||
.floor-header.active {
|
||
background: #000000;
|
||
}
|
||
|
||
.floor-header::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 7px;
|
||
right: 7px;
|
||
bottom: 0;
|
||
height: 1px;
|
||
background: #ecede7;
|
||
}
|
||
|
||
.floor-header-icon {
|
||
width: 16px;
|
||
height: 16px;
|
||
position: relative;
|
||
display: block;
|
||
color: #151713;
|
||
font-size: 0;
|
||
line-height: 0;
|
||
}
|
||
|
||
.floor-header-icon::before,
|
||
.floor-header-icon::after {
|
||
content: '';
|
||
position: absolute;
|
||
box-sizing: border-box;
|
||
border: 1.8px solid currentColor;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.floor-header-icon::before {
|
||
left: 2px;
|
||
top: 4px;
|
||
width: 11px;
|
||
height: 9px;
|
||
}
|
||
|
||
.floor-header-icon::after {
|
||
left: 5px;
|
||
top: 1px;
|
||
width: 11px;
|
||
height: 9px;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.floor-header.active .floor-header-icon {
|
||
color: var(--museum-accent);
|
||
}
|
||
|
||
.floor-header-label {
|
||
font-size: 10px;
|
||
line-height: 12px;
|
||
color: #545861;
|
||
}
|
||
|
||
.floor-header.active .floor-header-label {
|
||
color: var(--museum-accent);
|
||
}
|
||
|
||
.layer-mode-toggle {
|
||
position: absolute;
|
||
right: 16px;
|
||
width: 46px;
|
||
height: 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #dde5df;
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||
z-index: 36;
|
||
}
|
||
|
||
.layer-mode-toggle:active {
|
||
background: #f5f7f2;
|
||
}
|
||
|
||
.layer-mode-label {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
font-weight: 500;
|
||
color: #545861;
|
||
}
|
||
|
||
.floor-item {
|
||
position: relative;
|
||
height: 30px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.floor-item:first-child {
|
||
border-radius: 7px 7px 0 0;
|
||
}
|
||
|
||
.floor-item:last-child {
|
||
border-radius: 0 0 7px 7px;
|
||
}
|
||
|
||
.floor-item:not(:last-child)::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 7px;
|
||
right: 7px;
|
||
bottom: 0;
|
||
height: 1px;
|
||
background: #ecede7;
|
||
}
|
||
|
||
.floor-item.active {
|
||
background: #000000;
|
||
}
|
||
|
||
.floor-item.pending {
|
||
pointer-events: none;
|
||
opacity: 0.72;
|
||
}
|
||
|
||
.floor-item.pending::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 5px;
|
||
border: 1px solid rgba(224, 225, 0, 0.9);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.floor-item.disabled {
|
||
pointer-events: none;
|
||
opacity: 0.46;
|
||
}
|
||
|
||
.floor-item.failed:not(.pending) .floor-label {
|
||
color: #b84a4a;
|
||
}
|
||
|
||
.floor-label {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
font-weight: 500;
|
||
color: #545861;
|
||
}
|
||
|
||
.floor-item.active .floor-label {
|
||
color: var(--museum-accent);
|
||
}
|
||
|
||
.tool-stack {
|
||
position: absolute;
|
||
left: 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
z-index: 35;
|
||
}
|
||
|
||
.tool-btn {
|
||
width: 44px;
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 1px solid #e4e5df;
|
||
border-radius: 8px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.tool-btn:active {
|
||
background: #f6f7f4;
|
||
transform: translateY(1px);
|
||
}
|
||
|
||
.tool-icon-svg {
|
||
width: 22px;
|
||
height: 22px;
|
||
stroke: #151713;
|
||
stroke-width: 1.8;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.tool-label {
|
||
font-size: 12px;
|
||
line-height: 16px;
|
||
font-weight: 500;
|
||
color: #1f2329;
|
||
}
|
||
|
||
.zoom-controls {
|
||
position: absolute;
|
||
right: 18px;
|
||
width: 48px;
|
||
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 {
|
||
height: 48px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.zoom-divider {
|
||
height: 1px;
|
||
margin: 0 9px;
|
||
background: #ecede7;
|
||
}
|
||
|
||
.zoom-text {
|
||
font-size: 28px;
|
||
line-height: 32px;
|
||
font-weight: 300;
|
||
color: #151713;
|
||
}
|
||
|
||
.location-control,
|
||
.more-control {
|
||
position: absolute;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border: 1px solid #dde5df;
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||
z-index: 35;
|
||
}
|
||
|
||
.location-control {
|
||
left: 18px;
|
||
width: 48px;
|
||
height: 48px;
|
||
}
|
||
|
||
.location-ring {
|
||
width: 20px;
|
||
height: 20px;
|
||
box-sizing: border-box;
|
||
border: 2px solid #151713;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.location-ring::before,
|
||
.location-ring::after {
|
||
content: '';
|
||
position: absolute;
|
||
background: #151713;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.location-ring::before {
|
||
left: 23px;
|
||
top: 10px;
|
||
width: 2px;
|
||
height: 28px;
|
||
}
|
||
|
||
.location-ring::after {
|
||
left: 10px;
|
||
top: 23px;
|
||
width: 28px;
|
||
height: 2px;
|
||
}
|
||
|
||
.location-dot {
|
||
position: absolute;
|
||
width: 6px;
|
||
height: 6px;
|
||
background: #151713;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.more-control {
|
||
right: 18px;
|
||
min-width: 54px;
|
||
height: 36px;
|
||
padding: 0 12px;
|
||
}
|
||
|
||
.more-control-text {
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
font-weight: 500;
|
||
color: #151713;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.guide-map-shell {
|
||
max-width: 430px;
|
||
margin: 0 auto;
|
||
box-shadow: 0 12px 36px rgba(36, 49, 42, 0.16);
|
||
}
|
||
}
|
||
</style>
|