修复馆内模型初始视图与点位返回复位
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-15 18:20:18 +08:00
parent 1072b25c46
commit 6566d38cb0
11 changed files with 568 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
---
name: shenzhen-natural-museum-dev
description: Shenzhen Natural Museum mobile H5 spatial content guide architecture and client visual specification standards. Use when working in this frontend-miniapp project on guide and explain businesses, visual styling, typography, hall icons, homepage tab labels, indoor 3D/Three.js/GLB, SGS Map SDK H5 integration, iframe/postMessage SDK rendering, API/static data-source switching, POI/location preview, exhibit/hall/facility content, audio explanation, transcript/media data, Tencent Map/outdoor reference logic, static nav assets, Museum Content/Guide/Explain Data Access Layers, provider/adapter/repository architecture, route_graph/nav_data readiness, browser-simulated H5 user-flow audits, business-logic audits, engineering architecture reviews, legacy demo data cleanup, mobile overlay/canvas compatibility, and quality gates. Treat mini-program/mp-weixin as out of scope unless the user explicitly asks for it.
description: Shenzhen Natural Museum mobile H5 guide/explain architecture, indoor spatial rendering, content data, and client visual standards. Use in frontend-miniapp for guide/explain flows; visual styling, typography, hall icons, and homepage tabs; Three.js/GLB; unified guide-model initial state; deterministic overview/floor camera baselines; POI-detail return resets; async request invalidation; SGS Map SDK/iframe/postMessage integration; static/API data-source switching; POI/location previews; exhibit, hall, facility, audio, transcript, or media work; Tencent Map/outdoor logic; nav assets and data-layer architecture; route readiness; H5 browser-flow or business-logic audits; legacy-data cleanup; mobile canvas overlays; and quality gates. Treat mp-weixin as out of scope unless explicitly requested.
---
# Shenzhen Natural Museum Dev
@@ -234,6 +234,9 @@ Use this workflow when the user asks to connect the SGS Map SDK to the H5 guide
- Indoor 3D should load GLB/GLTF resources from the clean package under `static/nav-assets/...` while preserving texture/bin relationships.
- Initial indoor 3D entry should load the full building model when that is the product requirement; switch to a single floor only after zoom/focus/explicit floor action.
- Treat guide-model state restoration as one state-management contract, not as page-specific cleanup. Read and follow [references/guide-model-state-baseline.md](references/guide-model-state-baseline.md) whenever work touches initial model state, overview/floor camera baselines, POI focus, detail navigation/return, floor loading, reset behavior, or model async races.
- Keep `GUIDE_MODEL_INITIAL_STATE`, floor-view baselines, reset actions, and one-shot POI-detail return context behind the existing shared guide-model state boundary. Do not let search, map-click, hall, exhibit, or facility flows own separate reset behavior.
- Preserve the mounted `ThreeMap`, WebGL renderer, scene, and valid model cache during business-state reset. Never use reload, `reLaunch`, component keys, renderer recreation, or repeated cached GLB parsing as a reset mechanism.
- Provide side controls or equivalent for full-building/multi-floor versus single-floor views.
- Always provide loading, error, retry, and recovery paths for model loading.
- Keep canvas sizing constrained by the guide layout. It must not cover fixed navigation, cards, buttons, floor controls, search, top tabs, or explain/player controls.

View File

