diff --git a/.agents/skills/shenzhen-natural-museum-dev/SKILL.md b/.agents/skills/shenzhen-natural-museum-dev/SKILL.md index 64dddcf..649b32b 100644 --- a/.agents/skills/shenzhen-natural-museum-dev/SKILL.md +++ b/.agents/skills/shenzhen-natural-museum-dev/SKILL.md @@ -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. diff --git a/.agents/skills/shenzhen-natural-museum-dev/references/guide-model-state-baseline.md b/.agents/skills/shenzhen-natural-museum-dev/references/guide-model-state-baseline.md new file mode 100644 index 0000000..819ef3e --- /dev/null +++ b/.agents/skills/shenzhen-natural-museum-dev/references/guide-model-state-baseline.md @@ -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. diff --git a/.claude/skills/shenzhen-natural-museum-dev/SKILL.md b/.claude/skills/shenzhen-natural-museum-dev/SKILL.md index 64dddcf..649b32b 100644 --- a/.claude/skills/shenzhen-natural-museum-dev/SKILL.md +++ b/.claude/skills/shenzhen-natural-museum-dev/SKILL.md @@ -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. diff --git a/.claude/skills/shenzhen-natural-museum-dev/references/guide-model-state-baseline.md b/.claude/skills/shenzhen-natural-museum-dev/references/guide-model-state-baseline.md new file mode 100644 index 0000000..819ef3e --- /dev/null +++ b/.claude/skills/shenzhen-natural-museum-dev/references/guide-model-state-baseline.md @@ -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. diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 0736124..77498dc 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -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 = () => { diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index 7e7fd9d..84d65e7 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -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" diff --git a/src/composables/useGuideModelState.ts b/src/composables/useGuideModelState.ts index 6a4b37b..c2a971c 100644 --- a/src/composables/useGuideModelState.ts +++ b/src/composables/useGuideModelState.ts @@ -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(GUIDE_MODEL_INITIAL_STATE) const pendingModelResetAfterPoiDetail = ref(null) +const confirmedPoiDetailRequestId = ref(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 diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 6fd6f8d..d62dd68 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -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 => { }) || 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: '点位详情打开失败,请重试' }) } diff --git a/tests/e2e/three-map-camera-stability.spec.ts b/tests/e2e/three-map-camera-stability.spec.ts index edfb9f6..7179184 100644 --- a/tests/e2e/three-map-camera-stability.spec.ts +++ b/tests/e2e/three-map-camera-stability.spec.ts @@ -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) } }) diff --git a/tests/unit/GuideMapShell.spec.ts b/tests/unit/GuideMapShell.spec.ts index 06953ef..bd4bfde 100644 --- a/tests/unit/GuideMapShell.spec.ts +++ b/tests/unit/GuideMapShell.spec.ts @@ -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') + }) }) diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index e1b8a28..19bd212 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -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) + onShowHandler: null as null | (() => Promise | 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: '
' }) @@ -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)