@@ -48,6 +48,7 @@
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
|
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
|
||||||
|
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||||
import FloorSwitcher from './FloorSwitcher.vue'
|
import FloorSwitcher from './FloorSwitcher.vue'
|
||||||
import type {
|
import type {
|
||||||
@@ -194,6 +195,7 @@ let camera: THREE.PerspectiveCamera | null = null
|
|||||||
let renderer: THREE.WebGLRenderer | null = null
|
let renderer: THREE.WebGLRenderer | null = null
|
||||||
let controls: OrbitControls | null = null
|
let controls: OrbitControls | null = null
|
||||||
let loader: GLTFLoader | null = null
|
let loader: GLTFLoader | null = null
|
||||||
|
let dracoLoader: DRACOLoader | null = null
|
||||||
let activeModel: THREE.Object3D | null = null
|
let activeModel: THREE.Object3D | null = null
|
||||||
let poiGroup: THREE.Group | null = null
|
let poiGroup: THREE.Group | null = null
|
||||||
let routeGroup: THREE.Group | null = null
|
let routeGroup: THREE.Group | null = null
|
||||||
@@ -682,6 +684,9 @@ const initThree = async () => {
|
|||||||
syncControlInteractionOptions()
|
syncControlInteractionOptions()
|
||||||
|
|
||||||
loader = new GLTFLoader()
|
loader = new GLTFLoader()
|
||||||
|
dracoLoader = new DRACOLoader()
|
||||||
|
dracoLoader.setDecoderPath('/static/three/draco/')
|
||||||
|
loader.setDRACOLoader(dracoLoader)
|
||||||
poiGroup = new THREE.Group()
|
poiGroup = new THREE.Group()
|
||||||
poiGroup.name = 'GuideModelPOI'
|
poiGroup.name = 'GuideModelPOI'
|
||||||
scene.add(poiGroup)
|
scene.add(poiGroup)
|
||||||
@@ -2298,6 +2303,8 @@ const disposeScene = () => {
|
|||||||
scene = null
|
scene = null
|
||||||
camera = null
|
camera = null
|
||||||
loader = null
|
loader = null
|
||||||
|
dracoLoader?.dispose()
|
||||||
|
dracoLoader = null
|
||||||
poiGroup = null
|
poiGroup = null
|
||||||
routeGroup = null
|
routeGroup = null
|
||||||
|
|
||||||
@@ -2321,6 +2328,7 @@ defineExpose({
|
|||||||
queueTargetFocus(request)
|
queueTargetFocus(request)
|
||||||
},
|
},
|
||||||
clearSelection: clearMapSelection,
|
clearSelection: clearMapSelection,
|
||||||
|
clearRoute: clearRoutePreview,
|
||||||
clearNavigation: () => clearMapSelection(false),
|
clearNavigation: () => clearMapSelection(false),
|
||||||
disableAutoSwitchTemporarily
|
disableAutoSwitchTemporarily
|
||||||
})
|
})
|
||||||
@@ -2346,6 +2354,12 @@ watch(() => [props.routePreview, props.showRoute], () => {
|
|||||||
deep: true
|
deep: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => props.initialView, (newView) => {
|
||||||
|
if (newView === 'floor' || newView === 'multi') {
|
||||||
|
activeView.value = newView
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
init3DScene()
|
init3DScene()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,25 +3,14 @@
|
|||||||
<view class="map-layer" :class="`map-${mapType}`">
|
<view class="map-layer" :class="`map-${mapType}`">
|
||||||
<template v-if="mapType === 'indoor'">
|
<template v-if="mapType === 'indoor'">
|
||||||
<!-- #ifdef H5 -->
|
<!-- #ifdef H5 -->
|
||||||
<SgsMapRenderer
|
|
||||||
v-if="useSgsMapRenderer"
|
|
||||||
ref="indoorRendererRef"
|
|
||||||
class="indoor-three-map"
|
|
||||||
:initial-floor-id="activeFloorId"
|
|
||||||
:initial-view="indoorInitialView"
|
|
||||||
:floors="props.floors"
|
|
||||||
:target-focus="targetFocusRequest"
|
|
||||||
@target-focus="handleTargetFocus"
|
|
||||||
@floor-change="handleSdkFloorChange"
|
|
||||||
/>
|
|
||||||
<ThreeMap
|
<ThreeMap
|
||||||
v-else-if="indoorModelSource"
|
v-if="effectiveIndoorModelSource"
|
||||||
ref="indoorRendererRef"
|
ref="indoorRendererRef"
|
||||||
class="indoor-three-map"
|
class="indoor-three-map"
|
||||||
:asset-base-url="indoorAssetBaseUrl"
|
:asset-base-url="indoorAssetBaseUrl"
|
||||||
:model-source="indoorModelSource"
|
:model-source="effectiveIndoorModelSource"
|
||||||
:initial-floor-id="activeFloorId"
|
:initial-floor-id="activeFloorId"
|
||||||
:initial-view="indoorInitialView"
|
:initial-view="indoorView"
|
||||||
:show-controls="false"
|
:show-controls="false"
|
||||||
:show-poi="shouldShowIndoorPois"
|
:show-poi="shouldShowIndoorPois"
|
||||||
:target-focus="targetFocusRequest"
|
:target-focus="targetFocusRequest"
|
||||||
@@ -50,6 +39,8 @@
|
|||||||
class="outdoor-map"
|
class="outdoor-map"
|
||||||
:active-floor="activeFloor"
|
:active-floor="activeFloor"
|
||||||
:floors="props.floors"
|
:floors="props.floors"
|
||||||
|
:polylines="outdoorNavPolylines"
|
||||||
|
@map-tap="handleMapTap"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="dimmed" class="dim-layer"></view>
|
<view v-if="dimmed" class="dim-layer"></view>
|
||||||
@@ -170,11 +161,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="showLocationControl" class="location-control" :style="locationControlStyle" @tap="handleResetView">
|
|
||||||
<view class="location-ring"></view>
|
|
||||||
<view class="location-dot"></view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="showMoreControl" class="more-control" :style="moreControlStyle" @tap="handleMoreTap">
|
<view v-if="showMoreControl" class="more-control" :style="moreControlStyle" @tap="handleMoreTap">
|
||||||
<text class="more-control-text">更多</text>
|
<text class="more-control-text">更多</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -195,14 +181,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import TencentMap from '@/components/map/TencentMap.vue'
|
import TencentMap from '@/components/map/TencentMap.vue'
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
import ThreeMap from '@/components/map/ThreeMap.vue'
|
import ThreeMap from '@/components/map/ThreeMap.vue'
|
||||||
import SgsMapRenderer from '@/components/map/SgsMapRenderer.vue'
|
|
||||||
import {
|
|
||||||
isSgsSdkMode
|
|
||||||
} from '@/config/dataSource'
|
|
||||||
// #endif
|
// #endif
|
||||||
import type {
|
import type {
|
||||||
GuideModelSource,
|
GuideModelSource,
|
||||||
@@ -217,6 +199,14 @@ interface GuideFloorOption {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OutdoorNavPolyline {
|
||||||
|
points: Array<{ latitude: number; longitude: number }>
|
||||||
|
color: string
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type CameraViewMode = 'reset' | 'top' | 'oblique'
|
||||||
|
|
||||||
interface TargetPoiFocusRequest {
|
interface TargetPoiFocusRequest {
|
||||||
requestId: number | string
|
requestId: number | string
|
||||||
poiId: string
|
poiId: string
|
||||||
@@ -253,6 +243,7 @@ const props = withDefaults(defineProps<{
|
|||||||
layerModeToggleTop?: string
|
layerModeToggleTop?: string
|
||||||
toolsTop?: string
|
toolsTop?: string
|
||||||
tools?: string[]
|
tools?: string[]
|
||||||
|
cameraView?: CameraViewMode
|
||||||
searchVariant?: 'default' | 'home'
|
searchVariant?: 'default' | 'home'
|
||||||
showVoiceIcon?: boolean
|
showVoiceIcon?: boolean
|
||||||
showModeRow?: boolean
|
showModeRow?: boolean
|
||||||
@@ -260,9 +251,9 @@ const props = withDefaults(defineProps<{
|
|||||||
showZoomControls?: boolean
|
showZoomControls?: boolean
|
||||||
showLocationControl?: boolean
|
showLocationControl?: boolean
|
||||||
showMoreControl?: boolean
|
showMoreControl?: boolean
|
||||||
|
toolsBottom?: string
|
||||||
floorSide?: 'left' | 'right'
|
floorSide?: 'left' | 'right'
|
||||||
zoomControlsTop?: string
|
zoomControlsTop?: string
|
||||||
locationControlBottom?: string
|
|
||||||
moreControlBottom?: string
|
moreControlBottom?: string
|
||||||
showSearch?: boolean
|
showSearch?: boolean
|
||||||
showFloor?: boolean
|
showFloor?: boolean
|
||||||
@@ -285,6 +276,7 @@ const props = withDefaults(defineProps<{
|
|||||||
showRoute?: boolean
|
showRoute?: boolean
|
||||||
autoSwitchThresholdLow?: number
|
autoSwitchThresholdLow?: number
|
||||||
autoSwitchThresholdHigh?: number
|
autoSwitchThresholdHigh?: number
|
||||||
|
outdoorNavPolylines?: OutdoorNavPolyline[]
|
||||||
}>(), {
|
}>(), {
|
||||||
searchText: '请输入地点进行搜索',
|
searchText: '请输入地点进行搜索',
|
||||||
activeMode: '3d',
|
activeMode: '3d',
|
||||||
@@ -297,7 +289,9 @@ const props = withDefaults(defineProps<{
|
|||||||
floorMaxHeight: '',
|
floorMaxHeight: '',
|
||||||
layerModeToggleTop: '154px',
|
layerModeToggleTop: '154px',
|
||||||
toolsTop: '406px',
|
toolsTop: '406px',
|
||||||
|
toolsBottom: '',
|
||||||
tools: () => [] as string[],
|
tools: () => [] as string[],
|
||||||
|
cameraView: 'reset',
|
||||||
searchVariant: 'default',
|
searchVariant: 'default',
|
||||||
showVoiceIcon: false,
|
showVoiceIcon: false,
|
||||||
showModeRow: true,
|
showModeRow: true,
|
||||||
@@ -307,7 +301,6 @@ const props = withDefaults(defineProps<{
|
|||||||
showMoreControl: false,
|
showMoreControl: false,
|
||||||
floorSide: 'right',
|
floorSide: 'right',
|
||||||
zoomControlsTop: '520px',
|
zoomControlsTop: '520px',
|
||||||
locationControlBottom: '34px',
|
|
||||||
moreControlBottom: '34px',
|
moreControlBottom: '34px',
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
showFloor: true,
|
showFloor: true,
|
||||||
@@ -329,7 +322,8 @@ const props = withDefaults(defineProps<{
|
|||||||
routePreview: null,
|
routePreview: null,
|
||||||
showRoute: false,
|
showRoute: false,
|
||||||
autoSwitchThresholdLow: 1.0,
|
autoSwitchThresholdLow: 1.0,
|
||||||
autoSwitchThresholdHigh: 1.3
|
autoSwitchThresholdHigh: 1.3,
|
||||||
|
outdoorNavPolylines: () => [] as OutdoorNavPolyline[]
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -344,8 +338,23 @@ const emit = defineEmits<{
|
|||||||
selectionClear: []
|
selectionClear: []
|
||||||
targetFocus: [result: TargetPoiFocusResult]
|
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 }]
|
||||||
|
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<{
|
const indoorRendererRef = ref<{
|
||||||
switchFloor?: (floorId: string) => Promise<void> | void
|
switchFloor?: (floorId: string) => Promise<void> | void
|
||||||
showOverview?: () => Promise<void> | void
|
showOverview?: () => Promise<void> | void
|
||||||
@@ -354,19 +363,30 @@ const indoorRendererRef = ref<{
|
|||||||
setCameraPreset?: (preset: 'top' | 'oblique') => void
|
setCameraPreset?: (preset: 'top' | 'oblique') => void
|
||||||
zoomCamera?: (direction: 'in' | 'out') => void
|
zoomCamera?: (direction: 'in' | 'out') => void
|
||||||
clearSelection?: (shouldEmit?: boolean) => void
|
clearSelection?: (shouldEmit?: boolean) => void
|
||||||
|
clearRoute?: () => Promise<void> | void
|
||||||
disableAutoSwitchTemporarily?: (durationMs: number) => void
|
disableAutoSwitchTemporarily?: (durationMs: number) => void
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const floorLabels = computed(() => props.floors.map((floor) => floor.label))
|
const floorLabels = computed(() => props.floors.map((floor) => floor.label))
|
||||||
const indoorModelSource = computed(() => props.indoorModelSource)
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
const useSgsMapRenderer = computed(() => isSgsSdkMode())
|
const effectiveIndoorModelSource = computed(() => props.indoorModelSource)
|
||||||
// #endif
|
// #endif
|
||||||
const showIndoorRightControls = computed(() => (
|
const showIndoorRightControls = computed(() => (
|
||||||
props.mapType === 'indoor' && props.indoorView !== 'overview'
|
props.mapType === 'indoor' && props.indoorView !== 'overview'
|
||||||
))
|
))
|
||||||
|
|
||||||
const activeFloorId = computed(() => props.normalizeFloorId(props.activeFloor))
|
const activeFloorId = computed(() => {
|
||||||
|
const matchedFloor = props.floors.find((floor) => (
|
||||||
|
floor.id === props.activeFloor || floor.label === props.activeFloor
|
||||||
|
))
|
||||||
|
if (matchedFloor) return matchedFloor.id
|
||||||
|
|
||||||
|
const normalizedFloorId = props.normalizeFloorId(props.activeFloor)
|
||||||
|
const normalizedFloor = props.floors.find((floor) => (
|
||||||
|
floor.id === normalizedFloorId || floor.label === normalizedFloorId
|
||||||
|
))
|
||||||
|
return normalizedFloor?.id || normalizedFloorId
|
||||||
|
})
|
||||||
|
|
||||||
const searchFieldStyle = computed(() => ({
|
const searchFieldStyle = computed(() => ({
|
||||||
top: props.searchTop
|
top: props.searchTop
|
||||||
@@ -386,17 +406,15 @@ const modeRowStyle = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const toolStackStyle = computed(() => ({
|
const toolStackStyle = computed(() => ({
|
||||||
top: props.toolsTop
|
...(props.toolsTop ? { top: props.toolsTop } : {}),
|
||||||
|
...(props.toolsBottom ? { bottom: props.toolsBottom } : {}),
|
||||||
|
left: '18px'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const zoomControlsStyle = computed(() => ({
|
const zoomControlsStyle = computed(() => ({
|
||||||
top: props.zoomControlsTop
|
top: props.zoomControlsTop
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const locationControlStyle = computed(() => ({
|
|
||||||
bottom: props.locationControlBottom
|
|
||||||
}))
|
|
||||||
|
|
||||||
const moreControlStyle = computed(() => ({
|
const moreControlStyle = computed(() => ({
|
||||||
bottom: props.moreControlBottom
|
bottom: props.moreControlBottom
|
||||||
}))
|
}))
|
||||||
@@ -469,11 +487,6 @@ const handleToolClick = (tool: string) => {
|
|||||||
emit('toolClick', tool)
|
emit('toolClick', tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetView = () => {
|
|
||||||
indoorRendererRef.value?.resetCamera?.()
|
|
||||||
emit('toolClick', '重置')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleZoomClick = (direction: 'in' | 'out') => {
|
const handleZoomClick = (direction: 'in' | 'out') => {
|
||||||
indoorRendererRef.value?.zoomCamera?.(direction)
|
indoorRendererRef.value?.zoomCamera?.(direction)
|
||||||
emit('toolClick', direction === 'in' ? '放大' : '缩小')
|
emit('toolClick', direction === 'in' ? '放大' : '缩小')
|
||||||
@@ -502,16 +515,23 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
|||||||
emit('targetFocus', result)
|
emit('targetFocus', result)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSdkFloorChange = (floorId: string) => {
|
|
||||||
const floor = props.floors.find((item) => item.id === floorId)
|
|
||||||
if (floor) {
|
|
||||||
emit('floorChange', floor.label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }) => {
|
||||||
emit('autoSwitch', event)
|
emit('autoSwitch', 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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -958,7 +978,7 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
|||||||
|
|
||||||
.tool-stack {
|
.tool-stack {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 16px;
|
left: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
<view v-if="startPoint && endPoint" class="route-options">
|
<view v-if="startPoint && endPoint" class="route-options">
|
||||||
<view class="route-options-header">
|
<view class="route-options-header">
|
||||||
<text class="route-options-title">线路预览</text>
|
<text class="route-options-title">{{ routeOptionsTitle }}</text>
|
||||||
<text class="route-options-note">偏好选择</text>
|
<text class="route-options-note">偏好选择</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="route-option-list">
|
<view class="route-option-list">
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loading || error || summary" class="panel-status">
|
<view v-if="loading || error || summary" class="panel-status">
|
||||||
<text v-if="loading" class="panel-status-text">线路预览生成中</text>
|
<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-if="error" class="panel-status-text error">{{ error }}</text>
|
||||||
<text v-else class="panel-status-text">{{ summary }}</text>
|
<text v-else class="panel-status-text">{{ summary }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -136,6 +136,7 @@ const props = withDefaults(defineProps<{
|
|||||||
pickerLoading?: boolean
|
pickerLoading?: boolean
|
||||||
pickerError?: string
|
pickerError?: string
|
||||||
pickerEmptyText?: string
|
pickerEmptyText?: string
|
||||||
|
routeReady?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
visible: true,
|
visible: true,
|
||||||
startPoint: null,
|
startPoint: null,
|
||||||
@@ -147,7 +148,8 @@ const props = withDefaults(defineProps<{
|
|||||||
summary: '',
|
summary: '',
|
||||||
pickerLoading: false,
|
pickerLoading: false,
|
||||||
pickerError: '',
|
pickerError: '',
|
||||||
pickerEmptyText: '暂无匹配地点'
|
pickerEmptyText: '暂无匹配地点',
|
||||||
|
routeReady: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -194,7 +196,15 @@ const canViewRoute = computed(() => Boolean(
|
|||||||
const canPrimaryAction = computed(() => canViewRoute.value && !props.error)
|
const canPrimaryAction = computed(() => canViewRoute.value && !props.error)
|
||||||
|
|
||||||
const primaryActionText = computed(() => (
|
const primaryActionText = computed(() => (
|
||||||
props.hasRoutePreview ? '模拟导览' : '线路预览'
|
props.hasRoutePreview
|
||||||
|
? '开始导览'
|
||||||
|
: (props.routeReady ? '查看路线' : '查看位置关系')
|
||||||
|
))
|
||||||
|
|
||||||
|
const routeOptionsTitle = computed(() => '室内导览')
|
||||||
|
|
||||||
|
const loadingText = computed(() => (
|
||||||
|
props.routeReady ? '正在规划室内导览路线' : '正在生成室内位置关系'
|
||||||
))
|
))
|
||||||
|
|
||||||
const collapsedSummary = computed(() => {
|
const collapsedSummary = computed(() => {
|
||||||
@@ -207,26 +217,32 @@ const collapsedSummary = computed(() => {
|
|||||||
|
|
||||||
const routeOptions = computed(() => {
|
const routeOptions = computed(() => {
|
||||||
const unavailable = Boolean(props.error)
|
const unavailable = Boolean(props.error)
|
||||||
const summaryText = props.summary || '选择后查看位置关系'
|
const summaryText = props.summary || (props.routeReady ? '选择后规划真实路线' : '选择后查看位置关系')
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'recommended',
|
id: 'recommended',
|
||||||
title: '推荐路线',
|
title: '推荐路线',
|
||||||
meta: unavailable ? '当前为路线示意状态' : props.hasRoutePreview ? summaryText : '优先展示位置关系',
|
meta: unavailable
|
||||||
|
? '当前暂不可用'
|
||||||
|
: props.hasRoutePreview
|
||||||
|
? summaryText
|
||||||
|
: props.routeReady
|
||||||
|
? '调用 SGS SDK 路线规划'
|
||||||
|
: '查看起点终点位置关系',
|
||||||
active: !unavailable,
|
active: !unavailable,
|
||||||
disabled: unavailable
|
disabled: unavailable
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stairs',
|
id: 'stairs',
|
||||||
title: '少走楼梯',
|
title: '少走楼梯',
|
||||||
meta: '偏好预览,待路线数据验证',
|
meta: props.routeReady ? '偏好能力待接入' : '偏好预览,待路线数据验证',
|
||||||
active: false,
|
active: false,
|
||||||
disabled: true
|
disabled: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'elevator',
|
id: 'elevator',
|
||||||
title: '电梯优先',
|
title: '电梯优先',
|
||||||
meta: '偏好预览,待路线数据验证',
|
meta: props.routeReady ? '偏好能力待接入' : '偏好预览,待路线数据验证',
|
||||||
active: false,
|
active: false,
|
||||||
disabled: true
|
disabled: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
>
|
>
|
||||||
<GuideMapShell
|
<GuideMapShell
|
||||||
v-if="currentTab === 'guide'"
|
v-if="currentTab === 'guide'"
|
||||||
|
ref="guideMapShellRef"
|
||||||
:search-text="guideSearchText"
|
:search-text="guideSearchText"
|
||||||
:active-mode="guideShellMode"
|
:active-mode="guideShellMode"
|
||||||
:mode-layout="'status'"
|
:mode-layout="'status'"
|
||||||
@@ -35,18 +36,18 @@
|
|||||||
show-floor-header
|
show-floor-header
|
||||||
:show-layer-mode-toggle="false"
|
:show-layer-mode-toggle="false"
|
||||||
:show-floor="is3DMode"
|
:show-floor="is3DMode"
|
||||||
tools-top="calc(50% - 126px)"
|
tools-bottom="54px"
|
||||||
show-zoom-controls
|
show-zoom-controls
|
||||||
zoom-controls-top="calc(100vh - 284px)"
|
zoom-controls-top="calc(100vh - 284px)"
|
||||||
show-location-control
|
|
||||||
location-control-bottom="54px"
|
|
||||||
show-more-control
|
show-more-control
|
||||||
more-control-bottom="54px"
|
more-control-bottom="54px"
|
||||||
:tools="guide3DTools"
|
:tools="guide3DTools"
|
||||||
|
:camera-view="guideCameraView"
|
||||||
:route-preview="activeRoutePreview"
|
:route-preview="activeRoutePreview"
|
||||||
:show-route="Boolean(activeRoutePreview)"
|
:show-route="Boolean(activeRoutePreview)"
|
||||||
:auto-switch-threshold-low="0.58"
|
:auto-switch-threshold-low="0.58"
|
||||||
:auto-switch-threshold-high="1.18"
|
:auto-switch-threshold-high="1.18"
|
||||||
|
:outdoor-nav-polylines="outdoorNavPolylines"
|
||||||
@search-tap="handleGuideSearchTap"
|
@search-tap="handleGuideSearchTap"
|
||||||
@mode-change="handleModeChange"
|
@mode-change="handleModeChange"
|
||||||
@floor-change="handleFloorChange"
|
@floor-change="handleFloorChange"
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
@tool-click="handleGuideToolClick"
|
@tool-click="handleGuideToolClick"
|
||||||
@more-click="handleGuideMoreClick"
|
@more-click="handleGuideMoreClick"
|
||||||
@auto-switch="handleAutoSwitch"
|
@auto-switch="handleAutoSwitch"
|
||||||
|
@map-tap="handleMapTapForOutdoorNav"
|
||||||
>
|
>
|
||||||
<template #overlay>
|
<template #overlay>
|
||||||
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
|
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
|
||||||
@@ -123,16 +125,12 @@
|
|||||||
<view class="more-popover-handle" @tap.stop="closeGuideMoreMenu"></view>
|
<view class="more-popover-handle" @tap.stop="closeGuideMoreMenu"></view>
|
||||||
<view class="more-popover-grid">
|
<view class="more-popover-grid">
|
||||||
<view class="more-action" @tap="handleMoreRouteGuide">
|
<view class="more-action" @tap="handleMoreRouteGuide">
|
||||||
<text class="more-action-title">线路预览</text>
|
<text class="more-action-title">{{ routePlannerEntryTitle }}</text>
|
||||||
<text class="more-action-desc">室内到室内</text>
|
<text class="more-action-desc">{{ routePlannerEntryDesc }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="more-action" @tap="handleMoreOutdoorReference">
|
<view class="more-action" @tap="handleMoreOutdoorNav">
|
||||||
<text class="more-action-title">室外参考</text>
|
<text class="more-action-title">室外导航</text>
|
||||||
<text class="more-action-desc">入口位置</text>
|
<text class="more-action-desc">导航到博物馆</text>
|
||||||
</view>
|
|
||||||
<view class="more-action" @tap="handleMoreRecommendedEntrance">
|
|
||||||
<text class="more-action-title">推荐入口</text>
|
|
||||||
<text class="more-action-desc">入口参考</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -149,6 +147,7 @@
|
|||||||
:summary="routePlanSummary"
|
:summary="routePlanSummary"
|
||||||
:picker-loading="routeTargetsLoading"
|
:picker-loading="routeTargetsLoading"
|
||||||
:picker-error="routeTargetsError"
|
:picker-error="routeTargetsError"
|
||||||
|
:route-ready="routeReady"
|
||||||
@update:start-point="handleRouteStartPointChange"
|
@update:start-point="handleRouteStartPointChange"
|
||||||
@update:end-point="handleRouteEndPointChange"
|
@update:end-point="handleRouteEndPointChange"
|
||||||
@picker-open="handleRoutePickerOpen"
|
@picker-open="handleRoutePickerOpen"
|
||||||
@@ -189,6 +188,27 @@
|
|||||||
</view>
|
</view>
|
||||||
</GuideMapShell>
|
</GuideMapShell>
|
||||||
|
|
||||||
|
<OutdoorNavigationPanel
|
||||||
|
v-if="currentTab === 'guide'"
|
||||||
|
:visible="showOutdoorNavPanel"
|
||||||
|
:loading="outdoorNavLoading"
|
||||||
|
:route-error="outdoorNavError"
|
||||||
|
:route-distance="outdoorNavRoute?.distance ?? 0"
|
||||||
|
:route-duration="outdoorNavRoute?.duration ?? 0"
|
||||||
|
:selecting-manual-start="outdoorNavSelectingManualStart"
|
||||||
|
:start-point="outdoorNavStartPoint ? { ...outdoorNavStartPoint, name: '已选位置' } : null"
|
||||||
|
:start-mode="outdoorNavManualMode || outdoorNavStartPoint ? 'manual' : 'gps'"
|
||||||
|
@close="handleOutdoorNavClose"
|
||||||
|
@gps-start="handleOutdoorNavGpsStart"
|
||||||
|
@manual-start-request="handleOutdoorNavManualStartRequest"
|
||||||
|
@manual-start-cancel="handleOutdoorNavManualStartCancel"
|
||||||
|
@manual-start-confirm="handleOutdoorNavManualStartConfirm"
|
||||||
|
@travel-mode-change="handleOutdoorNavTravelModeChange"
|
||||||
|
@start-navigation="handleOutdoorNavStartNavigation"
|
||||||
|
@clear-route="handleOutdoorNavClearRoute"
|
||||||
|
@back="handleOutdoorNavBack"
|
||||||
|
/>
|
||||||
|
|
||||||
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
||||||
<ExplainHallSelect
|
<ExplainHallSelect
|
||||||
:halls="explainHallItems"
|
:halls="explainHallItems"
|
||||||
@@ -207,6 +227,7 @@ import { onLoad } from '@dcloudio/uni-app'
|
|||||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||||
import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
|
import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
|
||||||
|
import OutdoorNavigationPanel from '@/components/navigation/OutdoorNavigationPanel.vue'
|
||||||
import type {
|
import type {
|
||||||
RoutePointOption
|
RoutePointOption
|
||||||
} from '@/components/navigation/RoutePointPicker.vue'
|
} from '@/components/navigation/RoutePointPicker.vue'
|
||||||
@@ -236,6 +257,17 @@ import type {
|
|||||||
MuseumExhibit,
|
MuseumExhibit,
|
||||||
MuseumFloor
|
MuseumFloor
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
|
import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum'
|
||||||
|
import {
|
||||||
|
planWalkingRoute,
|
||||||
|
planDrivingRoute,
|
||||||
|
planTransitRoute,
|
||||||
|
type Route,
|
||||||
|
type RoutePoint
|
||||||
|
} from '@/services/tencent/TencentMapService'
|
||||||
|
import {
|
||||||
|
navigateToMuseum
|
||||||
|
} from '@/services/MapNavigationService'
|
||||||
|
|
||||||
type GuideIndoorView = 'overview' | 'floor' | 'multi'
|
type GuideIndoorView = 'overview' | 'floor' | 'multi'
|
||||||
type LayerDisplayMode = 'single' | 'multi'
|
type LayerDisplayMode = 'single' | 'multi'
|
||||||
@@ -282,6 +314,7 @@ const routeTargetsError = ref('')
|
|||||||
const routePlanning = ref(false)
|
const routePlanning = ref(false)
|
||||||
const routePlanError = ref('')
|
const routePlanError = ref('')
|
||||||
const routePreviewUnavailableMessage = ref('')
|
const routePreviewUnavailableMessage = ref('')
|
||||||
|
const routeReady = ref(false)
|
||||||
const activeRoutePreview = ref<GuideRouteResult | null>(null)
|
const activeRoutePreview = ref<GuideRouteResult | null>(null)
|
||||||
const showGuideMoreMenu = ref(false)
|
const showGuideMoreMenu = ref(false)
|
||||||
const isSimulatingRoute = ref(false)
|
const isSimulatingRoute = ref(false)
|
||||||
@@ -289,6 +322,22 @@ const isPoiCardCollapsed = ref(false)
|
|||||||
const guideMoreTouchStartY = ref(0)
|
const guideMoreTouchStartY = ref(0)
|
||||||
const guideMoreTouchCurrentY = ref(0)
|
const guideMoreTouchCurrentY = ref(0)
|
||||||
|
|
||||||
|
// GuideMapShell 组件引用,用于显式调用路线清除方法
|
||||||
|
const guideMapShellRef = ref<{
|
||||||
|
clearRoute?: () => void
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// 室外导航相关状态
|
||||||
|
const showOutdoorNavPanel = ref(false)
|
||||||
|
const outdoorNavLoading = ref(false)
|
||||||
|
const outdoorNavError = ref('')
|
||||||
|
const outdoorNavRoute = ref<Route | null>(null)
|
||||||
|
const outdoorNavPolylines = ref<Array<{ points: Array<{ latitude: number; longitude: number }>; color: string; width: number }>>([])
|
||||||
|
const outdoorNavStartPoint = ref<{ latitude: number; longitude: number } | null>(null)
|
||||||
|
const outdoorNavTravelMode = ref<TravelMode>('walking')
|
||||||
|
const outdoorNavSelectingManualStart = ref(false)
|
||||||
|
const outdoorNavManualMode = ref(false)
|
||||||
|
|
||||||
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
|
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
|
||||||
const indoorModelSource = guideUseCase.getModelSource()
|
const indoorModelSource = guideUseCase.getModelSource()
|
||||||
const guideFloors = ref<MuseumFloor[]>([])
|
const guideFloors = ref<MuseumFloor[]>([])
|
||||||
@@ -297,7 +346,7 @@ const indoorLayerMode = computed<LayerDisplayMode>(() => (
|
|||||||
indoorView.value === 'multi' ? 'multi' : 'single'
|
indoorView.value === 'multi' ? 'multi' : 'single'
|
||||||
))
|
))
|
||||||
const guide3DTools = computed(() => {
|
const guide3DTools = computed(() => {
|
||||||
return is3DMode.value ? ['重置', '俯视', '斜视', '清除', '清路线'] : []
|
return is3DMode.value ? ['视角'] : []
|
||||||
})
|
})
|
||||||
|
|
||||||
const routePlanSummary = computed(() => {
|
const routePlanSummary = computed(() => {
|
||||||
@@ -314,7 +363,13 @@ const routePlanSummary = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const routePanelError = computed(() => routePlanError.value || routePreviewUnavailableMessage.value)
|
const routePanelError = computed(() => routePlanError.value)
|
||||||
|
|
||||||
|
const routePlannerEntryTitle = computed(() => '室内导览')
|
||||||
|
|
||||||
|
const routePlannerEntryDesc = computed(() => (
|
||||||
|
routeReady.value ? '开始路线规划' : '查看室内位置'
|
||||||
|
))
|
||||||
|
|
||||||
const toRoutePointOption = (target: GuideRouteTarget): RoutePointOption => ({
|
const toRoutePointOption = (target: GuideRouteTarget): RoutePointOption => ({
|
||||||
poiId: target.poiId,
|
poiId: target.poiId,
|
||||||
@@ -333,9 +388,12 @@ const selectedGuidePoiSubtitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const routeSimulationStepText = computed(() => {
|
const routeSimulationStepText = computed(() => {
|
||||||
if (!activeRoutePreview.value) return '路线示意 · 查看预览'
|
if (!activeRoutePreview.value) return routeReady.value ? '室内导览 · 查看路线' : '室内导览 · 查看位置'
|
||||||
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
|
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
|
||||||
return hasConnector ? '路线示意 · 跨楼层连接点' : '路线示意 · 同层位置关系'
|
if (routeReady.value) {
|
||||||
|
return hasConnector ? '室内导览 · 跨楼层路线' : '室内导览 · 同层路线'
|
||||||
|
}
|
||||||
|
return hasConnector ? '室内导览 · 跨楼层位置关系' : '室内导览 · 同层位置关系'
|
||||||
})
|
})
|
||||||
|
|
||||||
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
||||||
@@ -384,6 +442,8 @@ const handleTabChange = (tabId: GuideTopTab) => {
|
|||||||
showRoutePlanner.value = false
|
showRoutePlanner.value = false
|
||||||
isSimulatingRoute.value = false
|
isSimulatingRoute.value = false
|
||||||
isPoiCardCollapsed.value = false
|
isPoiCardCollapsed.value = false
|
||||||
|
showOutdoorNavPanel.value = false
|
||||||
|
clearOutdoorNavState()
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncTopTabFromHash = () => {
|
const syncTopTabFromHash = () => {
|
||||||
@@ -648,13 +708,14 @@ const handleSelectedPoiRelated = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGuideToolClick = (tool: string) => {
|
const guideCameraView = ref<'reset' | 'top' | 'oblique'>('reset')
|
||||||
if (tool === '清除') {
|
|
||||||
selectedGuidePoi.value = null
|
const handleGuideToolClick = (_tool: string) => {
|
||||||
} else if (tool === '清路线') {
|
// 视角循环切换:重置 -> 俯视 -> 斜视 -> 重置
|
||||||
clearRoutePreviewState()
|
const viewOrder: Array<'reset' | 'top' | 'oblique'> = ['reset', 'top', 'oblique']
|
||||||
isSimulatingRoute.value = false
|
const currentIndex = viewOrder.indexOf(guideCameraView.value)
|
||||||
}
|
const nextIndex = (currentIndex + 1) % viewOrder.length
|
||||||
|
guideCameraView.value = viewOrder[nextIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadRouteTargets = async (keyword = '') => {
|
const loadRouteTargets = async (keyword = '') => {
|
||||||
@@ -679,11 +740,13 @@ const loadRouteTargets = async (keyword = '') => {
|
|||||||
const refreshRouteReadinessState = async () => {
|
const refreshRouteReadinessState = async () => {
|
||||||
try {
|
try {
|
||||||
const readiness = await guideRouteUseCase.getRouteReadiness()
|
const readiness = await guideRouteUseCase.getRouteReadiness()
|
||||||
|
routeReady.value = readiness.ready
|
||||||
routePreviewUnavailableMessage.value = readiness.ready
|
routePreviewUnavailableMessage.value = readiness.ready
|
||||||
? ''
|
? ''
|
||||||
: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意'
|
: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('读取路线能力状态失败:', error)
|
console.warn('读取路线能力状态失败:', error)
|
||||||
|
routeReady.value = false
|
||||||
routePreviewUnavailableMessage.value = '当前暂不支持正式导航,可查看位置预览和路线示意'
|
routePreviewUnavailableMessage.value = '当前暂不支持正式导航,可查看位置预览和路线示意'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,6 +766,7 @@ const openRoutePlanner = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearRoutePreviewState = () => {
|
const clearRoutePreviewState = () => {
|
||||||
|
guideMapShellRef.value?.clearRoute?.()
|
||||||
activeRoutePreview.value = null
|
activeRoutePreview.value = null
|
||||||
routePlanError.value = ''
|
routePlanError.value = ''
|
||||||
isSimulatingRoute.value = false
|
isSimulatingRoute.value = false
|
||||||
@@ -782,7 +846,7 @@ const handleRouteView = async ({ startPoint, endPoint }: { startPoint: RoutePoin
|
|||||||
|
|
||||||
if (!result.route) {
|
if (!result.route) {
|
||||||
activeRoutePreview.value = null
|
activeRoutePreview.value = null
|
||||||
routePlanError.value = result.error || '路线预览生成失败'
|
routePlanError.value = result.error || '室内导览位置关系生成失败'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,9 +854,9 @@ const handleRouteView = async ({ startPoint, endPoint }: { startPoint: RoutePoin
|
|||||||
focusRouteFloor(result.route)
|
focusRouteFloor(result.route)
|
||||||
showRoutePlanner.value = true
|
showRoutePlanner.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('路线预览生成失败:', error)
|
console.error('室内导览位置关系生成失败:', error)
|
||||||
activeRoutePreview.value = null
|
activeRoutePreview.value = null
|
||||||
routePlanError.value = error instanceof Error ? error.message : '路线预览生成失败'
|
routePlanError.value = error instanceof Error ? error.message : '室内导览位置关系生成失败'
|
||||||
} finally {
|
} finally {
|
||||||
routePlanning.value = false
|
routePlanning.value = false
|
||||||
}
|
}
|
||||||
@@ -864,6 +928,7 @@ const handleGuideMoreClick = () => {
|
|||||||
|
|
||||||
const closeGuideMoreMenu = () => {
|
const closeGuideMoreMenu = () => {
|
||||||
showGuideMoreMenu.value = false
|
showGuideMoreMenu.value = false
|
||||||
|
showOutdoorNavPanel.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGestureClientY = (event: TouchEvent | MouseEvent) => {
|
const getGestureClientY = (event: TouchEvent | MouseEvent) => {
|
||||||
@@ -914,23 +979,181 @@ const handleMoreRouteGuide = () => {
|
|||||||
openRoutePlanner()
|
openRoutePlanner()
|
||||||
}
|
}
|
||||||
|
|
||||||
const openOutdoorReference = (state: 'home' | 'entrance') => {
|
// 室外导航处理函数
|
||||||
is3DMode.value = false
|
const openOutdoorNavPanel = () => {
|
||||||
guideOutdoorState.value = state
|
showOutdoorNavPanel.value = true
|
||||||
indoorView.value = 'overview'
|
showGuideMoreMenu.value = false
|
||||||
|
showRoutePlanner.value = false
|
||||||
selectedGuidePoi.value = null
|
selectedGuidePoi.value = null
|
||||||
isPoiCardCollapsed.value = false
|
isPoiCardCollapsed.value = false
|
||||||
showRoutePlanner.value = false
|
// 切换到室外 2D 地图
|
||||||
showGuideMoreMenu.value = false
|
is3DMode.value = false
|
||||||
clearRoutePreviewState()
|
clearOutdoorNavState()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoreOutdoorReference = () => {
|
const clearOutdoorNavState = () => {
|
||||||
openOutdoorReference('home')
|
outdoorNavRoute.value = null
|
||||||
|
outdoorNavPolylines.value = []
|
||||||
|
outdoorNavStartPoint.value = null
|
||||||
|
outdoorNavError.value = ''
|
||||||
|
outdoorNavLoading.value = false
|
||||||
|
outdoorNavSelectingManualStart.value = false
|
||||||
|
outdoorNavManualMode.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoreRecommendedEntrance = () => {
|
const handleMoreOutdoorNav = () => {
|
||||||
openOutdoorReference('entrance')
|
openOutdoorNavPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavClose = () => {
|
||||||
|
showOutdoorNavPanel.value = false
|
||||||
|
clearOutdoorNavState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavBack = () => {
|
||||||
|
showOutdoorNavPanel.value = false
|
||||||
|
clearOutdoorNavState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavGpsStart = () => {
|
||||||
|
outdoorNavLoading.value = true
|
||||||
|
outdoorNavError.value = ''
|
||||||
|
|
||||||
|
uni.getLocation({
|
||||||
|
type: 'gcj02',
|
||||||
|
success: (res) => {
|
||||||
|
console.log('GPS 定位成功:', res)
|
||||||
|
outdoorNavStartPoint.value = {
|
||||||
|
latitude: res.latitude,
|
||||||
|
longitude: res.longitude
|
||||||
|
}
|
||||||
|
void planOutdoorRoute({
|
||||||
|
latitude: res.latitude,
|
||||||
|
longitude: res.longitude
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('GPS 定位失败:', err)
|
||||||
|
const errMsg = err.errMsg || ''
|
||||||
|
if (errMsg.includes('auth deny') || errMsg.includes('auth reject')) {
|
||||||
|
outdoorNavError.value = '定位权限被拒绝,请在设置中开启'
|
||||||
|
} else if (errMsg.includes('timeout')) {
|
||||||
|
outdoorNavError.value = '定位超时,请重试'
|
||||||
|
} else if (errMsg.includes('unavailable')) {
|
||||||
|
outdoorNavError.value = '位置服务不可用,请检查设备设置'
|
||||||
|
} else {
|
||||||
|
outdoorNavError.value = '定位失败,请检查位置权限'
|
||||||
|
}
|
||||||
|
outdoorNavLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavManualStartRequest = () => {
|
||||||
|
outdoorNavManualMode.value = true
|
||||||
|
outdoorNavSelectingManualStart.value = true
|
||||||
|
uni.showToast({
|
||||||
|
title: '输入地址搜索,或点击地图选择起点',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavManualStartCancel = () => {
|
||||||
|
outdoorNavManualMode.value = false
|
||||||
|
outdoorNavSelectingManualStart.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavManualStartConfirm = (location: { latitude: number; longitude: number }) => {
|
||||||
|
outdoorNavStartPoint.value = location
|
||||||
|
outdoorNavManualMode.value = false
|
||||||
|
outdoorNavSelectingManualStart.value = false
|
||||||
|
void planOutdoorRoute(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavTravelModeChange = (mode: TravelMode) => {
|
||||||
|
outdoorNavTravelMode.value = mode
|
||||||
|
if (outdoorNavStartPoint.value) {
|
||||||
|
outdoorNavLoading.value = true
|
||||||
|
void planOutdoorRoute(outdoorNavStartPoint.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const planOutdoorRoute = async (from: { latitude: number; longitude: number }) => {
|
||||||
|
outdoorNavLoading.value = true
|
||||||
|
outdoorNavError.value = ''
|
||||||
|
|
||||||
|
const to: RoutePoint = {
|
||||||
|
latitude: MUSEUM_LOCATION.latitude,
|
||||||
|
longitude: MUSEUM_LOCATION.longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
switch (outdoorNavTravelMode.value) {
|
||||||
|
case 'walking':
|
||||||
|
result = await planWalkingRoute(from, to)
|
||||||
|
break
|
||||||
|
case 'driving':
|
||||||
|
result = await planDrivingRoute(from, to)
|
||||||
|
break
|
||||||
|
case 'transit':
|
||||||
|
result = await planTransitRoute(from, to)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
result = await planWalkingRoute(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 0 && result.routes && result.routes.length > 0) {
|
||||||
|
const route = result.routes[0]
|
||||||
|
outdoorNavRoute.value = route
|
||||||
|
outdoorNavPolylines.value = [{
|
||||||
|
points: route.polyline,
|
||||||
|
color: '#E0E100',
|
||||||
|
width: 6
|
||||||
|
}]
|
||||||
|
console.log('路线规划成功:', {
|
||||||
|
distance: route.distance,
|
||||||
|
duration: route.duration,
|
||||||
|
steps: route.steps.length
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
outdoorNavError.value = result.message || '路线规划失败'
|
||||||
|
outdoorNavRoute.value = null
|
||||||
|
outdoorNavPolylines.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('路线规划异常:', error)
|
||||||
|
outdoorNavError.value = '路线规划失败,请重试'
|
||||||
|
outdoorNavRoute.value = null
|
||||||
|
outdoorNavPolylines.value = []
|
||||||
|
} finally {
|
||||||
|
outdoorNavLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavStartNavigation = async () => {
|
||||||
|
// 直接调起导航
|
||||||
|
await navigateToMuseum()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOutdoorNavClearRoute = () => {
|
||||||
|
outdoorNavRoute.value = null
|
||||||
|
outdoorNavPolylines.value = []
|
||||||
|
outdoorNavStartPoint.value = null
|
||||||
|
outdoorNavError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理地图点击(用于手动选择起点)
|
||||||
|
const handleMapTapForOutdoorNav = (location: { latitude?: number; longitude?: number }) => {
|
||||||
|
if (!outdoorNavSelectingManualStart.value) return
|
||||||
|
|
||||||
|
const latitude = location.latitude
|
||||||
|
const longitude = location.longitude
|
||||||
|
if (latitude !== undefined && longitude !== undefined) {
|
||||||
|
outdoorNavStartPoint.value = { latitude, longitude }
|
||||||
|
outdoorNavSelectingManualStart.value = false
|
||||||
|
void planOutdoorRoute({ latitude, longitude })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const guideShellMode = computed(() => (is3DMode.value ? '3d' : '2d'))
|
const guideShellMode = computed(() => (is3DMode.value ? '3d' : '2d'))
|
||||||
|
|||||||
226
src/repositories/SdkGuideRouteRepository.ts
Normal file
226
src/repositories/SdkGuideRouteRepository.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import type {
|
||||||
|
GuideRouteReadiness,
|
||||||
|
GuideRouteResult,
|
||||||
|
GuideRouteTarget
|
||||||
|
} from '@/domain/museum'
|
||||||
|
import {
|
||||||
|
NAV_ROUTE_READINESS
|
||||||
|
} from '@/domain/guideReadiness'
|
||||||
|
import {
|
||||||
|
GuideRouteError
|
||||||
|
} from '@/repositories/GuideRouteRepository'
|
||||||
|
import {
|
||||||
|
defaultSgsSdkApiProvider,
|
||||||
|
type SgsNavigablePlacePayload,
|
||||||
|
type SgsSdkApiProvider
|
||||||
|
} from '@/data/providers/sgsSdkApiProvider'
|
||||||
|
import {
|
||||||
|
toGuideRouteResultFromSgsPlan
|
||||||
|
} from '@/data/adapters/sgsSdkRouteAdapter'
|
||||||
|
import { stringifyId, formatSgsFloorLabel } from '@/data/adapters/sgsSdkGuideAdapter'
|
||||||
|
import { toAppFloorId } from '@/services/sgs/SgsMapEventAdapter'
|
||||||
|
|
||||||
|
const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
|
||||||
|
ready: false,
|
||||||
|
message,
|
||||||
|
requiredData: ['route_graph', 'nav_data']
|
||||||
|
})
|
||||||
|
|
||||||
|
const toNumber = (value: unknown) => (
|
||||||
|
Number.isFinite(Number(value)) ? Number(value) : undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
const positionFromPlace = (place: SgsNavigablePlacePayload): [number, number, number] | undefined => {
|
||||||
|
const source = place.position || {
|
||||||
|
x: place.x,
|
||||||
|
y: place.y,
|
||||||
|
z: place.z
|
||||||
|
}
|
||||||
|
const x = toNumber(source.x)
|
||||||
|
const z = toNumber(source.z)
|
||||||
|
|
||||||
|
if (typeof x !== 'number' || typeof z !== 'number') return undefined
|
||||||
|
|
||||||
|
return [
|
||||||
|
x,
|
||||||
|
toNumber(source.y) || 0,
|
||||||
|
z
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericId = (value: unknown) => {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SdkGuideRouteRepository {
|
||||||
|
private targetsCache: GuideRouteTarget[] | null = null
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sdkApiProvider: SgsSdkApiProvider = defaultSgsSdkApiProvider
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getRouteReadiness(): Promise<GuideRouteReadiness> {
|
||||||
|
try {
|
||||||
|
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
|
||||||
|
const floors = diagnostics.floors || []
|
||||||
|
|
||||||
|
if (!floors.length) {
|
||||||
|
return createUnavailableReadiness('SDK 地图楼层数据为空')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集有路线数据的楼层
|
||||||
|
const readyFloors = floors.filter(
|
||||||
|
(floor) =>
|
||||||
|
floor.routePlanningReady &&
|
||||||
|
floor.routeNodeCount &&
|
||||||
|
floor.routeNodeCount > 0 &&
|
||||||
|
floor.routeEdgeCount &&
|
||||||
|
floor.routeEdgeCount > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// 没有楼层有路线数据时不可用
|
||||||
|
if (readyFloors.length === 0) {
|
||||||
|
return createUnavailableReadiness('SDK 路线规划数据不完整,当前暂不支持正式导航')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集未就绪的楼层
|
||||||
|
const notReadyFloors = floors.filter((floor) => !floor.routePlanningReady)
|
||||||
|
|
||||||
|
// 如果有楼层未就绪,给出警告但仍然可用
|
||||||
|
if (notReadyFloors.length > 0) {
|
||||||
|
const floorLabels = notReadyFloors
|
||||||
|
.map((f) => f.floorName || f.floorCode || String(f.floorId))
|
||||||
|
.join('、')
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
message: `部分楼层路线数据未就绪:${floorLabels},已就绪楼层可正常使用导览功能`,
|
||||||
|
requiredData: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
message: 'SDK 路线数据已就绪',
|
||||||
|
requiredData: []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return createUnavailableReadiness(
|
||||||
|
error instanceof Error ? error.message : 'SDK 路线数据加载失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRouteTargets(): Promise<GuideRouteTarget[]> {
|
||||||
|
if (this.targetsCache) return this.targetsCache
|
||||||
|
|
||||||
|
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
|
||||||
|
const floors = diagnostics.floors || []
|
||||||
|
|
||||||
|
const targets: GuideRouteTarget[] = []
|
||||||
|
|
||||||
|
for (const floor of floors) {
|
||||||
|
// 使用原始 floorId 请求 API(SDK API 需要原始 ID 如 2065808921272119298)
|
||||||
|
const rawFloorId = stringifyId(floor.floorId)
|
||||||
|
try {
|
||||||
|
const places = await this.sdkApiProvider.getNavigablePlaces(rawFloorId)
|
||||||
|
|
||||||
|
for (const place of places) {
|
||||||
|
// 过滤掉没有 nodeId 的点位,这些无法用于路线规划
|
||||||
|
if (place.nodeId === null || place.nodeId === undefined || place.nodeId === '') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeId = stringifyId(place.id)
|
||||||
|
const placeName = place.name || `目的地 ${placeId}`
|
||||||
|
const categoryLabel = place.category || place.typeName || place.typeCode || place.type || undefined
|
||||||
|
const positionGltf = positionFromPlace(place)
|
||||||
|
|
||||||
|
// 使用统一的楼层标签格式化函数,确保与 nav-assets 格式一致
|
||||||
|
const floorLabel = formatSgsFloorLabel(
|
||||||
|
place.floorCode || undefined,
|
||||||
|
place.floorName || floor.floorName || undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
targets.push({
|
||||||
|
poiId: placeId,
|
||||||
|
name: placeName,
|
||||||
|
// 展示用标准化的 floorId(如 L1)
|
||||||
|
floorId: toAppFloorId(place.floorId ?? rawFloorId),
|
||||||
|
floorLabel,
|
||||||
|
categoryLabel,
|
||||||
|
positionGltf,
|
||||||
|
// 保存原始 nodeId 用于 API 调用
|
||||||
|
routeNodeId: stringifyId(place.nodeId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略单个楼层的加载错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetsCache = targets
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRoute(startPoiId: string, endPoiId: string): Promise<GuideRouteResult> {
|
||||||
|
const readiness = await this.getRouteReadiness()
|
||||||
|
if (!readiness.ready) {
|
||||||
|
throw new GuideRouteError(
|
||||||
|
'ROUTE_DATA_UNAVAILABLE',
|
||||||
|
readiness.message || NAV_ROUTE_READINESS.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startPoiId || !endPoiId) {
|
||||||
|
throw new GuideRouteError('ROUTE_TARGET_MISSING', '请选择起点和终点')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startPoiId === endPoiId) {
|
||||||
|
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点和终点不能相同')
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await this.listRouteTargets()
|
||||||
|
const startTarget = targets.find((target) => target.poiId === startPoiId)
|
||||||
|
const endTarget = targets.find((target) => target.poiId === endPoiId)
|
||||||
|
|
||||||
|
if (!startTarget || !endTarget) {
|
||||||
|
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点或终点不在 SDK 可导航目的地列表中')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!startTarget.positionGltf || !endTarget.positionGltf) {
|
||||||
|
throw new GuideRouteError('ROUTE_TARGET_UNANCHORED', '起点或终点缺少可规划坐标')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取原始 floorId 用于 API 调用
|
||||||
|
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
|
||||||
|
const floorIdMap = new Map<string, string>()
|
||||||
|
for (const floor of diagnostics.floors || []) {
|
||||||
|
floorIdMap.set(toAppFloorId(floor.floorId), stringifyId(floor.floorId))
|
||||||
|
}
|
||||||
|
const rawStartFloorId = floorIdMap.get(startTarget.floorId) || startTarget.floorId
|
||||||
|
const rawEndFloorId = floorIdMap.get(endTarget.floorId) || endTarget.floorId
|
||||||
|
|
||||||
|
const route = await this.sdkApiProvider.planRoute({
|
||||||
|
startFloorId: rawStartFloorId,
|
||||||
|
startX: startTarget.positionGltf[0],
|
||||||
|
startY: startTarget.positionGltf[2],
|
||||||
|
startNodeId: numericId(startTarget.routeNodeId) || null,
|
||||||
|
endFloorId: rawEndFloorId,
|
||||||
|
endX: endTarget.positionGltf[0],
|
||||||
|
endY: endTarget.positionGltf[2],
|
||||||
|
endNodeId: numericId(endTarget.routeNodeId) || null,
|
||||||
|
wheelchair: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return toGuideRouteResultFromSgsPlan(route, startTarget, endTarget)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GuideRouteError) throw error
|
||||||
|
throw new GuideRouteError(
|
||||||
|
'ROUTE_NOT_FOUND',
|
||||||
|
error instanceof Error ? error.message : 'SGS 路线接口规划失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user