@@ -0,0 +1,113 @@
# Guide Model State And View Baseline Contract
Use this contract for every change or audit involving the indoor guide model, POI focus, floor switching, detail-page return, camera reset, or model async work.
## Contents
- State ownership and initial state
- Overview and floor baselines
- Reset action contract
- POI detail return contract
- Async invalidation
- Prohibited reset mechanisms
- Required tests and audit checks
## State Ownership And Initial State
- Keep one shared business-state owner, preferably the existing `useGuideModelState` boundary. Do not introduce per-page reset flags or duplicate state managers.
- Keep `GUIDE_MODEL_INITIAL_STATE` immutable. Define it as the stable overview state captured only after the exterior/full-building model becomes ready for the first time.
- Derive the default floor and initial camera from model configuration, floor data, or the first stable ready state. Never hardcode `1F`, a floor ID, camera coordinates, target, or distance.
- Route state changes through explicit actions such as `initializeModel`, `enterOverview`, `switchFloor`, `focusPoi`, `clearPoiSelection`, `enterRoutePreview`, and `resetToViewBaseline`.
- Include at least these business fields in the shared contract:
- 3D/indoor mode, `indoorView`, and `layerMode`.
- Default active floor, requested floor, loading floor, and committed/rendered floor.
- Camera position, up, fov, zoom, controls target, and camera distance.
- Selected POI, active/highlighted POI, target focus request, and pending target focus.
- Visible POI IDs, temporary focus markers, focus labels, pulses, and hall highlights.
- Route preview, navigation simulation, and automatic floor-switch state.
- Model ready/loading/error state, model-load version, focus request ID, and reset generation.
## Overview And Floor Baselines
- Preserve the first stable exterior/full-building ready snapshot as the overview baseline.
- Maintain deterministic `FloorViewBaseline` values keyed by stable floor ID and a baseline cache key.
- Inherit observation direction, camera up, fov, zoom, composition, and screen-offset rules from the overview baseline.
- Compute each floor's controls target and camera distance from that floor's visible model bounding box. Do not copy the overview's absolute position and target to every floor.
- Never capture a floor baseline from a user-dragged camera, an animated camera, or a POI-focus camera.
- Return identical position, target, distance, direction, tilt, occupied-screen ratio, and visual center for repeated resets of the same floor within a reasonable epsilon.
- Invalidate a cached floor baseline when model/package version, visible model bounds, composition rules, or canvas aspect ratio changes.
## Reset Action Contract
Expose one renderer operation:
```ts
resetToViewBaseline({
view: 'overview' | 'floor',
floorId?: string,
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
})
```
Perform a floor reset in this order:
1. Increment the shared generation/request ID so old floor loads and focus requests become stale.
2. Cancel camera animation, target-focus work, and queued focus callbacks.
3. Clear selected POI, active/highlighted POI, target focus request, and pending target focus.
4. Dispose temporary markers, focus labels/pulses/bases, hall highlights, route previews, and navigation simulation.
5. Restore `visiblePoiIds` to `null`, reset search state, and collapse the search dock.
6. Switch to or confirm the target floor without remounting the renderer.
7. Apply the deterministic floor baseline only after that floor's model commit succeeds.
8. Reset automatic floor switching using the final restored camera distance.
9. Keep `ThreeMap`, WebGL, scene resources, and valid loaded-model caches alive.
- Make reset idempotent. Repeated calls with the same target view and floor must converge on the same business and camera state.
- If the renderer is not ready, retain only the newest reset request and apply it after ready. A failed model state must fail safely without reviving old POI state.
## POI Detail Return Contract
- Use a one-shot context instead of a boolean:
```ts
type PoiDetailReturnContext = Readonly<{
floorId: string
resetMode: 'floor-baseline'
requestId: number
}>
```
- Set the context before every guide-originated POI detail navigation, including search results, direct model/map POI selection, hall detail, exhibit detail, facility detail, and related-explanation actions.
- Cancel the context when `navigateTo` fails or the target cannot be resolved/opened.
- Consume it once in the guide homepage `onShow`, clear it immediately, and then reset to that POI's floor baseline.
- Do not set it for explain-home, deep-link, or other non-guide entry paths to the same detail pages.
- Keep top close, custom back, browser/system back, and other successful detail exits on the normal page-stack return path. Do not make detail pages mutate `ThreeMap` internals.
- Preserve the first normal homepage entry as overview. Never treat initial `onShow` as a detail return.
- Audit all navigation call sites, not only `facility/detail`. Search for `navigateTo`, hall/exhibit/facility detail URLs, map `poiClick`, and related-explanation actions.
## Async Invalidation
- Give floor loads, model commits, POI loads, focus requests, camera animations, and queued resets a comparable request ID, generation, epoch, or abort signal.
- Increment generation before reset cleanup. Check it before every async commit that mutates floor, selection, focus, marker, or camera state.
- Allow only the newest POI/floor request to commit. A completed old cross-floor request must not overwrite a later selection or reset.
- Invalidate preloads separately when necessary, but never let a preload commit become active business state after reset.
- Treat stale-request completion as a no-op, not as a user-visible load failure.
## Prohibited Reset Mechanisms
- Do not use `window.location.reload()`, `location.href`, or equivalent page reloads.
- Do not use `uni.reLaunch()` to reopen the current guide homepage.
- Do not change a component key to remount `GuideMapShell` or `ThreeMap`.
- Do not destroy and recreate `ThreeMap`, `WebGLRenderer`, controls, or scene solely to reset business state.
- Do not redownload or reparse an already valid cached GLB/model package.
- Do not add floor-, category-, type-, or POI-ID-specific reset branches.
## Required Tests And Audit Checks
- Cover search-result and direct map/model POI entry paths for facilities, halls, exhibits, and temporary focus markers.
- Cover same-floor and cross-floor targets, focus animation in progress, floor load in progress, repeated close/onShow, detail-open failure, model-not-ready, and model-error states.
- Assert after return that shared business state matches the target baseline contract, selection/highlight/focus are empty, `visiblePoiIds` is `null`, and search is collapsed.
- Assert camera position, controls target, direction, and distance match the appropriate overview/floor baseline within epsilon.
- Assert an old cross-floor focus/load request cannot commit after reset.
- Assert `ThreeMap` mount count, WebGL initialization count, model-package fetch count, and cached GLB parse count do not increase on detail return.
- Assert non-guide detail entry does not reset the guide model.
- Browser-test at least three POI types on different floors and verify identical same-floor reset composition, no white flash, no URL/load-time reset, and no duplicate GLB network request.

