Compare commits
2 Commits
d813f0b375
...
c08e1afe85
| Author | SHA1 | Date | |
|---|---|---|---|
| c08e1afe85 | |||
| 84fbbc0694 |
@@ -209,6 +209,7 @@ interface LoadFloorOptions {
|
|||||||
detachPoiBeforeLoad?: boolean
|
detachPoiBeforeLoad?: boolean
|
||||||
allowSameFloorReload?: boolean
|
allowSameFloorReload?: boolean
|
||||||
cameraSnapshot?: CameraSnapshot
|
cameraSnapshot?: CameraSnapshot
|
||||||
|
applyFloorBaseline?: boolean
|
||||||
onCameraStable?: () => void
|
onCameraStable?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +244,21 @@ interface CameraSnapshot {
|
|||||||
distance: number
|
distance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResetViewBaselineOptions = {
|
||||||
|
view: 'overview' | 'floor'
|
||||||
|
floorId?: string
|
||||||
|
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FloorViewBaseline {
|
||||||
|
floorId: string
|
||||||
|
modelUrl: string
|
||||||
|
packageEpoch: number
|
||||||
|
aspectRatio: number
|
||||||
|
boundsSignature: string
|
||||||
|
camera: CameraSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
// 所有普通楼层/外观切换共享该外观基准;切换时使用完整快照恢复而非包围盒拟合。
|
// 所有普通楼层/外观切换共享该外观基准;切换时使用完整快照恢复而非包围盒拟合。
|
||||||
const referenceOverviewCameraState: CameraSnapshot = (() => {
|
const referenceOverviewCameraState: CameraSnapshot = (() => {
|
||||||
const referenceCamera = new THREE.PerspectiveCamera(
|
const referenceCamera = new THREE.PerspectiveCamera(
|
||||||
@@ -478,6 +494,7 @@ let resizeObserver: ResizeObserver | null = null
|
|||||||
let isDisposed = false
|
let isDisposed = false
|
||||||
let pendingTargetFocus: TargetPoiFocusRequest | null = null
|
let pendingTargetFocus: TargetPoiFocusRequest | null = null
|
||||||
let targetFocusQueue: Promise<unknown> = Promise.resolve()
|
let targetFocusQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
let targetFocusGeneration = 0
|
||||||
let modelLoadVersion = 0
|
let modelLoadVersion = 0
|
||||||
let pendingRequestedFloorId = ''
|
let pendingRequestedFloorId = ''
|
||||||
let floorSwitchLoadToken = 0
|
let floorSwitchLoadToken = 0
|
||||||
@@ -515,6 +532,9 @@ let defaultFloorPreloadSeq = 0
|
|||||||
let firstModelLoadStartedAt = 0
|
let firstModelLoadStartedAt = 0
|
||||||
let firstModelVisibleReported = false
|
let firstModelVisibleReported = false
|
||||||
let initialModelSettled = false
|
let initialModelSettled = false
|
||||||
|
let initialGuideState: { floorId: string; camera: CameraSnapshot } | null = null
|
||||||
|
const floorViewBaselines = new Map<string, FloorViewBaseline>()
|
||||||
|
let modelPackageEpoch = 0
|
||||||
|
|
||||||
const cameraTweenEnabled = true
|
const cameraTweenEnabled = true
|
||||||
const cameraTweenDurationMs = 480
|
const cameraTweenDurationMs = 480
|
||||||
@@ -1981,7 +2001,9 @@ const handleResize = () => {
|
|||||||
|
|
||||||
const width = Math.max(1, container.clientWidth)
|
const width = Math.max(1, container.clientWidth)
|
||||||
const height = Math.max(1, container.clientHeight)
|
const height = Math.max(1, container.clientHeight)
|
||||||
camera.aspect = width / height
|
const nextAspect = width / height
|
||||||
|
if (Math.abs(camera.aspect - nextAspect) > 1e-6) clearFloorViewBaselines()
|
||||||
|
camera.aspect = nextAspect
|
||||||
camera.updateProjectionMatrix()
|
camera.updateProjectionMatrix()
|
||||||
renderer.setSize(width, height)
|
renderer.setSize(width, height)
|
||||||
}
|
}
|
||||||
@@ -3388,6 +3410,99 @@ const applyReferenceOverviewCameraState = () => {
|
|||||||
restoreCameraSnapshot(referenceOverviewCameraState)
|
restoreCameraSnapshot(referenceOverviewCameraState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearFloorViewBaselines = () => {
|
||||||
|
floorViewBaselines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBoxSignature = (box: THREE.Box3) => [
|
||||||
|
box.min.x,
|
||||||
|
box.min.y,
|
||||||
|
box.min.z,
|
||||||
|
box.max.x,
|
||||||
|
box.max.y,
|
||||||
|
box.max.z
|
||||||
|
].map((value) => value.toFixed(6)).join(':')
|
||||||
|
|
||||||
|
const createFloorViewBaseline = (floorId: string, model: THREE.Object3D, modelUrl: string): FloorViewBaseline | null => {
|
||||||
|
if (!camera || !controls) return null
|
||||||
|
|
||||||
|
const overviewCamera = initialGuideState?.camera || referenceOverviewCameraState
|
||||||
|
const box = getObjectBox(model)
|
||||||
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
const size = box.getSize(new THREE.Vector3())
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||||||
|
const verticalFov = overviewCamera.fov * (Math.PI / 180)
|
||||||
|
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * camera.aspect)
|
||||||
|
const visibleFraction = FLOOR_CAMERA_DISTANCE_FACTOR
|
||||||
|
const distance = Math.max(
|
||||||
|
size.y / (2 * Math.tan(verticalFov / 2) * visibleFraction),
|
||||||
|
size.x / (2 * Math.tan(horizontalFov / 2) * visibleFraction),
|
||||||
|
size.z / (2 * Math.tan(verticalFov / 2) * visibleFraction),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
const direction = overviewCamera.position.clone().sub(overviewCamera.target).normalize()
|
||||||
|
const target = center.clone()
|
||||||
|
const position = target.clone().add(direction.multiplyScalar(distance))
|
||||||
|
const offsetRatio = SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio
|
||||||
|
const viewDirection = target.clone().sub(position).normalize()
|
||||||
|
const cameraRight = new THREE.Vector3().crossVectors(viewDirection, overviewCamera.up).normalize()
|
||||||
|
const viewHeight = 2 * distance * Math.tan(verticalFov / 2)
|
||||||
|
const viewWidth = viewHeight * camera.aspect
|
||||||
|
const screenOffset = cameraRight.multiplyScalar(offsetRatio.x * viewWidth)
|
||||||
|
.add(overviewCamera.up.clone().multiplyScalar(offsetRatio.y * viewHeight))
|
||||||
|
position.add(screenOffset)
|
||||||
|
target.add(screenOffset)
|
||||||
|
|
||||||
|
const baselineCamera = new THREE.PerspectiveCamera(
|
||||||
|
overviewCamera.fov,
|
||||||
|
camera.aspect,
|
||||||
|
Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1000),
|
||||||
|
Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20)
|
||||||
|
)
|
||||||
|
baselineCamera.position.copy(position)
|
||||||
|
baselineCamera.up.copy(overviewCamera.up)
|
||||||
|
baselineCamera.zoom = overviewCamera.zoom
|
||||||
|
baselineCamera.lookAt(target)
|
||||||
|
baselineCamera.updateProjectionMatrix()
|
||||||
|
|
||||||
|
return {
|
||||||
|
floorId,
|
||||||
|
modelUrl,
|
||||||
|
packageEpoch: modelPackageEpoch,
|
||||||
|
aspectRatio: camera.aspect,
|
||||||
|
boundsSignature: getBoxSignature(box),
|
||||||
|
camera: {
|
||||||
|
position: baselineCamera.position.clone(),
|
||||||
|
target,
|
||||||
|
up: baselineCamera.up.clone(),
|
||||||
|
quaternion: baselineCamera.quaternion.clone(),
|
||||||
|
fov: baselineCamera.fov,
|
||||||
|
zoom: baselineCamera.zoom,
|
||||||
|
near: baselineCamera.near,
|
||||||
|
far: baselineCamera.far,
|
||||||
|
distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFloorViewBaseline = (floorId: string, model: THREE.Object3D, modelUrl: string) => {
|
||||||
|
const cached = floorViewBaselines.get(floorId)
|
||||||
|
const boundsSignature = getBoxSignature(getObjectBox(model))
|
||||||
|
if (
|
||||||
|
cached
|
||||||
|
&& cached.modelUrl === modelUrl
|
||||||
|
&& cached.packageEpoch === modelPackageEpoch
|
||||||
|
&& cached.boundsSignature === boundsSignature
|
||||||
|
&& Math.abs(cached.aspectRatio - (camera?.aspect || 1)) < 1e-6
|
||||||
|
) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = createFloorViewBaseline(floorId, model, modelUrl)
|
||||||
|
if (baseline) floorViewBaselines.set(floorId, baseline)
|
||||||
|
return baseline || null
|
||||||
|
}
|
||||||
|
|
||||||
const serializeCameraSnapshot = (snapshot: CameraSnapshot) => ({
|
const serializeCameraSnapshot = (snapshot: CameraSnapshot) => ({
|
||||||
position: { x: snapshot.position.x, y: snapshot.position.y, z: snapshot.position.z },
|
position: { x: snapshot.position.x, y: snapshot.position.y, z: snapshot.position.z },
|
||||||
target: { x: snapshot.target.x, y: snapshot.target.y, z: snapshot.target.z },
|
target: { x: snapshot.target.x, y: snapshot.target.y, z: snapshot.target.z },
|
||||||
@@ -3519,6 +3634,8 @@ const getVisualStabilityReport = () => ({
|
|||||||
anchors: getProjectedBuildingAnchors(),
|
anchors: getProjectedBuildingAnchors(),
|
||||||
activeView: activeView.value,
|
activeView: activeView.value,
|
||||||
floorId: currentFloor.value,
|
floorId: currentFloor.value,
|
||||||
|
activeFocusPoiId: activeFocusPoiId.value,
|
||||||
|
hasPendingTargetFocus: Boolean(pendingTargetFocus),
|
||||||
isCameraTweening: Boolean(cameraTween),
|
isCameraTweening: Boolean(cameraTween),
|
||||||
poiFocusCameraAnimationCount,
|
poiFocusCameraAnimationCount,
|
||||||
cameraSnapshotRestoreCount,
|
cameraSnapshotRestoreCount,
|
||||||
@@ -3538,6 +3655,10 @@ const installVisualStabilityDiagnostics = () => {
|
|||||||
label: floor.label,
|
label: floor.label,
|
||||||
modelMatchKeys: floor.modelMatchKeys || []
|
modelMatchKeys: floor.modelMatchKeys || []
|
||||||
})),
|
})),
|
||||||
|
getFloorBaseline: (floorId: string) => {
|
||||||
|
const baseline = floorViewBaselines.get(floorId)
|
||||||
|
return baseline ? serializeCameraSnapshot(baseline.camera) : null
|
||||||
|
},
|
||||||
switchFloor: handleFloorChange,
|
switchFloor: handleFloorChange,
|
||||||
showOverview,
|
showOverview,
|
||||||
resetCamera,
|
resetCamera,
|
||||||
@@ -3558,7 +3679,9 @@ const installVisualStabilityDiagnostics = () => {
|
|||||||
queueTargetFocus(request)
|
queueTargetFocus(request)
|
||||||
return targetFocusQueue
|
return targetFocusQueue
|
||||||
},
|
},
|
||||||
clearTargetFocus
|
clearTargetFocus,
|
||||||
|
resetToViewBaseline,
|
||||||
|
resetToInitialState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3998,7 +4121,10 @@ const commitPreparedFloorScene = (
|
|||||||
selectedPOI.value = null
|
selectedPOI.value = null
|
||||||
updatePoiMarkerFocus()
|
updatePoiMarkerFocus()
|
||||||
hasLoadedFloorViewOnce = true
|
hasLoadedFloorViewOnce = true
|
||||||
const cameraSnapshot = options.cameraSnapshot || referenceOverviewCameraState
|
const floorBaseline = options.applyFloorBaseline
|
||||||
|
? getFloorViewBaseline(expectedFloorId, activeModel, prepared.floor.modelUrl)
|
||||||
|
: null
|
||||||
|
const cameraSnapshot = floorBaseline?.camera || options.cameraSnapshot || referenceOverviewCameraState
|
||||||
restoreCameraSnapshot(cameraSnapshot)
|
restoreCameraSnapshot(cameraSnapshot)
|
||||||
if (!referenceBuildingAnchors.length) {
|
if (!referenceBuildingAnchors.length) {
|
||||||
updateReferenceBuildingAnchors(activeModel)
|
updateReferenceBuildingAnchors(activeModel)
|
||||||
@@ -5144,6 +5270,7 @@ const emitTargetFocus = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearTargetFocus = () => {
|
const clearTargetFocus = () => {
|
||||||
|
targetFocusGeneration += 1
|
||||||
pendingTargetFocus = null
|
pendingTargetFocus = null
|
||||||
clearMapSelection(false)
|
clearMapSelection(false)
|
||||||
}
|
}
|
||||||
@@ -5176,7 +5303,10 @@ const focusCameraOnPoi = (poi: RenderPoi) => {
|
|||||||
moveCameraTo(nextPosition, target, { reason: 'poi-focus' })
|
moveCameraTo(nextPosition, target, { reason: 'poi-focus' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
const isCurrentTargetFocus = (generation: number) => generation === targetFocusGeneration
|
||||||
|
|
||||||
|
const focusTargetPoi = async (request: TargetPoiFocusRequest, generation: number) => {
|
||||||
|
if (!isCurrentTargetFocus(generation)) return false
|
||||||
if (!request.poiId || !request.floorId) {
|
if (!request.poiId || !request.floorId) {
|
||||||
emitTargetFocus(request, 'missing', '目标位置数据不完整')
|
emitTargetFocus(request, 'missing', '目标位置数据不完整')
|
||||||
return false
|
return false
|
||||||
@@ -5198,12 +5328,15 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
|||||||
suppressProgress: true,
|
suppressProgress: true,
|
||||||
detachPoiBeforeLoad: true
|
detachPoiBeforeLoad: true
|
||||||
})
|
})
|
||||||
|
if (!isCurrentTargetFocus(generation)) return false
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
emit('floorChange', request.floorId)
|
emit('floorChange', request.floorId)
|
||||||
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
|
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
|
||||||
await loadFloorPOIs(floor, modelLoadVersion)
|
await loadFloorPOIs(floor, modelLoadVersion)
|
||||||
|
if (!isCurrentTargetFocus(generation)) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isCurrentTargetFocus(generation)) return false
|
||||||
const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request)
|
const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request)
|
||||||
|
|
||||||
if (!poi?.positionGltf) {
|
if (!poi?.positionGltf) {
|
||||||
@@ -5244,11 +5377,12 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
|
|||||||
if (!isSceneReadyForTargetFocus()) return
|
if (!isSceneReadyForTargetFocus()) return
|
||||||
|
|
||||||
const nextRequest = pendingTargetFocus
|
const nextRequest = pendingTargetFocus
|
||||||
|
const requestGeneration = targetFocusGeneration
|
||||||
if (!nextRequest) return
|
if (!nextRequest) return
|
||||||
|
|
||||||
pendingTargetFocus = null
|
pendingTargetFocus = null
|
||||||
targetFocusQueue = targetFocusQueue
|
targetFocusQueue = targetFocusQueue
|
||||||
.then(() => focusTargetPoi(nextRequest))
|
.then(() => focusTargetPoi(nextRequest, requestGeneration))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (pendingTargetFocus) {
|
if (pendingTargetFocus) {
|
||||||
queueTargetFocus(pendingTargetFocus)
|
queueTargetFocus(pendingTargetFocus)
|
||||||
@@ -5259,6 +5393,8 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
|
|||||||
const loadModelPackage = async () => {
|
const loadModelPackage = async () => {
|
||||||
setProgress(8, '正在读取馆内导览资源...')
|
setProgress(8, '正在读取馆内导览资源...')
|
||||||
const packageData = await props.modelSource.loadPackage()
|
const packageData = await props.modelSource.loadPackage()
|
||||||
|
modelPackageEpoch += 1
|
||||||
|
clearFloorViewBaselines()
|
||||||
renderPackage.value = packageData
|
renderPackage.value = packageData
|
||||||
|
|
||||||
setProgress(14, '正在读取楼层索引...')
|
setProgress(14, '正在读取楼层索引...')
|
||||||
@@ -5301,6 +5437,7 @@ const loadModelPackage = async () => {
|
|||||||
const init3DScene = async () => {
|
const init3DScene = async () => {
|
||||||
try {
|
try {
|
||||||
disposeScene()
|
disposeScene()
|
||||||
|
clearFloorViewBaselines()
|
||||||
isDisposed = false
|
isDisposed = false
|
||||||
firstModelLoadStartedAt = getNow()
|
firstModelLoadStartedAt = getNow()
|
||||||
firstModelVisibleReported = false
|
firstModelVisibleReported = false
|
||||||
@@ -5321,6 +5458,12 @@ const init3DScene = async () => {
|
|||||||
installVisualStabilityDiagnostics()
|
installVisualStabilityDiagnostics()
|
||||||
await loadModelPackage()
|
await loadModelPackage()
|
||||||
|
|
||||||
|
// The package, not a hard-coded floor, determines the stable first-ready baseline.
|
||||||
|
initialGuideState = {
|
||||||
|
floorId: currentFloor.value,
|
||||||
|
camera: captureCameraSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
setProgress(100, '馆内三维模型加载完成')
|
setProgress(100, '馆内三维模型加载完成')
|
||||||
initialModelSettled = true
|
initialModelSettled = true
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -5440,6 +5583,80 @@ const showMultiFloor = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restores business and camera state on the existing scene. It never initializes WebGL or
|
||||||
|
// reloads the model package, so closing a detail page cannot remount ThreeMap.
|
||||||
|
const resetToViewBaseline = async (options: ResetViewBaselineOptions) => {
|
||||||
|
clearTargetFocus()
|
||||||
|
invalidateModelLoads()
|
||||||
|
adjacentPreloadSeq += 1
|
||||||
|
defaultFloorPreloadSeq += 1
|
||||||
|
cancelCameraTween()
|
||||||
|
clearProgrammaticCameraTimer()
|
||||||
|
clearRoutePreview()
|
||||||
|
selectedPOI.value = null
|
||||||
|
activeFocusPoiId.value = ''
|
||||||
|
disposeFocusLabel()
|
||||||
|
disposeFocusPulse()
|
||||||
|
disposeFocusBase()
|
||||||
|
clearFocusHallHighlight()
|
||||||
|
updatePoiMarkerFocus()
|
||||||
|
autoSwitchStateMachine.reset(options.view)
|
||||||
|
resetInteractionGateState()
|
||||||
|
|
||||||
|
const baseline = initialGuideState
|
||||||
|
if (!baseline || !scene || !camera || !controls || loadError.value) return false
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
if (options.view === 'floor') {
|
||||||
|
const floorId = options.floorId
|
||||||
|
if (!floorId) return false
|
||||||
|
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||||||
|
if (!floor) return false
|
||||||
|
|
||||||
|
if (activeView.value !== 'floor' || currentFloor.value !== floorId || activeModel?.userData.floorId !== floorId) {
|
||||||
|
await loadFloor(floorId, { applyFloorBaseline: true, detachPoiBeforeLoad: true })
|
||||||
|
} else {
|
||||||
|
const floorBaseline = getFloorViewBaseline(floorId, activeModel, floor.modelUrl)
|
||||||
|
if (!floorBaseline) return false
|
||||||
|
restoreCameraSnapshot(floorBaseline.camera)
|
||||||
|
}
|
||||||
|
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return false
|
||||||
|
activeView.value = 'floor'
|
||||||
|
currentFloor.value = floorId
|
||||||
|
autoSwitchStateMachine.reset('floor')
|
||||||
|
} else {
|
||||||
|
currentFloor.value = baseline.floorId
|
||||||
|
if (activeView.value !== 'overview') {
|
||||||
|
await loadOverview({ cameraSnapshot: baseline.camera })
|
||||||
|
}
|
||||||
|
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return false
|
||||||
|
restoreCameraSnapshot(baseline.camera)
|
||||||
|
currentFloor.value = baseline.floorId
|
||||||
|
activeView.value = 'overview'
|
||||||
|
autoSwitchStateMachine.reset('overview')
|
||||||
|
}
|
||||||
|
if (controls && activeView.value === 'floor') {
|
||||||
|
ensureFloorAutoExitZoomRange()
|
||||||
|
autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
|
||||||
|
}
|
||||||
|
refreshPoiVisibilityByDistance()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (!isStaleModelLoadError(error)) {
|
||||||
|
console.error('恢复馆内导览初始状态失败:', error)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToInitialState = () => resetToViewBaseline({
|
||||||
|
view: 'overview',
|
||||||
|
reason: 'manual-reset'
|
||||||
|
})
|
||||||
|
|
||||||
const retryLoad = () => {
|
const retryLoad = () => {
|
||||||
init3DScene()
|
init3DScene()
|
||||||
}
|
}
|
||||||
@@ -5530,6 +5747,8 @@ defineExpose({
|
|||||||
},
|
},
|
||||||
clearSelection: clearMapSelection,
|
clearSelection: clearMapSelection,
|
||||||
clearRoute: clearRoutePreview,
|
clearRoute: clearRoutePreview,
|
||||||
|
resetToViewBaseline,
|
||||||
|
resetToInitialState,
|
||||||
clearNavigation: () => clearMapSelection(false),
|
clearNavigation: () => clearMapSelection(false),
|
||||||
disableAutoSwitchTemporarily
|
disableAutoSwitchTemporarily
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -460,6 +460,12 @@ const indoorRendererRef = ref<{
|
|||||||
zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
|
zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
|
||||||
clearSelection?: (shouldEmit?: boolean) => void
|
clearSelection?: (shouldEmit?: boolean) => void
|
||||||
clearRoute?: () => Promise<void> | void
|
clearRoute?: () => Promise<void> | void
|
||||||
|
resetToViewBaseline?: (options: {
|
||||||
|
view: 'overview' | 'floor'
|
||||||
|
floorId?: string
|
||||||
|
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||||||
|
}) => Promise<boolean> | boolean
|
||||||
|
resetToInitialState?: () => Promise<boolean> | boolean
|
||||||
disableAutoSwitchTemporarily?: (durationMs: number) => void
|
disableAutoSwitchTemporarily?: (durationMs: number) => void
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
@@ -779,7 +785,13 @@ defineExpose({
|
|||||||
clearRoute: () => {
|
clearRoute: () => {
|
||||||
indoorRendererRef.value?.clearRoute?.()
|
indoorRendererRef.value?.clearRoute?.()
|
||||||
},
|
},
|
||||||
showOverview: handleShowOverview
|
showOverview: handleShowOverview,
|
||||||
|
resetToViewBaseline: (options: {
|
||||||
|
view: 'overview' | 'floor'
|
||||||
|
floorId?: string
|
||||||
|
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||||||
|
}) => indoorRendererRef.value?.resetToViewBaseline?.(options) || false,
|
||||||
|
resetToInitialState: () => indoorRendererRef.value?.resetToInitialState?.() || false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
98
src/composables/useGuideModelState.ts
Normal file
98
src/composables/useGuideModelState.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { readonly, ref } from 'vue'
|
||||||
|
|
||||||
|
export type GuideModelInitialState = Readonly<{
|
||||||
|
mode: '3d'
|
||||||
|
indoorView: 'overview' | 'floor' | 'multi'
|
||||||
|
layerMode: 'single' | 'multi'
|
||||||
|
defaultActiveFloorId: string
|
||||||
|
loadedFloorId: string
|
||||||
|
selectedPoiId: null
|
||||||
|
activeFocusPoiId: null
|
||||||
|
targetFocusRequest: null
|
||||||
|
pendingTargetFocus: null
|
||||||
|
visiblePoiIds: null
|
||||||
|
temporaryFocus: false
|
||||||
|
poiInfoLabelVisible: false
|
||||||
|
routePreviewActive: false
|
||||||
|
navigationSimulationActive: false
|
||||||
|
autoFloorSwitchActive: boolean
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type PoiDetailReturnContext = Readonly<{
|
||||||
|
floorId: string
|
||||||
|
resetMode: 'floor-baseline'
|
||||||
|
requestId: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
// The object is frozen so callers cannot accidentally turn a captured baseline into live state.
|
||||||
|
export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({
|
||||||
|
mode: '3d',
|
||||||
|
indoorView: 'overview',
|
||||||
|
layerMode: 'single',
|
||||||
|
defaultActiveFloorId: '',
|
||||||
|
loadedFloorId: '',
|
||||||
|
selectedPoiId: null,
|
||||||
|
activeFocusPoiId: null,
|
||||||
|
targetFocusRequest: null,
|
||||||
|
pendingTargetFocus: null,
|
||||||
|
visiblePoiIds: null,
|
||||||
|
temporaryFocus: false,
|
||||||
|
poiInfoLabelVisible: false,
|
||||||
|
routePreviewActive: false,
|
||||||
|
navigationSimulationActive: false,
|
||||||
|
autoFloorSwitchActive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialState = ref<GuideModelInitialState>(GUIDE_MODEL_INITIAL_STATE)
|
||||||
|
const pendingModelResetAfterPoiDetail = ref<PoiDetailReturnContext | null>(null)
|
||||||
|
const requestGeneration = ref(0)
|
||||||
|
let poiDetailRequestId = 0
|
||||||
|
|
||||||
|
export const useGuideModelState = () => {
|
||||||
|
const initializeModel = (floorId: string) => {
|
||||||
|
if (initialState.value.defaultActiveFloorId) return initialState.value
|
||||||
|
initialState.value = Object.freeze({
|
||||||
|
...GUIDE_MODEL_INITIAL_STATE,
|
||||||
|
defaultActiveFloorId: floorId,
|
||||||
|
loadedFloorId: floorId
|
||||||
|
})
|
||||||
|
return initialState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginPoiDetailReturn = (floorId: string) => {
|
||||||
|
const context = Object.freeze({
|
||||||
|
floorId,
|
||||||
|
resetMode: 'floor-baseline' as const,
|
||||||
|
requestId: ++poiDetailRequestId
|
||||||
|
})
|
||||||
|
pendingModelResetAfterPoiDetail.value = context
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelPoiDetailReturn = () => {
|
||||||
|
pendingModelResetAfterPoiDetail.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumePoiDetailReturn = () => {
|
||||||
|
const context = pendingModelResetAfterPoiDetail.value
|
||||||
|
pendingModelResetAfterPoiDetail.value = null
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// All async focus/floor operations compare this generation before committing.
|
||||||
|
const invalidatePendingRequests = () => {
|
||||||
|
requestGeneration.value += 1
|
||||||
|
return requestGeneration.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialState: readonly(initialState),
|
||||||
|
pendingModelResetAfterPoiDetail: readonly(pendingModelResetAfterPoiDetail),
|
||||||
|
requestGeneration: readonly(requestGeneration),
|
||||||
|
initializeModel,
|
||||||
|
beginPoiDetailReturn,
|
||||||
|
cancelPoiDetailReturn,
|
||||||
|
consumePoiDetailReturn,
|
||||||
|
invalidatePendingRequests
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
:active-floor="activeFloor"
|
:active-floor="activeFloor"
|
||||||
map-type="indoor"
|
map-type="indoor"
|
||||||
:tools="[]"
|
:tools="[]"
|
||||||
:indoor-model-source="indoorModelSource"
|
:indoor-model-source="modelSource"
|
||||||
:floors="guideFloors"
|
:floors="guideFloors"
|
||||||
:normalize-floor-id="normalizeGuideFloorId"
|
:normalize-floor-id="normalizeGuideFloorId"
|
||||||
:target-focus-request="targetFocusRequest"
|
:target-focus-request="targetFocusRequest"
|
||||||
@@ -105,18 +105,17 @@ import type {
|
|||||||
MuseumPoi
|
MuseumPoi
|
||||||
} from '@/domain/museum'
|
} from '@/domain/museum'
|
||||||
import type {
|
import type {
|
||||||
GuideModelSource,
|
|
||||||
GuideRenderPoi
|
GuideRenderPoi
|
||||||
} from '@/domain/guideModel'
|
} from '@/domain/guideModel'
|
||||||
|
|
||||||
const indoorModelSource: GuideModelSource = guideUseCase.getModelSource()
|
const modelSource = guideUseCase.getModelSource()
|
||||||
const guideFloors = ref<MuseumFloor[]>([])
|
const guideFloors = ref<MuseumFloor[]>([])
|
||||||
const normalizeGuideFloorId = (labelOrId: string) =>
|
const normalizeGuideFloorId = (labelOrId: string) =>
|
||||||
guideFloors.value.find((floor) => floor.id === labelOrId || floor.label === labelOrId)?.id
|
guideFloors.value.find((floor) => floor.id === labelOrId || floor.label === labelOrId)?.id
|
||||||
|| guideUseCase.normalizeFloorId(labelOrId)
|
|| guideUseCase.normalizeFloorId(labelOrId)
|
||||||
|
const requestedDetailFloorId = ref('')
|
||||||
const activeMode = ref<'2d' | '3d'>('3d')
|
const activeMode = ref<'2d' | '3d'>('3d')
|
||||||
const activeFloor = ref('1F')
|
const activeFloor = ref('')
|
||||||
const requestedTargetFloorId = ref('')
|
|
||||||
const targetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
|
const targetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
|
||||||
const targetFocusRequestId = ref(0)
|
const targetFocusRequestId = ref(0)
|
||||||
const locationErrorMessage = ref('')
|
const locationErrorMessage = ref('')
|
||||||
@@ -146,68 +145,13 @@ const searchContext = ref<FacilityDetailSearchContext>(
|
|||||||
const loadGuideFloors = async () => {
|
const loadGuideFloors = async () => {
|
||||||
try {
|
try {
|
||||||
guideFloors.value = await guideUseCase.getFloors()
|
guideFloors.value = await guideUseCase.getFloors()
|
||||||
const floors = guideFloors.value
|
if (!activeFloor.value) activeFloor.value = guideFloors.value[0]?.id || ''
|
||||||
const normalizedActiveFloorId = activeFloor.value
|
|
||||||
? normalizeGuideFloorId(activeFloor.value)
|
|
||||||
: ''
|
|
||||||
const defaultFloor = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id
|
|
||||||
|| floors.find((floor) => floor.label === '1F')?.id
|
|
||||||
|| floors[0]?.id
|
|
||||||
|| activeFloor.value
|
|
||||||
activeFloor.value = defaultFloor
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载导览楼层失败:', error)
|
console.error('加载导览楼层失败:', error)
|
||||||
guideFloors.value = []
|
guideFloors.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusFacilityPoi = (poi: MuseumPoi) => {
|
|
||||||
if (!poi.floorId) {
|
|
||||||
locationErrorMessage.value = '该点位缺少楼层信息,暂时无法在地图中定位。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
locationErrorMessage.value = ''
|
|
||||||
activeMode.value = '3d'
|
|
||||||
activeFloor.value = poi.floorId
|
|
||||||
targetFocusRequestId.value += 1
|
|
||||||
targetFocusRequest.value = toTargetFocusRequestViewModel({
|
|
||||||
poiId: poi.id,
|
|
||||||
name: poi.name,
|
|
||||||
floorId: poi.floorId,
|
|
||||||
floorLabel: poi.floorLabel,
|
|
||||||
primaryCategoryZh: poi.primaryCategory.label,
|
|
||||||
positionGltf: poi.positionGltf,
|
|
||||||
sourceObjectName: poi.sourceObjectName
|
|
||||||
}, targetFocusRequestId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusRenderPoi = (poi: GuideRenderPoi) => {
|
|
||||||
if (!poi.floorId) {
|
|
||||||
locationErrorMessage.value = '该点位缺少楼层信息,暂时无法在地图中定位。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
locationErrorMessage.value = ''
|
|
||||||
const floorLabel = guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
|
|
||||||
activeMode.value = '3d'
|
|
||||||
activeFloor.value = poi.floorId
|
|
||||||
targetFocusRequestId.value += 1
|
|
||||||
targetFocusRequest.value = toTargetFocusRequestViewModel({
|
|
||||||
poiId: poi.id,
|
|
||||||
name: poi.name,
|
|
||||||
floorId: poi.floorId,
|
|
||||||
floorLabel,
|
|
||||||
primaryCategoryZh: poi.primaryCategoryZh,
|
|
||||||
positionGltf: poi.positionGltf,
|
|
||||||
sourceObjectName: poi.sourceObjectName,
|
|
||||||
kind: poi.kind,
|
|
||||||
hallId: poi.hallId,
|
|
||||||
hallName: poi.hallName,
|
|
||||||
entrances: poi.entrances
|
|
||||||
}, targetFocusRequestId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRenderPoiFloorLabel = (poi: GuideRenderPoi) =>
|
const getRenderPoiFloorLabel = (poi: GuideRenderPoi) =>
|
||||||
guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
|
guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
|
||||||
|
|
||||||
@@ -224,6 +168,50 @@ const applyRenderPoiToFacility = (poi: GuideRenderPoi) => {
|
|||||||
focusRenderPoi(poi)
|
focusRenderPoi(poi)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focusFacilityPoi = (poi: MuseumPoi) => {
|
||||||
|
if (!poi.floorId) {
|
||||||
|
locationErrorMessage.value = '该点位缺少楼层信息,暂时无法提供位置预览。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locationErrorMessage.value = ''
|
||||||
|
activeFloor.value = poi.floorId
|
||||||
|
targetFocusRequestId.value += 1
|
||||||
|
targetFocusRequest.value = toTargetFocusRequestViewModel({
|
||||||
|
poiId: poi.id,
|
||||||
|
name: poi.name,
|
||||||
|
floorId: poi.floorId,
|
||||||
|
floorLabel: poi.floorLabel,
|
||||||
|
primaryCategoryZh: poi.primaryCategory.label,
|
||||||
|
positionGltf: poi.positionGltf,
|
||||||
|
sourceObjectName: poi.sourceObjectName
|
||||||
|
}, targetFocusRequestId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusRenderPoi = (poi: GuideRenderPoi) => {
|
||||||
|
if (!poi.floorId) {
|
||||||
|
locationErrorMessage.value = '该点位缺少楼层信息,暂时无法提供位置预览。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locationErrorMessage.value = ''
|
||||||
|
activeFloor.value = poi.floorId
|
||||||
|
targetFocusRequestId.value += 1
|
||||||
|
targetFocusRequest.value = toTargetFocusRequestViewModel({
|
||||||
|
poiId: poi.id,
|
||||||
|
name: poi.name,
|
||||||
|
floorId: poi.floorId,
|
||||||
|
floorLabel: getRenderPoiFloorLabel(poi),
|
||||||
|
primaryCategoryZh: poi.primaryCategoryZh,
|
||||||
|
positionGltf: poi.positionGltf,
|
||||||
|
sourceObjectName: poi.sourceObjectName,
|
||||||
|
kind: poi.kind,
|
||||||
|
hallId: poi.hallId,
|
||||||
|
hallName: poi.hallName,
|
||||||
|
entrances: poi.entrances
|
||||||
|
}, targetFocusRequestId.value)
|
||||||
|
}
|
||||||
|
|
||||||
const normalizePoiName = (value: string) => value
|
const normalizePoiName = (value: string) => value
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[\s()()【】[\]_-]/g, '')
|
.replace(/[\s()()【】[\]_-]/g, '')
|
||||||
@@ -242,7 +230,7 @@ const decodeRouteText = (value: unknown) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isPoiOnRequestedFloor = (poi: Pick<MuseumPoi | GuideRenderPoi, 'floorId'>) => (
|
const isPoiOnRequestedFloor = (poi: Pick<MuseumPoi | GuideRenderPoi, 'floorId'>) => (
|
||||||
!requestedTargetFloorId.value || poi.floorId === requestedTargetFloorId.value
|
!requestedDetailFloorId.value || poi.floorId === requestedDetailFloorId.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const resolvePoiFromHall = async () => {
|
const resolvePoiFromHall = async () => {
|
||||||
@@ -269,7 +257,7 @@ const resolveFacilityPoi = async () => {
|
|||||||
if (!normalizedTarget) return null
|
if (!normalizedTarget) return null
|
||||||
|
|
||||||
const candidates = await guideUseCase.searchPois(facility.value.name)
|
const candidates = await guideUseCase.searchPois(facility.value.name)
|
||||||
const floorMatchedCandidates = requestedTargetFloorId.value
|
const floorMatchedCandidates = requestedDetailFloorId.value
|
||||||
? candidates.filter(isPoiOnRequestedFloor)
|
? candidates.filter(isPoiOnRequestedFloor)
|
||||||
: candidates
|
: candidates
|
||||||
const candidatesToMatch = floorMatchedCandidates.length ? floorMatchedCandidates : candidates
|
const candidatesToMatch = floorMatchedCandidates.length ? floorMatchedCandidates : candidates
|
||||||
@@ -289,16 +277,16 @@ const resolveRenderPoi = async () => {
|
|||||||
const normalizedTarget = normalizePoiName(facility.value.name)
|
const normalizedTarget = normalizePoiName(facility.value.name)
|
||||||
if (!resolveByExactId && !normalizedTarget) return null
|
if (!resolveByExactId && !normalizedTarget) return null
|
||||||
|
|
||||||
const modelPackage = await indoorModelSource.loadPackage()
|
const modelPackage = await modelSource.loadPackage()
|
||||||
const floorsToSearch = requestedTargetFloorId.value
|
const floorsToSearch = requestedDetailFloorId.value
|
||||||
? modelPackage.floors.filter((floor) => floor.floorId === requestedTargetFloorId.value)
|
? modelPackage.floors.filter((floor) => floor.floorId === requestedDetailFloorId.value)
|
||||||
: modelPackage.floors
|
: modelPackage.floors
|
||||||
|
|
||||||
for (const floor of floorsToSearch) {
|
for (const floor of floorsToSearch) {
|
||||||
const floorId = floor.floorId
|
const floorId = floor.floorId
|
||||||
let pois: GuideRenderPoi[] = []
|
let pois: GuideRenderPoi[] = []
|
||||||
try {
|
try {
|
||||||
pois = await indoorModelSource.loadFloorPois(floorId)
|
pois = await modelSource.loadFloorPois(floorId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
resolutionLoadFailed.value = true
|
resolutionLoadFailed.value = true
|
||||||
console.warn(`楼层 ${floorId} 的模型点位加载失败:`, error)
|
console.warn(`楼层 ${floorId} 的模型点位加载失败:`, error)
|
||||||
@@ -362,10 +350,8 @@ onLoad(async (options: Record<string, unknown> = {}) => {
|
|||||||
const requestedFloorId = decodeRouteText(options.floorId)
|
const requestedFloorId = decodeRouteText(options.floorId)
|
||||||
|| searchContext.value.searchFloorId
|
|| searchContext.value.searchFloorId
|
||||||
if (requestedFloorId) {
|
if (requestedFloorId) {
|
||||||
requestedTargetFloorId.value = normalizeGuideFloorId(requestedFloorId)
|
requestedDetailFloorId.value = normalizeGuideFloorId(requestedFloorId)
|
||||||
if (requestedTargetFloorId.value) {
|
activeFloor.value = requestedDetailFloorId.value || activeFloor.value
|
||||||
activeFloor.value = requestedTargetFloorId.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedFloorLabel = decodeRouteText(options.floorLabel)
|
const requestedFloorLabel = decodeRouteText(options.floorLabel)
|
||||||
@@ -403,7 +389,7 @@ onLoad(async (options: Record<string, unknown> = {}) => {
|
|||||||
console.error('加载设施位置详情失败:', error)
|
console.error('加载设施位置详情失败:', error)
|
||||||
locationErrorMessage.value = '位置数据加载失败,请稍后重试。'
|
locationErrorMessage.value = '位置数据加载失败,请稍后重试。'
|
||||||
} finally {
|
} finally {
|
||||||
// 目标楼层与聚焦请求准备完成后再挂载 ThreeMap,避免默认楼层的中间帧。
|
// Wait for the target context so the preview does not flash an unrelated floor.
|
||||||
mapContextReady.value = true
|
mapContextReady.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -419,8 +405,8 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
locationErrorMessage.value = result.status === 'missing'
|
locationErrorMessage.value = result.status === 'missing'
|
||||||
? '该点位缺少可用的位置数据,暂时无法在地图中定位。'
|
? '该点位缺少可用的位置数据,暂时无法提供位置预览。'
|
||||||
: '地图定位暂不可用,请稍后重试。'
|
: '位置预览暂不可用,请稍后重试。'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseDetailPanel = () => {
|
const handleCloseDetailPanel = () => {
|
||||||
@@ -432,21 +418,7 @@ const handleTopTabChange = (tab: GuideTopTab) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePageBack = () => {
|
const handlePageBack = () => {
|
||||||
if (searchContext.value.source === 'search' && searchContext.value.resultCount === 1) {
|
uni.navigateBack({ delta: 1 })
|
||||||
uni.reLaunch({
|
|
||||||
url: '/pages/index/index'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.navigateBack({
|
|
||||||
delta: 1,
|
|
||||||
fail: () => {
|
|
||||||
uni.reLaunch({
|
|
||||||
url: '/pages/index/index'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -325,9 +325,9 @@ import type {
|
|||||||
PoiSearchContext
|
PoiSearchContext
|
||||||
} from '@/domain/poiSearch'
|
} from '@/domain/poiSearch'
|
||||||
import {
|
import {
|
||||||
toTargetFocusRequestViewModel,
|
|
||||||
type TargetPoiFocusRequestViewModel
|
type TargetPoiFocusRequestViewModel
|
||||||
} from '@/view-models/guideViewModels'
|
} from '@/view-models/guideViewModels'
|
||||||
|
import { useGuideModelState, type PoiDetailReturnContext } from '@/composables/useGuideModelState'
|
||||||
import {
|
import {
|
||||||
ARRIVAL_TARGETS_BY_TYPE,
|
ARRIVAL_TARGETS_BY_TYPE,
|
||||||
type ArrivalSearchTarget,
|
type ArrivalSearchTarget,
|
||||||
@@ -344,6 +344,8 @@ import type {
|
|||||||
type GuideIndoorView = 'overview' | 'floor' | 'multi'
|
type GuideIndoorView = 'overview' | 'floor' | 'multi'
|
||||||
type LayerDisplayMode = 'single' | 'multi'
|
type LayerDisplayMode = 'single' | 'multi'
|
||||||
|
|
||||||
|
const guideModelState = useGuideModelState()
|
||||||
|
|
||||||
const isIndexHashRoute = () => {
|
const isIndexHashRoute = () => {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false
|
||||||
|
|
||||||
@@ -416,6 +418,12 @@ const returningToOverview = ref(false)
|
|||||||
const guideMapShellRef = ref<{
|
const guideMapShellRef = ref<{
|
||||||
clearRoute?: () => void
|
clearRoute?: () => void
|
||||||
showOverview?: () => Promise<void> | void
|
showOverview?: () => Promise<void> | void
|
||||||
|
resetToViewBaseline?: (options: {
|
||||||
|
view: 'overview' | 'floor'
|
||||||
|
floorId?: string
|
||||||
|
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||||||
|
}) => Promise<boolean> | boolean
|
||||||
|
resetToInitialState?: () => Promise<boolean> | boolean
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const homeSearchPanelRef = ref<{
|
const homeSearchPanelRef = ref<{
|
||||||
collapseHomePanel?: () => void
|
collapseHomePanel?: () => void
|
||||||
@@ -427,9 +435,7 @@ const homeSearchExpanded = ref(false)
|
|||||||
const homeCategoryModeActive = ref(false)
|
const homeCategoryModeActive = ref(false)
|
||||||
const searchVisiblePoiIds = ref<string[] | null>(null)
|
const searchVisiblePoiIds = ref<string[] | null>(null)
|
||||||
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
|
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
|
||||||
let searchTargetFocusRequestId = 0
|
let pendingGuideModelResetUntilReady: PoiDetailReturnContext | null = null
|
||||||
let pendingCollapseSearchAfterDetail = false
|
|
||||||
let pendingSearchDetailFocusRequest: TargetPoiFocusRequestViewModel | null = null
|
|
||||||
|
|
||||||
type PoiSearchOverlayHistoryState = {
|
type PoiSearchOverlayHistoryState = {
|
||||||
museumGuideOverlay?: 'poi-search'
|
museumGuideOverlay?: 'poi-search'
|
||||||
@@ -916,6 +922,7 @@ const loadGuideFloors = async () => {
|
|||||||
|| floors[0]?.id
|
|| floors[0]?.id
|
||||||
|| activeGuideFloor.value
|
|| activeGuideFloor.value
|
||||||
renderedFloorId.value = activeGuideFloor.value
|
renderedFloorId.value = activeGuideFloor.value
|
||||||
|
guideModelState.initializeModel(activeGuideFloor.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载导览楼层失败:', error)
|
console.error('加载导览楼层失败:', error)
|
||||||
guideFloors.value = []
|
guideFloors.value = []
|
||||||
@@ -1031,7 +1038,13 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
|
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
|
||||||
|
guideModelState.initializeModel(event.floorId || activeGuideFloor.value)
|
||||||
settleLaunchOverlayByModel('ready', event)
|
settleLaunchOverlayByModel('ready', event)
|
||||||
|
if (pendingGuideModelResetUntilReady) {
|
||||||
|
const context = pendingGuideModelResetUntilReady
|
||||||
|
pendingGuideModelResetUntilReady = null
|
||||||
|
void resetGuideModelToInitial({ reason: 'queued-poi-detail-close', floorId: context.floorId })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInitialModelFailed = (event: { view: GuideIndoorView; floorId?: string; message: string; elapsedMs?: number }) => {
|
const handleInitialModelFailed = (event: { view: GuideIndoorView; floorId?: string; message: string; elapsedMs?: number }) => {
|
||||||
@@ -1449,27 +1462,60 @@ const closeHomeSearchDock = () => {
|
|||||||
homeSearchExpanded.value = false
|
homeSearchExpanded.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetGuideModelToInitial = async ({
|
||||||
|
reason,
|
||||||
|
floorId
|
||||||
|
}: {
|
||||||
|
reason: 'poi-detail-close' | 'queued-poi-detail-close'
|
||||||
|
floorId: string
|
||||||
|
}) => {
|
||||||
|
const initial = guideModelState.initialState.value
|
||||||
|
guideModelState.invalidatePendingRequests()
|
||||||
|
searchTargetFocusRequest.value = null
|
||||||
|
searchVisiblePoiIds.value = null
|
||||||
|
homeCategoryModeActive.value = false
|
||||||
|
selectedGuidePoi.value = null
|
||||||
|
isPoiCardCollapsed.value = false
|
||||||
|
showRoutePlanner.value = false
|
||||||
|
activeRoutePreview.value = null
|
||||||
|
routeStartPoint.value = null
|
||||||
|
routeEndPoint.value = null
|
||||||
|
routePlanError.value = ''
|
||||||
|
isSimulatingRoute.value = false
|
||||||
|
requestedFloorId.value = ''
|
||||||
|
requestedFloorLabel.value = ''
|
||||||
|
loadingFloorId.value = ''
|
||||||
|
failedFloorId.value = ''
|
||||||
|
is3DMode.value = true
|
||||||
|
guideOutdoorState.value = 'home'
|
||||||
|
indoorView.value = 'floor'
|
||||||
|
activeGuideFloor.value = floorId || initial.defaultActiveFloorId || activeGuideFloor.value
|
||||||
|
renderedFloorId.value = activeGuideFloor.value
|
||||||
|
closeArrivalPanel()
|
||||||
|
await homeSearchPanelRef.value?.resetSearchState?.()
|
||||||
|
closeHomeSearchDock()
|
||||||
|
|
||||||
|
const resetApplied = await guideMapShellRef.value?.resetToViewBaseline?.({
|
||||||
|
view: 'floor',
|
||||||
|
floorId: activeGuideFloor.value,
|
||||||
|
reason: 'poi-detail-close'
|
||||||
|
})
|
||||||
|
if (!resetApplied) {
|
||||||
|
// The renderer is still becoming ready; retain only the latest requested reset.
|
||||||
|
pendingGuideModelResetUntilReady = Object.freeze({
|
||||||
|
floorId: activeGuideFloor.value,
|
||||||
|
resetMode: 'floor-baseline',
|
||||||
|
requestId: guideModelState.invalidatePendingRequests()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.info('馆内三维模型已恢复统一初始状态:', { reason })
|
||||||
|
}
|
||||||
|
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (pendingCollapseSearchAfterDetail) {
|
const detailReturnContext = guideModelState.consumePoiDetailReturn()
|
||||||
pendingCollapseSearchAfterDetail = false
|
if (detailReturnContext) {
|
||||||
const pendingFocusRequest = pendingSearchDetailFocusRequest
|
await resetGuideModelToInitial({ reason: 'poi-detail-close', floorId: detailReturnContext.floorId })
|
||||||
pendingSearchDetailFocusRequest = null
|
|
||||||
await homeSearchPanelRef.value?.returnToCollapsedAfterDetail?.()
|
|
||||||
homeSearchExpanded.value = false
|
|
||||||
homeCategoryModeActive.value = false
|
|
||||||
searchVisiblePoiIds.value = null
|
|
||||||
if (pendingFocusRequest) {
|
|
||||||
closeArrivalPanel()
|
|
||||||
showRoutePlanner.value = false
|
|
||||||
isSimulatingRoute.value = false
|
|
||||||
selectedGuidePoi.value = null
|
|
||||||
isPoiCardCollapsed.value = false
|
|
||||||
is3DMode.value = true
|
|
||||||
indoorView.value = 'floor'
|
|
||||||
activeGuideFloor.value = pendingFocusRequest.floorId
|
|
||||||
searchTargetFocusRequest.value = pendingFocusRequest
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1569,29 +1615,13 @@ const createSearchDetailUrl = (poi: MuseumPoi, context: PoiSearchContext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
|
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
|
||||||
searchTargetFocusRequestId += 1
|
// The homepage keeps its renderer mounted; detail close is consumed by onShow once.
|
||||||
pendingSearchDetailFocusRequest = toTargetFocusRequestViewModel({
|
|
||||||
poiId: poi.id,
|
|
||||||
name: poi.name,
|
|
||||||
floorId: poi.floorId,
|
|
||||||
floorLabel: poi.floorLabel,
|
|
||||||
primaryCategoryZh: poi.primaryCategory.label,
|
|
||||||
positionGltf: poi.positionGltf,
|
|
||||||
sourceObjectName: poi.sourceObjectName,
|
|
||||||
kind: poi.kind,
|
|
||||||
hallId: poi.hallId,
|
|
||||||
hallName: poi.hallName,
|
|
||||||
entrances: poi.entrances
|
|
||||||
}, searchTargetFocusRequestId)
|
|
||||||
|
|
||||||
// 详情页会一次性挂载目标楼层;首页不再先启动并销毁 ThreeMap。
|
|
||||||
searchTargetFocusRequest.value = null
|
searchTargetFocusRequest.value = null
|
||||||
pendingCollapseSearchAfterDetail = true
|
guideModelState.beginPoiDetailReturn(poi.floorId)
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: createSearchDetailUrl(poi, context),
|
url: createSearchDetailUrl(poi, context),
|
||||||
fail: () => {
|
fail: () => {
|
||||||
pendingCollapseSearchAfterDetail = false
|
guideModelState.cancelPoiDetailReturn()
|
||||||
pendingSearchDetailFocusRequest = null
|
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '点位详情打开失败,请重试',
|
title: '点位详情打开失败,请重试',
|
||||||
icon: 'none'
|
icon: 'none'
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface VisualReport {
|
|||||||
anchors: AnchorReport[]
|
anchors: AnchorReport[]
|
||||||
activeView: string
|
activeView: string
|
||||||
floorId: string
|
floorId: string
|
||||||
|
activeFocusPoiId: string
|
||||||
|
hasPendingTargetFocus: boolean
|
||||||
isCameraTweening: boolean
|
isCameraTweening: boolean
|
||||||
poiFocusCameraAnimationCount: number
|
poiFocusCameraAnimationCount: number
|
||||||
cameraSnapshotRestoreCount: number
|
cameraSnapshotRestoreCount: number
|
||||||
@@ -58,6 +60,13 @@ interface VisualStabilityApi {
|
|||||||
getFloors: () => FloorReport[]
|
getFloors: () => FloorReport[]
|
||||||
switchFloor: (floorId: string) => Promise<void>
|
switchFloor: (floorId: string) => Promise<void>
|
||||||
showOverview: () => Promise<void>
|
showOverview: () => Promise<void>
|
||||||
|
resetToViewBaseline: (options: {
|
||||||
|
view: 'overview' | 'floor'
|
||||||
|
floorId?: string
|
||||||
|
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||||||
|
}) => Promise<boolean>
|
||||||
|
resetToInitialState: () => Promise<boolean>
|
||||||
|
getFloorBaseline: (floorId: string) => CameraReport | null
|
||||||
getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]>
|
getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]>
|
||||||
focusTargetPoi: (request: {
|
focusTargetPoi: (request: {
|
||||||
requestId: number | string
|
requestId: number | string
|
||||||
@@ -399,3 +408,64 @@ test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and do
|
|||||||
await switchFloor(page, testInfo, l1, 'search-focus-clear-lminus1-l1')
|
await switchFloor(page, testInfo, l1, 'search-focus-clear-lminus1-l1')
|
||||||
await showOverview(page, testInfo, 'search-focus-clear-l1-exterior')
|
await showOverview(page, testInfo, 'search-focus-clear-l1-exterior')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('reset invalidates an in-flight cross-floor focus before it can overwrite the initial state', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
|
||||||
|
page.getByText('馆内', { exact: true }).click()
|
||||||
|
])
|
||||||
|
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
|
||||||
|
await expect.poll(async () => (await getApiState(page))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0)
|
||||||
|
const state = await getApiState(page)
|
||||||
|
expect(state).not.toBeNull()
|
||||||
|
const targetFloor = state!.floors.find((floor) => floor.floorId !== state!.report.floorId)?.floorId
|
||||||
|
|| state!.floors[0]!.floorId
|
||||||
|
const targetPoi = (await getFloorPois(page, targetFloor))[0]!
|
||||||
|
|
||||||
|
await page.evaluate(async (request) => {
|
||||||
|
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||||||
|
.__GUIDE_3D_VISUAL_STABILITY__
|
||||||
|
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||||||
|
const focus = api.focusTargetPoi({ requestId: 'reset-race', ...request })
|
||||||
|
await api.resetToInitialState()
|
||||||
|
await focus
|
||||||
|
}, targetPoi)
|
||||||
|
|
||||||
|
const report = await getReport(page)
|
||||||
|
expect(report.activeView).toBe('overview')
|
||||||
|
expect(report.activeFocusPoiId).toBe('')
|
||||||
|
expect(report.hasPendingTargetFocus).toBe(false)
|
||||||
|
expect(report.focus).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('floor view baseline is deterministic across repeated resets', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
|
||||||
|
page.getByText('馆内', { exact: true }).click()
|
||||||
|
])
|
||||||
|
await expect.poll(async () => (await getApiState(page))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0)
|
||||||
|
const state = await getApiState(page)
|
||||||
|
expect(state).not.toBeNull()
|
||||||
|
|
||||||
|
for (const floor of state!.floors) {
|
||||||
|
await page.evaluate(async (floorId) => {
|
||||||
|
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||||||
|
.__GUIDE_3D_VISUAL_STABILITY__
|
||||||
|
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||||||
|
await api.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
|
||||||
|
}, floor.floorId)
|
||||||
|
const first = await getReport(page)
|
||||||
|
await page.evaluate(async (floorId) => {
|
||||||
|
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||||||
|
.__GUIDE_3D_VISUAL_STABILITY__
|
||||||
|
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||||||
|
await api.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
|
||||||
|
}, floor.floorId)
|
||||||
|
const second = await getReport(page)
|
||||||
|
expect(second.activeView).toBe('floor')
|
||||||
|
expect(second.floorId).toBe(floor.floorId)
|
||||||
|
expect(getMaximumCameraDelta(first.camera, second.camera)).toBeLessThan(1e-6)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -143,10 +143,9 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('点位详情实际渲染与返回闭环', () => {
|
describe('点位详情实际渲染与返回闭环', () => {
|
||||||
it('解析目标楼层和点位后才挂载地图,避免默认楼层中间帧', async () => {
|
it('目标楼层与点位就绪后挂载位置预览,避免默认楼层中间帧', async () => {
|
||||||
const wrapper = mountDetail()
|
const wrapper = mountDetail()
|
||||||
|
|
||||||
expect(wrapper.find('[data-testid="guide-map-shell"]').exists()).toBe(false)
|
|
||||||
expect(wrapper.get('[data-testid="facility-map-loading"]').text()).toContain('正在准备位置预览')
|
expect(wrapper.get('[data-testid="facility-map-loading"]').text()).toContain('正在准备位置预览')
|
||||||
|
|
||||||
await loadDetail({
|
await loadDetail({
|
||||||
@@ -163,7 +162,7 @@ describe('点位详情实际渲染与返回闭环', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('只展示真实详情字段,并向地图发送正确楼层与点位定位请求', async () => {
|
it('只展示真实详情字段,并发送正确的位置预览请求', async () => {
|
||||||
const wrapper = mountDetail()
|
const wrapper = mountDetail()
|
||||||
|
|
||||||
await loadDetail({
|
await loadDetail({
|
||||||
@@ -174,7 +173,6 @@ describe('点位详情实际渲染与返回闭环', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const sheet = wrapper.get('[data-testid="facility-detail-sheet"]')
|
const sheet = wrapper.get('[data-testid="facility-detail-sheet"]')
|
||||||
const mapShell = wrapper.getComponent(GuideMapShellStub)
|
|
||||||
expect(sheet.text()).toContain('二层卫生间')
|
expect(sheet.text()).toContain('二层卫生间')
|
||||||
expect(sheet.text()).toContain('类别洗手间')
|
expect(sheet.text()).toContain('类别洗手间')
|
||||||
expect(sheet.text()).toContain('楼层2F')
|
expect(sheet.text()).toContain('楼层2F')
|
||||||
@@ -182,10 +180,11 @@ describe('点位详情实际渲染与返回闭环', () => {
|
|||||||
expect(sheet.text()).not.toContain('展示点位')
|
expect(sheet.text()).not.toContain('展示点位')
|
||||||
expect(sheet.text()).not.toContain('已在对应楼层模型中标出点位')
|
expect(sheet.text()).not.toContain('已在对应楼层模型中标出点位')
|
||||||
expect(wrapper.find('.detail-restore').exists()).toBe(false)
|
expect(wrapper.find('.detail-restore').exists()).toBe(false)
|
||||||
|
const mapShell = wrapper.getComponent(GuideMapShellStub)
|
||||||
expect(mapShell.props('activeFloor')).toBe('floor-l2')
|
expect(mapShell.props('activeFloor')).toBe('floor-l2')
|
||||||
expect(mapShell.props('targetFocusRequest')).toMatchObject({
|
expect(mapShell.props('targetFocusRequest')).toMatchObject({
|
||||||
poiId: 'poi-restroom-l2',
|
poiId: facilityPoi.id,
|
||||||
floorId: 'floor-l2',
|
floorId: facilityPoi.floorId,
|
||||||
positionGltf: [12, 3, -8]
|
positionGltf: [12, 3, -8]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -223,7 +222,7 @@ describe('点位详情实际渲染与返回闭环', () => {
|
|||||||
expect(uni.reLaunch).not.toHaveBeenCalled()
|
expect(uni.reLaunch).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('搜索单结果关闭详情后回到首页', async () => {
|
it('搜索单结果关闭详情同样仅使用页面栈返回', async () => {
|
||||||
const wrapper = mountDetail()
|
const wrapper = mountDetail()
|
||||||
await loadDetail({
|
await loadDetail({
|
||||||
source: 'search',
|
source: 'search',
|
||||||
@@ -234,8 +233,8 @@ describe('点位详情实际渲染与返回闭环', () => {
|
|||||||
|
|
||||||
await wrapper.get('[data-testid="facility-detail-close"]').trigger('tap')
|
await wrapper.get('[data-testid="facility-detail-close"]').trigger('tap')
|
||||||
|
|
||||||
expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/index/index' })
|
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
|
||||||
expect(uni.navigateBack).not.toHaveBeenCalled()
|
expect(uni.reLaunch).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('直接进入详情时使用常规返回', async () => {
|
it('直接进入详情时使用常规返回', async () => {
|
||||||
@@ -248,11 +247,10 @@ describe('点位详情实际渲染与返回闭环', () => {
|
|||||||
expect(uni.reLaunch).not.toHaveBeenCalled()
|
expect(uni.reLaunch).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('地图报告点位缺失或定位失败时显示面向用户的错误反馈', async () => {
|
it('位置预览失败时显示面向用户的错误反馈', async () => {
|
||||||
const wrapper = mountDetail()
|
const wrapper = mountDetail()
|
||||||
await loadDetail({ id: facilityPoi.id, target: facilityPoi.name })
|
await loadDetail({ id: facilityPoi.id, target: facilityPoi.name })
|
||||||
const mapShell = wrapper.getComponent(GuideMapShellStub)
|
const mapShell = wrapper.getComponent(GuideMapShellStub)
|
||||||
|
|
||||||
mapShell.vm.$emit('target-focus', {
|
mapShell.vm.$emit('target-focus', {
|
||||||
requestId: 1,
|
requestId: 1,
|
||||||
poiId: facilityPoi.id,
|
poiId: facilityPoi.id,
|
||||||
@@ -260,18 +258,7 @@ describe('点位详情实际渲染与返回闭环', () => {
|
|||||||
status: 'missing'
|
status: 'missing'
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.get('[data-testid="facility-location-error"]').text())
|
expect(wrapper.get('[data-testid="facility-location-error"]').text()).toContain('缺少可用的位置数据')
|
||||||
.toContain('缺少可用的位置数据')
|
|
||||||
|
|
||||||
mapShell.vm.$emit('target-focus', {
|
|
||||||
requestId: 1,
|
|
||||||
poiId: facilityPoi.id,
|
|
||||||
floorId: facilityPoi.floorId,
|
|
||||||
status: 'error'
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.get('[data-testid="facility-location-error"]').text())
|
|
||||||
.toContain('地图定位暂不可用')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('位置数据源加载失败时提供明确重试反馈', async () => {
|
it('位置数据源加载失败时提供明确重试反馈', async () => {
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
|
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('分类结果进入详情时不触发首页地图,返回后才恢复目标焦点与楼层', async () => {
|
it('分类结果进入详情后恢复目标楼层标准视图', async () => {
|
||||||
const wrapper = await mountIndex()
|
const wrapper = await mountIndex()
|
||||||
const search = wrapper.getComponent(PoiSearchPanelStub)
|
const search = wrapper.getComponent(PoiSearchPanelStub)
|
||||||
const map = wrapper.getComponent(GuideMapShellStub)
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
@@ -354,17 +354,19 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
await testState.onShowHandler?.()
|
await testState.onShowHandler?.()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(testState.searchCollapseAfterDetailCount).toBe(1)
|
expect(testState.searchResetCount).toBeGreaterThan(0)
|
||||||
expect(testState.searchRestoreCount).toBe(0)
|
expect(testState.searchRestoreCount).toBe(0)
|
||||||
expect(map.props('activeFloor')).toBe('L2')
|
expect(map.props('activeFloor')).toBe('L2')
|
||||||
expect(map.props('visiblePoiIds')).toBeNull()
|
expect(map.props('visiblePoiIds')).toBeNull()
|
||||||
expect(map.props('targetFocusRequest')).toMatchObject({
|
expect(map.props('targetFocusRequest')).toBeNull()
|
||||||
poiId: poi.id,
|
|
||||||
floorId: 'L2'
|
const resetCount = testState.searchResetCount
|
||||||
})
|
await testState.onShowHandler?.()
|
||||||
|
await flushPromises()
|
||||||
|
expect(testState.searchResetCount).toBe(resetCount)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('全屏搜索结果进入详情并返回后同样恢复为收缩态', async () => {
|
it('全屏搜索结果进入详情并返回后同样恢复为默认收缩态', async () => {
|
||||||
const wrapper = await mountIndex()
|
const wrapper = await mountIndex()
|
||||||
const search = wrapper.getComponent(PoiSearchPanelStub)
|
const search = wrapper.getComponent(PoiSearchPanelStub)
|
||||||
const map = wrapper.getComponent(GuideMapShellStub)
|
const map = wrapper.getComponent(GuideMapShellStub)
|
||||||
@@ -379,10 +381,11 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
await testState.onShowHandler?.()
|
await testState.onShowHandler?.()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(testState.searchCollapseAfterDetailCount).toBe(1)
|
expect(testState.searchResetCount).toBeGreaterThan(0)
|
||||||
expect(testState.searchRestoreCount).toBe(0)
|
expect(testState.searchRestoreCount).toBe(0)
|
||||||
expect(map.props('visiblePoiIds')).toBeNull()
|
expect(map.props('visiblePoiIds')).toBeNull()
|
||||||
expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id })
|
expect(map.props('targetFocusRequest')).toBeNull()
|
||||||
|
expect(map.props('activeFloor')).toBe('L2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('页面重新显示时要求搜索面板恢复原列表位置', async () => {
|
it('页面重新显示时要求搜索面板恢复原列表位置', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user