Compare commits

..

2 Commits

Author SHA1 Message Date
lyf
c08e1afe85 统一楼层视觉基线与详情返回复位
Some checks failed
CI / verify (push) Has been cancelled
2026-07-14 23:17:33 +08:00
lyf
84fbbc0694 fix: 统一馆内三维模型返回初始状态 2026-07-14 21:57:31 +08:00
8 changed files with 560 additions and 169 deletions

View File

@@ -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
}) })

View File

@@ -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>

View 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
}
}

View File

@@ -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>

View File

@@ -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'

View File

@@ -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)
}
})

View File

@@ -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 () => {

View File

@@ -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 () => {