View File

@@ -1,6 +1,6 @@
---
name: shenzhen-natural-museum-dev
description: Shenzhen Natural Museum mobile H5 spatial content guide architecture and client visual specification standards. Use when working in this frontend-miniapp project on guide and explain businesses, visual styling, typography, hall icons, homepage tab labels, indoor 3D/Three.js/GLB, SGS Map SDK H5 integration, iframe/postMessage SDK rendering, API/static data-source switching, POI/location preview, exhibit/hall/facility content, audio explanation, transcript/media data, Tencent Map/outdoor reference logic, static nav assets, Museum Content/Guide/Explain Data Access Layers, provider/adapter/repository architecture, route_graph/nav_data readiness, browser-simulated H5 user-flow audits, business-logic audits, engineering architecture reviews, legacy demo data cleanup, mobile overlay/canvas compatibility, and quality gates. Treat mini-program/mp-weixin as out of scope unless the user explicitly asks for it.
description: Shenzhen Natural Museum mobile H5 guide/explain architecture, indoor spatial rendering, content data, and client visual standards. Use in frontend-miniapp for guide/explain flows; visual styling, typography, hall icons, and homepage tabs; Three.js/GLB; unified guide-model initial state; deterministic overview/floor camera baselines; POI-detail return resets; async request invalidation; SGS Map SDK/iframe/postMessage integration; static/API data-source switching; POI/location previews; exhibit, hall, facility, audio, transcript, or media work; Tencent Map/outdoor logic; nav assets and data-layer architecture; route readiness; H5 browser-flow or business-logic audits; legacy-data cleanup; mobile canvas overlays; and quality gates. Treat mp-weixin as out of scope unless explicitly requested.
---
# Shenzhen Natural Museum Dev
@@ -234,6 +234,9 @@ Use this workflow when the user asks to connect the SGS Map SDK to the H5 guide
- Indoor 3D should load GLB/GLTF resources from the clean package under `static/nav-assets/...` while preserving texture/bin relationships.
- Initial indoor 3D entry should load the full building model when that is the product requirement; switch to a single floor only after zoom/focus/explicit floor action.
- Treat guide-model state restoration as one state-management contract, not as page-specific cleanup. Read and follow [references/guide-model-state-baseline.md](references/guide-model-state-baseline.md) whenever work touches initial model state, overview/floor camera baselines, POI focus, detail navigation/return, floor loading, reset behavior, or model async races.
- Keep `GUIDE_MODEL_INITIAL_STATE`, floor-view baselines, reset actions, and one-shot POI-detail return context behind the existing shared guide-model state boundary. Do not let search, map-click, hall, exhibit, or facility flows own separate reset behavior.
- Preserve the mounted `ThreeMap`, WebGL renderer, scene, and valid model cache during business-state reset. Never use reload, `reLaunch`, component keys, renderer recreation, or repeated cached GLB parsing as a reset mechanism.
- Provide side controls or equivalent for full-building/multi-floor versus single-floor views.
- Always provide loading, error, retry, and recovery paths for model loading.
- Keep canvas sizing constrained by the guide layout. It must not cover fixed navigation, cards, buttons, floor controls, search, top tabs, or explain/player controls.

View File

@@ -0,0 +1,113 @@
# Guide Model State And View Baseline Contract
Use this contract for every change or audit involving the indoor guide model, POI focus, floor switching, detail-page return, camera reset, or model async work.
## Contents
- State ownership and initial state
- Overview and floor baselines
- Reset action contract
- POI detail return contract
- Async invalidation
- Prohibited reset mechanisms
- Required tests and audit checks
## State Ownership And Initial State
- Keep one shared business-state owner, preferably the existing `useGuideModelState` boundary. Do not introduce per-page reset flags or duplicate state managers.
- Keep `GUIDE_MODEL_INITIAL_STATE` immutable. Define it as the stable overview state captured only after the exterior/full-building model becomes ready for the first time.
- Derive the default floor and initial camera from model configuration, floor data, or the first stable ready state. Never hardcode `1F`, a floor ID, camera coordinates, target, or distance.
- Route state changes through explicit actions such as `initializeModel`, `enterOverview`, `switchFloor`, `focusPoi`, `clearPoiSelection`, `enterRoutePreview`, and `resetToViewBaseline`.
- Include at least these business fields in the shared contract:
- 3D/indoor mode, `indoorView`, and `layerMode`.
- Default active floor, requested floor, loading floor, and committed/rendered floor.
- Camera position, up, fov, zoom, controls target, and camera distance.
- Selected POI, active/highlighted POI, target focus request, and pending target focus.
- Visible POI IDs, temporary focus markers, focus labels, pulses, and hall highlights.
- Route preview, navigation simulation, and automatic floor-switch state.
- Model ready/loading/error state, model-load version, focus request ID, and reset generation.
## Overview And Floor Baselines
- Preserve the first stable exterior/full-building ready snapshot as the overview baseline.
- Maintain deterministic `FloorViewBaseline` values keyed by stable floor ID and a baseline cache key.
- Inherit observation direction, camera up, fov, zoom, composition, and screen-offset rules from the overview baseline.
- Compute each floor's controls target and camera distance from that floor's visible model bounding box. Do not copy the overview's absolute position and target to every floor.
- Never capture a floor baseline from a user-dragged camera, an animated camera, or a POI-focus camera.
- Return identical position, target, distance, direction, tilt, occupied-screen ratio, and visual center for repeated resets of the same floor within a reasonable epsilon.
- Invalidate a cached floor baseline when model/package version, visible model bounds, composition rules, or canvas aspect ratio changes.
## Reset Action Contract
Expose one renderer operation:
```ts
resetToViewBaseline({
view: 'overview' | 'floor',
floorId?: string,
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
})
```
Perform a floor reset in this order:
1. Increment the shared generation/request ID so old floor loads and focus requests become stale.
2. Cancel camera animation, target-focus work, and queued focus callbacks.
3. Clear selected POI, active/highlighted POI, target focus request, and pending target focus.
4. Dispose temporary markers, focus labels/pulses/bases, hall highlights, route previews, and navigation simulation.
5. Restore `visiblePoiIds` to `null`, reset search state, and collapse the search dock.
6. Switch to or confirm the target floor without remounting the renderer.
7. Apply the deterministic floor baseline only after that floor's model commit succeeds.
8. Reset automatic floor switching using the final restored camera distance.
9. Keep `ThreeMap`, WebGL, scene resources, and valid loaded-model caches alive.
- Make reset idempotent. Repeated calls with the same target view and floor must converge on the same business and camera state.
- If the renderer is not ready, retain only the newest reset request and apply it after ready. A failed model state must fail safely without reviving old POI state.
## POI Detail Return Contract
- Use a one-shot context instead of a boolean:
```ts
type PoiDetailReturnContext = Readonly<{
floorId: string
resetMode: 'floor-baseline'
requestId: number
}>
```
- Set the context before every guide-originated POI detail navigation, including search results, direct model/map POI selection, hall detail, exhibit detail, facility detail, and related-explanation actions.
- Cancel the context when `navigateTo` fails or the target cannot be resolved/opened.
- Consume it once in the guide homepage `onShow`, clear it immediately, and then reset to that POI's floor baseline.
- Do not set it for explain-home, deep-link, or other non-guide entry paths to the same detail pages.
- Keep top close, custom back, browser/system back, and other successful detail exits on the normal page-stack return path. Do not make detail pages mutate `ThreeMap` internals.
- Preserve the first normal homepage entry as overview. Never treat initial `onShow` as a detail return.
- Audit all navigation call sites, not only `facility/detail`. Search for `navigateTo`, hall/exhibit/facility detail URLs, map `poiClick`, and related-explanation actions.
## Async Invalidation
- Give floor loads, model commits, POI loads, focus requests, camera animations, and queued resets a comparable request ID, generation, epoch, or abort signal.
- Increment generation before reset cleanup. Check it before every async commit that mutates floor, selection, focus, marker, or camera state.
- Allow only the newest POI/floor request to commit. A completed old cross-floor request must not overwrite a later selection or reset.
- Invalidate preloads separately when necessary, but never let a preload commit become active business state after reset.
- Treat stale-request completion as a no-op, not as a user-visible load failure.
## Prohibited Reset Mechanisms
- Do not use `window.location.reload()`, `location.href`, or equivalent page reloads.
- Do not use `uni.reLaunch()` to reopen the current guide homepage.
- Do not change a component key to remount `GuideMapShell` or `ThreeMap`.
- Do not destroy and recreate `ThreeMap`, `WebGLRenderer`, controls, or scene solely to reset business state.
- Do not redownload or reparse an already valid cached GLB/model package.
- Do not add floor-, category-, type-, or POI-ID-specific reset branches.
## Required Tests And Audit Checks
- Cover search-result and direct map/model POI entry paths for facilities, halls, exhibits, and temporary focus markers.
- Cover same-floor and cross-floor targets, focus animation in progress, floor load in progress, repeated close/onShow, detail-open failure, model-not-ready, and model-error states.
- Assert after return that shared business state matches the target baseline contract, selection/highlight/focus are empty, `visiblePoiIds` is `null`, and search is collapsed.
- Assert camera position, controls target, direction, and distance match the appropriate overview/floor baseline within epsilon.
- Assert an old cross-floor focus/load request cannot commit after reset.
- Assert `ThreeMap` mount count, WebGL initialization count, model-package fetch count, and cached GLB parse count do not increase on detail return.
- Assert non-guide detail entry does not reset the guide model.
- Browser-test at least three POI types on different floors and verify identical same-floor reset composition, no white flash, no URL/load-time reset, and no duplicate GLB network request.

View File

@@ -1999,8 +1999,12 @@ const handleResize = () => {
const container = getContainerElement()
if (!container || !camera || !renderer) return
const width = Math.max(1, container.clientWidth)
const height = Math.max(1, container.clientHeight)
const width = container.clientWidth
const height = container.clientHeight
// Kept-alive uni-app pages briefly report a zero-sized canvas while a detail page is on top.
// Treat that as hidden state instead of a real aspect-ratio change, otherwise a return reset
// would rebuild the floor baseline against a synthetic 1:1 viewport.
if (width <= 1 || height <= 1) return
const nextAspect = width / height
if (Math.abs(camera.aspect - nextAspect) > 1e-6) clearFloorViewBaselines()
camera.aspect = nextAspect
@@ -3391,19 +3395,39 @@ const restoreCameraSnapshot = (snapshot: CameraSnapshot) => {
cameraSnapshotRestoreCount += 1
cancelCameraTween()
clearProgrammaticCameraTimer()
isProgrammaticCameraChange = false
camera.position.copy(snapshot.position)
camera.up.copy(snapshot.up)
camera.fov = snapshot.fov
camera.zoom = snapshot.zoom
camera.near = snapshot.near
camera.far = snapshot.far
camera.updateProjectionMatrix()
controls.target.copy(snapshot.target)
controls.update()
// OrbitControls 以 position/target 同步内部球坐标后,恢复快照四元数以保存精确观察方向。
camera.quaternion.copy(snapshot.quaternion)
camera.updateMatrixWorld()
isProgrammaticCameraChange = true
// OrbitControls clamps the camera during update(). Make the immutable snapshot part of the
// valid range before synchronizing controls, otherwise a large floor baseline is clipped by
// the previous view's maxDistance and each reset converges to a different camera distance.
const snapshotDistance = snapshot.position.distanceTo(snapshot.target)
if (Number.isFinite(snapshotDistance) && snapshotDistance > 0) {
controls.minDistance = Math.min(controls.minDistance, snapshotDistance)
controls.maxDistance = Math.max(controls.maxDistance, snapshotDistance)
}
// Consume any gesture delta left in OrbitControls without damping before applying the
// snapshot. Subsequent render frames must not continue a drag or wheel gesture after reset.
const dampingEnabled = controls.enableDamping
controls.enableDamping = false
try {
controls.update()
camera.position.copy(snapshot.position)
camera.up.copy(snapshot.up)
camera.fov = snapshot.fov
camera.zoom = snapshot.zoom
camera.near = snapshot.near
camera.far = snapshot.far
camera.updateProjectionMatrix()
controls.target.copy(snapshot.target)
controls.update()
// OrbitControls 以 position/target 同步内部球坐标后,恢复快照四元数以保存精确观察方向。
camera.quaternion.copy(snapshot.quaternion)
camera.updateMatrixWorld()
} finally {
controls.enableDamping = dampingEnabled
isProgrammaticCameraChange = false
}
}
const applyReferenceOverviewCameraState = () => {

View File

@@ -10,7 +10,7 @@
:asset-base-url="indoorAssetBaseUrl"
:model-source="effectiveIndoorModelSource"
:initial-floor-id="activeFloorId"
:initial-view="indoorView"
:initial-view="indoorInitialView"
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:visible-poi-ids="visiblePoiIds"

View File

@@ -24,6 +24,11 @@ export type PoiDetailReturnContext = Readonly<{
requestId: number
}>
type GuideModelReadyState = Readonly<{
view: 'overview' | 'floor' | 'multi'
floorId: string
}>
// 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',
@@ -45,12 +50,14 @@ export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({
const initialState = ref<GuideModelInitialState>(GUIDE_MODEL_INITIAL_STATE)
const pendingModelResetAfterPoiDetail = ref<PoiDetailReturnContext | null>(null)
const confirmedPoiDetailRequestId = ref<number | null>(null)
const requestGeneration = ref(0)
let poiDetailRequestId = 0
export const useGuideModelState = () => {
const initializeModel = (floorId: string) => {
const initializeModel = ({ view, floorId }: GuideModelReadyState) => {
if (initialState.value.defaultActiveFloorId) return initialState.value
if (view !== 'overview' || !floorId.trim()) return initialState.value
initialState.value = Object.freeze({
...GUIDE_MODEL_INITIAL_STATE,
defaultActiveFloorId: floorId,
@@ -66,16 +73,31 @@ export const useGuideModelState = () => {
requestId: ++poiDetailRequestId
})
pendingModelResetAfterPoiDetail.value = context
confirmedPoiDetailRequestId.value = null
return context
}
const cancelPoiDetailReturn = () => {
const confirmPoiDetailReturn = (requestId: number) => {
if (pendingModelResetAfterPoiDetail.value?.requestId !== requestId) return false
confirmedPoiDetailRequestId.value = requestId
return true
}
const cancelPoiDetailReturn = (requestId?: number) => {
if (
requestId !== undefined
&& pendingModelResetAfterPoiDetail.value?.requestId !== requestId
) return false
pendingModelResetAfterPoiDetail.value = null
confirmedPoiDetailRequestId.value = null
return true
}
const consumePoiDetailReturn = () => {
const context = pendingModelResetAfterPoiDetail.value
if (!context || confirmedPoiDetailRequestId.value !== context.requestId) return null
pendingModelResetAfterPoiDetail.value = null
confirmedPoiDetailRequestId.value = null
return context
}
@@ -91,6 +113,7 @@ export const useGuideModelState = () => {
requestGeneration: readonly(requestGeneration),
initializeModel,
beginPoiDetailReturn,
confirmPoiDetailReturn,
cancelPoiDetailReturn,
consumePoiDetailReturn,
invalidatePendingRequests

View File

@@ -918,11 +918,9 @@ const loadGuideFloors = async () => {
? normalizeGuideFloorId(activeGuideFloor.value)
: ''
activeGuideFloor.value = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id
|| floors.find((floor) => floor.label === '1F')?.id
|| floors[0]?.id
|| activeGuideFloor.value
renderedFloorId.value = activeGuideFloor.value
guideModelState.initializeModel(activeGuideFloor.value)
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
@@ -1038,7 +1036,12 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
}
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
guideModelState.initializeModel(event.floorId || activeGuideFloor.value)
const readyFloorId = event.floorId || activeGuideFloor.value
if (event.view === 'overview' && readyFloorId) {
activeGuideFloor.value = readyFloorId
renderedFloorId.value = readyFloorId
guideModelState.initializeModel({ view: event.view, floorId: readyFloorId })
}
settleLaunchOverlayByModel('ready', event)
if (pendingGuideModelResetUntilReady) {
const context = pendingGuideModelResetUntilReady
@@ -1127,12 +1130,49 @@ const resolveSelectedPoiHall = async (): Promise<MuseumHall | null> => {
}) || null
}
const navigateToHallDetail = (hallId: string) => {
const navigateToPoiDetail = ({
url,
floorId,
failureMessage
}: {
url: string
floorId: string
failureMessage: string
}) => {
const returnContext = guideModelState.beginPoiDetailReturn(floorId)
uni.navigateTo({
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
url,
success: () => {
guideModelState.confirmPoiDetailReturn(returnContext.requestId)
},
fail: () => {
guideModelState.cancelPoiDetailReturn(returnContext.requestId)
uni.showToast({
title: failureMessage,
icon: 'none'
})
}
})
}
const navigateFromSelectedPoiToDetail = (url: string) => {
const poi = selectedGuidePoi.value
if (!poi) return
// Detail pages do not own the mounted renderer. The homepage consumes this once on return.
navigateToPoiDetail({
url,
floorId: poi.floorId,
failureMessage: '讲解详情打开失败,请重试'
})
}
const navigateToHallDetail = (hallId: string) => {
navigateFromSelectedPoiToDetail(
`/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
)
}
const handleViewSelectedHall = async () => {
const hall = await resolveSelectedPoiHall()
if (!hall) {
@@ -1166,9 +1206,9 @@ const handleSelectedPoiRelated = async () => {
}
if (firstExhibit?.exhibitId) {
uni.navigateTo({
url: `/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
})
navigateFromSelectedPoiToDetail(
`/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
)
return
}
@@ -1470,7 +1510,7 @@ const resetGuideModelToInitial = async ({
floorId: string
}) => {
const initial = guideModelState.initialState.value
guideModelState.invalidatePendingRequests()
const resetRequestId = guideModelState.invalidatePendingRequests()
searchTargetFocusRequest.value = null
searchVisiblePoiIds.value = null
homeCategoryModeActive.value = false
@@ -1490,7 +1530,6 @@ const resetGuideModelToInitial = async ({
guideOutdoorState.value = 'home'
indoorView.value = 'floor'
activeGuideFloor.value = floorId || initial.defaultActiveFloorId || activeGuideFloor.value
renderedFloorId.value = activeGuideFloor.value
closeArrivalPanel()
await homeSearchPanelRef.value?.resetSearchState?.()
closeHomeSearchDock()
@@ -1500,12 +1539,15 @@ const resetGuideModelToInitial = async ({
floorId: activeGuideFloor.value,
reason: 'poi-detail-close'
})
if (!resetApplied) {
if (resetApplied) {
renderedFloorId.value = activeGuideFloor.value
pendingGuideModelResetUntilReady = null
} else {
// The renderer is still becoming ready; retain only the latest requested reset.
pendingGuideModelResetUntilReady = Object.freeze({
floorId: activeGuideFloor.value,
resetMode: 'floor-baseline',
requestId: guideModelState.invalidatePendingRequests()
requestId: resetRequestId
})
}
console.info('馆内三维模型已恢复统一初始状态:', { reason })
@@ -1617,16 +1659,10 @@ const createSearchDetailUrl = (poi: MuseumPoi, context: PoiSearchContext) => {
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
// The homepage keeps its renderer mounted; detail close is consumed by onShow once.
searchTargetFocusRequest.value = null
guideModelState.beginPoiDetailReturn(poi.floorId)
uni.navigateTo({
navigateToPoiDetail({
url: createSearchDetailUrl(poi, context),
fail: () => {
guideModelState.cancelPoiDetailReturn()
uni.showToast({
title: '点位详情打开失败,请重试',
icon: 'none'
})
}
floorId: poi.floorId,
failureMessage: '点位详情打开失败,请重试'
})
}

View File

@@ -457,6 +457,14 @@ test('floor view baseline is deterministic across repeated resets', async ({ pag
await api.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
}, floor.floorId)
const first = await getReport(page)
const baseline = await page.evaluate((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')
return api.getFloorBaseline(floorId)
}, floor.floorId)
expect(baseline, `missing floor baseline ${floor.floorId}`).not.toBeNull()
expect(getMaximumCameraDelta(baseline!, first.camera)).toBeLessThan(1e-6)
await page.evaluate(async (floorId) => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
@@ -467,5 +475,6 @@ test('floor view baseline is deterministic across repeated resets', async ({ pag
expect(second.activeView).toBe('floor')
expect(second.floorId).toBe(floor.floorId)
expect(getMaximumCameraDelta(first.camera, second.camera)).toBeLessThan(1e-6)
expect(getMaximumCameraDelta(baseline!, second.camera)).toBeLessThan(1e-6)
}
})

View File

@@ -8,6 +8,14 @@ import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
const ThreeMapStub = defineComponent({
name: 'ThreeMap',
props: {
initialFloorId: {
type: String,
default: ''
},
initialView: {
type: String,
default: ''
},
visiblePoiIds: {
type: Array,
default: null
@@ -71,4 +79,29 @@ describe('GuideMapShell 点位过滤契约', () => {
expect(wrapper.emitted('floorChange')).toEqual([['L2']])
expect(wrapper.emitted('targetFocus')).toEqual([[focusResult]])
})
it('动态楼层业务视图不会覆盖首次外观模型初始化视图', () => {
const wrapper = mount(GuideMapShell, {
props: {
mapType: 'indoor',
indoorModelSource: modelSource,
floors: [
{ id: 'L1', label: '1F' },
{ id: 'L2', label: '2F' }
],
activeFloor: 'L2',
indoorView: 'floor',
indoorInitialView: 'overview'
},
global: {
stubs: {
ThreeMap: ThreeMapStub
}
}
})
const renderer = wrapper.getComponent(ThreeMapStub)
expect(renderer.props('initialFloorId')).toBe('L2')
expect(renderer.props('initialView')).toBe('overview')
})
})

View File

@@ -6,12 +6,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import IndexPage from '@/pages/index/index.vue'
const testState = vi.hoisted(() => ({
mapMountCount: 0,
mapUnmountCount: 0,
resetToViewBaselineCalls: [] as Array<{
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}>,
searchMountCount: 0,
searchUnmountCount: 0,
searchRestoreCount: 0,
searchCollapseAfterDetailCount: 0,
searchResetCount: 0,
onShowHandler: null as null | (() => Promise<void> | void)
onShowHandler: null as null | (() => Promise<void> | void),
halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>,
explainResults: [] as Array<{
type: 'hall' | 'exhibit'
hallId?: string
exhibitId?: string
}>
}))
const hostEnvironmentMocks = vi.hoisted(() => ({
@@ -63,8 +76,8 @@ vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
loadExplainHalls: async () => [],
loadExplainHallSummaries: async () => ({}),
listHalls: async () => [],
searchExplain: async () => []
listHalls: async () => testState.halls,
searchExplain: async () => testState.explainResults
}
}))
@@ -82,6 +95,25 @@ const GuideMapShellStub = defineComponent({
targetFocusRequest: { type: Object, default: null }
},
emits: ['floor-change', 'poi-click', 'selection-clear'],
setup() {
onMounted(() => {
testState.mapMountCount += 1
})
onUnmounted(() => {
testState.mapUnmountCount += 1
})
return {}
},
methods: {
resetToViewBaseline(options: {
view: 'overview' | 'floor'
floorId?: string
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
}) {
testState.resetToViewBaselineCalls.push(options)
return true
}
},
template: '<section data-testid="guide-map"><slot name="overlay" /><slot /></section>'
})
@@ -162,14 +194,24 @@ const mountIndex = async () => {
return wrapper
}
const confirmLatestNavigation = () => {
const navigation = vi.mocked(uni.navigateTo).mock.calls.at(-1)?.[0]
navigation?.success?.({ errMsg: 'navigateTo:ok' } as never)
}
beforeEach(() => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
testState.mapMountCount = 0
testState.mapUnmountCount = 0
testState.resetToViewBaselineCalls = []
testState.searchMountCount = 0
testState.searchUnmountCount = 0
testState.searchRestoreCount = 0
testState.searchCollapseAfterDetailCount = 0
testState.searchResetCount = 0
testState.onShowHandler = null
testState.halls = []
testState.explainResults = []
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
vi.stubGlobal('uni', {
navigateTo: vi.fn(),
@@ -351,6 +393,7 @@ describe('首页搜索与地图闭环', () => {
expect(testState.searchMountCount).toBe(1)
expect(testState.searchUnmountCount).toBe(0)
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
@@ -378,6 +421,7 @@ describe('首页搜索与地图闭环', () => {
search.vm.$emit('result-tap', poi, fullSearchContext)
await flushPromises()
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
@@ -417,6 +461,129 @@ describe('首页搜索与地图闭环', () => {
expect(map.props('targetFocusRequest')).toBeNull()
})
it('模型点选展厅后查看详情,返回时恢复该楼层视觉基线且不重挂载地图', async () => {
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
const viewHallAction = wrapper.find('.poi-action.primary')
expect(viewHallAction.exists()).toBe(true)
await viewHallAction.trigger('tap')
await flushPromises()
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/hall/detail?id=hall-l2')
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toHaveLength(0)
expect(wrapper.find('.guide-poi-card').exists()).toBe(true)
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toEqual([{
view: 'floor',
floorId: 'L2',
reason: 'poi-detail-close'
}])
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('visiblePoiIds')).toBeNull()
expect(map.props('targetFocusRequest')).toBeNull()
expect(wrapper.find('.guide-poi-card').exists()).toBe(false)
expect(testState.mapMountCount).toBe(1)
expect(testState.mapUnmountCount).toBe(0)
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toHaveLength(1)
})
it('模型点选展厅的相关讲解可进入展品详情,并在返回时复位原楼层', async () => {
testState.explainResults = [{ type: 'exhibit', exhibitId: 'exhibit-l2' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
const relatedActions = wrapper.findAll('.poi-action')
expect(relatedActions).toHaveLength(2)
await relatedActions[1]!.trigger('tap')
await flushPromises()
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/exhibit/detail?id=exhibit-l2')
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toEqual([{
view: 'floor',
floorId: 'L2',
reason: 'poi-detail-close'
}])
expect(wrapper.find('.guide-poi-card').exists()).toBe(false)
})
it('模型点选展厅的相关讲解优先进入展厅详情并保留一次性返回上下文', async () => {
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
await wrapper.findAll('.poi-action')[1]!.trigger('tap')
await flushPromises()
expect(vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url).toContain('/pages/hall/detail?id=hall-l2')
confirmLatestNavigation()
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls[0]).toMatchObject({ view: 'floor', floorId: 'L2' })
})
it('模型点选详情打开失败时取消返回上下文,不触发楼层复位', async () => {
testState.halls = [{ id: 'hall-l2', poiId: poi.id, name: '二层展厅' }]
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('poi-click', {
...poi,
kind: 'hall',
primaryCategory: 'exhibition_hall',
primaryCategoryZh: '展厅'
})
await flushPromises()
await wrapper.find('.poi-action.primary').trigger('tap')
await flushPromises()
const navigation = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]
navigation?.fail?.({ errMsg: 'navigateTo:fail simulated' })
await testState.onShowHandler?.()
await flushPromises()
expect(testState.resetToViewBaselineCalls).toHaveLength(0)
expect(wrapper.find('.guide-poi-card').exists()).toBe(true)
expect(map.props('activeFloor')).toBe('L2')
})
it('地图点位卡隐藏首页 dock 时仍保留搜索组件,关闭后可继续使用原列表', async () => {
const wrapper = await mountIndex()
const map = wrapper.getComponent(GuideMapShellStub)