diff --git a/.agents/skills/shenzhen-natural-museum-dev/SKILL.md b/.agents/skills/shenzhen-natural-museum-dev/SKILL.md index 3759575..725d89b 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 standards. Use when working in this frontend-miniapp project on guide and explain businesses, indoor 3D/Three.js/GLB, 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 spatial content guide architecture standards. Use when working in this frontend-miniapp project on guide and explain businesses, 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. --- # Shenzhen Natural Museum Dev @@ -114,6 +114,95 @@ Guide data and explain data may share the same exhibit/hall/POI IDs, but their u - unrelated historical demo modules; - deletion candidates that need user approval. +## Multi-Source Data Strategy + +Use this module when the guide data source must switch between the local static resource package, backend APIs, and the SGS Map SDK H5 renderer. + +Supported H5 modes: + +- `static`: current default. Use the static nav-assets package through `StaticNavAssetsProvider`, static adapters, `StaticGuideRepository`, and the existing Three.js/GLB renderer. +- `api`: use backend GIS/map APIs for floors and POIs through an API provider and repository, while keeping the existing local/static renderer if SDK rendering is not enabled. +- `sdk`: use backend GIS/map APIs for guide domain data and use SGS Map SDK as the indoor map renderer. Do not use SDK mode as a replacement for guide/explain repositories; it is a rendering/runtime service, not the page data contract. + +Configuration rules: + +- Prefer environment-driven switching, e.g. `VITE_DATA_SOURCE_MODE=static|api|sdk`, `VITE_API_BASE_URL`, `VITE_SGS_SDK_URL`, and `VITE_SGS_SDK_ORIGIN`. +- Centralize mode parsing and validation in one config module such as `src/config/dataSource.ts`; pages and components must not read raw env vars directly. +- Repository selection should be explicit: + - `static` -> `StaticGuideRepository` + `StaticNavAssetsProvider`. + - `api` -> `ApiGuideRepository` + `ApiNavAssetsProvider`. + - `sdk` -> `ApiGuideRepository` + `ApiNavAssetsProvider`; SDK is selected only for renderer/service integration. +- Renderer selection should be explicit: + - `static` and `api` -> existing `ThreeMap`/local renderer path unless the product requires another renderer. + - `sdk` -> `SgsMapRenderer` or equivalent SDK container component. +- Keep the selected mode observable in diagnostics/logging so QA can tell which source and renderer are active. +- The same domain models and view models must be consumed by the UI in all modes. If API fields differ from static package fields, normalize them in adapters. +- Switching modes must not require changing page templates, business use cases, or explain components. + +## SDK Integration Standards + +Use this module when integrating the SGS Map SDK from `E:\MyWork\深圳国际艺术馆\智慧导览\smart-navigation-system` into the H5 guide experience. + +Treat the SDK as a black-box H5 map runtime: + +- Do not let pages, route/detail pages, search pages, explain components, or generic guide components import or call `SGSMapSDK` directly. +- Wrap the SDK instance behind a provider/service such as `SgsMapServiceProvider` that owns script availability, initialization, readiness, command calls, event listener registration, and teardown. +- Use an adapter layer to translate SDK events and responses into guide-domain events and models. Examples: `poiClick` -> selected `MuseumPoi`/`GuideLocationPreview`, `floorChanged` -> guide floor state, route result -> route readiness/result domain model. +- Keep SDK rendering separate from domain data loading. API-backed `GuideRepository` should still provide floors, POIs, search, and location previews. +- Keep Tencent/outdoor map logic independent from SDK indoor map state. +- Preserve mobile H5 overlays above the SDK iframe/canvas. The SDK container must not cover top tabs, search, cards, floor controls, explain controls, or audio player. + +SGS Map SDK specific rules: + +- Use a dedicated H5 renderer component, e.g. `SgsMapRenderer.vue`, for the iframe host and UI event emits. +- Initialize SDK with explicit `container`, `sdkUrl`, initial `floorId`, `timeout`, and `targetOrigin` where available. +- Use `targetOrigin` instead of `*` for deployed environments. If local development temporarily needs `*`, mark it as development-only. +- Wait for the SDK `ready` event before sending commands that require `_postCommandAsync` readiness. +- Bridge SDK command methods through a renderer/service interface: `changeFloor`, `focusTo`, `addMarker`, `clearMarkers`, `highlightPolygon`, `toggleLayer`, `planRoute`, `clearRoute`, `resetView`, `setViewMode`, and `getState`. +- Call `destroy()` on component unmount or provider disposal to remove iframe, listeners, and pending promises. +- Handle `ERR_NOT_READY`, `ERR_TIMEOUT`, `ERR_NO_PATH`, `ERR_FLOOR_NOT_FOUND`, `ERR_POI_NOT_FOUND`, `ERR_NETWORK`, `ERR_IFRAME_DEAD`, `ERR_UNAUTHORIZED`, `ERR_UNSUPPORTED`, and `ERR_FAILED` without leaving the user stuck. +- Route planning through SDK can be enabled only when route graph/nav data and runtime behavior are verified. Until then, keep user-facing copy as location preview or experimental route preview, not certified navigation. + +Recommended integration shape: + +```text +GuideRepository / GuideUseCase -> domain floors, POIs, search, location preview +GuideMapShell / renderer switch -> chooses local ThreeMap or SgsMapRenderer +SgsMapServiceProvider -> owns SGSMapSDK instance and lifecycle +SgsMapEventAdapter -> SDK events/responses -> guide domain events +``` + +## SDK Integration Workflow + +Use this workflow when the user asks to connect the SGS Map SDK to the H5 guide app. + +1. Interface abstraction: + - Inventory current guide/map entry points, especially `GuideMapShell`, `ThreeMap`, `guideUseCase`, repositories, providers, and route/search pages. + - Define or formalize a renderer boundary for guide map operations: floor change, POI focus, marker display, route preview/planning, view reset, and emitted interactions. + - Keep current static/Three.js behavior as one implementation of that boundary; do not rewrite unrelated guide/explain logic. + - Add a config module for `static|api|sdk` mode selection and validation. + +2. API data layer: + - Add an API provider for SGS Map backend endpoints such as floor list and POI-by-floor list. + - Add adapters that normalize backend floor/POI response fields into `MuseumFloor`, `MuseumPoi`, and `GuideLocationPreview`. + - Implement `ApiGuideRepository` behind the existing `GuideRepository` contract. + - Verify that search, floor list, and location preview work in `static` and `api` modes before adding SDK rendering. + +3. SDK renderer integration: + - Source the browser SDK deliberately: copy/version `sgs-map-sdk.min.js` into this project only if that is the chosen deployment model, or load it from the SGS Map service if operations owns it there. + - Add TypeScript declarations or a small wrapper type for the SDK API instead of scattering `any` across components. + - Create `SgsMapServiceProvider` to load/instantiate/destroy SDK and expose typed operations. + - Create `SgsMapRenderer.vue` to host the SDK container, pass initial floor/mode, emit normalized map events, and show loading/error/retry states. + - Update the guide shell to choose `ThreeMap` or `SgsMapRenderer` by config, not by page-level conditionals. + - Keep SDK-only code in H5-safe boundaries. Do not add mini-program/mp-weixin fallback work unless the user explicitly asks. + +4. Validation: + - Run at least `pnpm type-check`, `pnpm lint`, and `pnpm build:h5` after code changes. + - Browser-test mode switching: `static` -> `api` -> `sdk`, confirming the active source/renderer is visible in logs or diagnostics. + - Browser-test SDK lifecycle: mount, SDK `ready`, floor switch, POI focus/click, route preview if enabled, unmount, remount. + - Browser-test error states: SDK URL unavailable, API unavailable, SDK not ready, command timeout, and route-not-ready. + - Verify overlays remain clickable above the SDK iframe/canvas on a mobile viewport. + ## Guide, Map, And 3D Standards - Indoor 3D should load GLB/GLTF resources from the clean package under `static/nav-assets/...` while preserving texture/bin relationships. @@ -179,13 +268,64 @@ Check: - Shell layer: `GuidePageFrame`, top tabs, and shared navigation shell should handle layout and cross-business tab state without owning data conversion. - Guide shell: `GuideMapShell` should carry map/3D controls and mode switching without knowing static package internals beyond transitional props. - Renderer layer: `ThreeMap` should render model/POI/camera state, not own source-specific manifest/floor/POI adapters after migration. +- SDK isolation: pages and generic guide/explain components must not import `SGSMapSDK`, access iframe `contentWindow`, or send raw `postMessage` commands directly. +- Renderer abstraction: SDK mode should be selected through a renderer boundary or shell-level switch, not by scattering `if (mode === 'sdk')` branches across pages. +- SDK provider encapsulation: SDK instance lifecycle, command queueing/readiness, target origin, and teardown must stay in a provider/service layer. +- SDK event adapter: SDK `ready`, `poiClick`, `floorChanged`, route response, and error events must be converted into domain events or use-case calls before reaching presentation components. +- Mode switching: `static`, `api`, and `sdk` modes must be selectable through centralized config and must preserve the same domain/view-model contracts. +- Fallback strategy: SDK unavailable, iframe load failure, API failure, or command timeout must show recovery/fallback states; do not silently leave a blank map. +- API dependency: SDK mode should pair with API-backed guide repositories for floor/POI domain data; static package data should not be mixed into SDK runtime unless explicitly marked as a migration bridge. - Explain layer: `ExplainList` should render repository/view-model data and emit actions; `AudioPlayer` should own only playback runtime. - Data layer: static package and future API/CMS loading must stay behind providers/adapters/repositories/use cases; pages and presentation components must not own source-specific loading. - Repository layer: `MuseumContentRepository`, `GuideRepository`, `ExplainRepository`, and `MediaRepository` should hide providers/adapters from UI. - State layer: guide session, explain session, player state, search state, selected target, and route readiness should live in composables/stores/use cases when shared across pages. - H5/mp boundary: H5 guide/explain behavior should not be constrained by mp-weixin unless requested. -- Overlay safety: canvas must not capture or cover top tabs, search, cards, floor/full-building controls, explain controls, or audio player. -- Lifecycle: models, audio contexts, network requests, and event listeners must clean up on unmount and recover from failures. +- Overlay safety: canvas, WebGL layers, and SDK iframe must not capture or cover top tabs, search, cards, floor/full-building controls, explain controls, or audio player. +- Lifecycle: models, SDK iframes, audio contexts, network requests, and event listeners must clean up on unmount and recover from failures. + +## SDK Integration Audit Module + +Use this module when reviewing or validating SGS Map SDK H5 integration. + +1. SDK loading: + - Confirm whether the SDK is loaded from this project, the SGS Map service, or another approved static host. + - Verify `sgs-map-sdk.min.js` version and source path are documented and not duplicated accidentally. + - Ensure TypeScript declarations or wrapper types cover the used API surface instead of leaking untyped globals through the app. + - Check that SDK loading failures surface a visible error/retry/fallback state. + +2. Lifecycle management: + - SDK initialization should happen in `SgsMapServiceProvider`, `SgsMapRenderer`, or an equivalent H5 renderer boundary, not in pages. + - `destroy()` must be called on unmount/dispose to remove iframe, message listeners, and pending promises. + - Pending commands must be rejected or cancelled when the renderer unmounts. + - Re-entering the guide page should not create duplicate iframes, duplicate listeners, or stale floor/POI state. + +3. Event bridge: + - `ready` should update map readiness and release queued/disabled UI actions. + - `poiClick` should resolve to a stable guide domain POI or show a controlled unresolved-POI state. + - `floorChanged` should sync the guide floor state and visible controls. + - Route responses must respect route readiness gates and user-facing copy rules. + - SDK errors should become use-case or view-model error states, not only console logs. + +4. Security: + - Use explicit `targetOrigin` in deployed environments. + - Validate message origin and iframe source; do not accept arbitrary `postMessage` payloads. + - Do not send tokens, private user data, or unrelated business payloads through the SDK message bridge. + - If cross-origin SDK hosting is used, document CORS, CSP, and deployment assumptions. + +5. Error handling: + - Cover `ERR_NOT_READY`, timeout, iframe load failure, backend/network failure, unsupported command, unauthorized access, missing floor, missing POI, and no-path cases. + - Provide retry/recovery for load failures and clear disabled states for unavailable route planning. + - Do not allow a failed SDK command to desynchronize the selected POI, floor selector, search result, or explain location preview. + +6. Performance and mobile H5 fit: + - SDK iframe/model loading should show progress/loading UI and must not block the rest of the guide shell. + - Floor changes and POI focus should avoid janky transitions that make overlay controls unresponsive. + - Keep GLB/model memory and iframe lifecycle under control when switching tabs or leaving the guide page. + - Validate small mobile viewport behavior: no horizontal overflow, overlay buttons remain tappable, and the iframe does not steal all gestures. + +7. Cross-browser H5 validation: + - Test at least Chromium-based H5 during development; add Safari/iOS WebView checks when production target includes iOS browsers. + - Keep the test scope H5 unless the user explicitly asks for mini-program/mp-weixin. ## 3D Guide Data Audit Module @@ -282,6 +422,10 @@ pnpm build:h5 - Audio fallback URLs such as `example.com` are not valid explain capability. - `src/pages/route/detail.vue` may still contain historical navigation states such as planning/navigating/arrived/location-error. Keep them blocked unless real graph data exists. - Runtime static JSON loading through `uni.request` must be verified on H5 for current work; verify mp-weixin only when explicitly requested. +- SGS Map SDK H5 integration depends on the sibling project `E:\MyWork\深圳国际艺术馆\智慧导览\smart-navigation-system`, especially `sgs-frontend-map/sdk-src`, `public/sdk/sgs-map-sdk.min.js`, and `/h5-sdk`. +- SGS Map SDK uses iframe + `postMessage`; target origin, iframe lifecycle, and event bridge errors are separate operational risks from local Three.js rendering. +- SDK mode should not be treated as proof of certified indoor navigation unless route graph/nav data and runtime route behavior are verified. +- Mixing static nav-assets POI IDs with backend SGS Map POI IDs can break search, focus, explain location previews, and route actions unless adapters normalize stable IDs deliberately. - Historical demo data remains outside the guide/explain production data boundary unless explicitly migrated. -- Tencent Map SDK behavior, indoor Three.js behavior, and audio playback behavior are separate operational risks; test each after changes that touch shared shell UI. -- Static asset size, GLB decode time, audio streaming, and image media size can affect mobile performance. Prefer lazy loading, caching, disposal, and unavailable states over loading unused assets. +- Tencent Map SDK behavior, indoor Three.js/SGS Map SDK behavior, and audio playback behavior are separate operational risks; test each after changes that touch shared shell UI. +- Static asset size, GLB decode time, SDK iframe/model startup, audio streaming, and image media size can affect mobile performance. Prefer lazy loading, caching, disposal, and unavailable states over loading unused assets. diff --git a/.claude/skills/shenzhen-natural-museum-dev/SKILL.md b/.claude/skills/shenzhen-natural-museum-dev/SKILL.md new file mode 100644 index 0000000..725d89b --- /dev/null +++ b/.claude/skills/shenzhen-natural-museum-dev/SKILL.md @@ -0,0 +1,431 @@ +--- +name: shenzhen-natural-museum-dev +description: Shenzhen Natural Museum mobile H5 spatial content guide architecture standards. Use when working in this frontend-miniapp project on guide and explain businesses, 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. +--- + +# Shenzhen Natural Museum Dev + +Use this skill for the Shenzhen Natural Museum `frontend-miniapp` project. Treat the app as a mobile H5 spatial content guide platform, not as only an indoor 3D map. + +The product has two first-class H5 businesses: + +- `guide`: outdoor reference map, indoor 3D display, full-building/single-floor switching, POI search/focus, and POI/location preview. Real route planning requires verified `route_graph` and `nav_data`. +- `explain`: exhibit/hall content, audio explanation, transcript/chapters/media metadata, search/filtering, player state, and optional linkage from an explain item to a guide location preview. + +## Project Ground Truth + +- Main workspace: `E:\MyWork\深圳国际艺术馆\museum-guide\museum-guide-v4.0\frontend-miniapp`. +- Default runtime target: H5. Do not consider mini-program/mp-weixin constraints unless the user explicitly asks for them. +- Current clean nav asset package: `static/nav-assets/app_nav_assets_v2_clean_20260611_093623`. +- Current guide data boundary: `src/data/providers/staticNavAssetsProvider.ts` + `src/data/adapters/navAssetsAdapter.ts` + `src/repositories/GuideRepository.ts` / `src/repositories/GuideModelRepository.ts`. +- Current content/explain architecture is still transitional. Treat Provider / Adapter / Repository / UseCase as the current data boundary; do not reintroduce the retired legacy nav assets service. +- Legacy demo data area: `src/assets/data`. Treat it as historical/demo-only unless the user explicitly asks to inspect or migrate it. +- Current guide capability is indoor 3D display plus POI/location preview. Do not present it as certified real indoor navigation until `route_graph` and `nav_data` are available and verified. +- Current explain capability must be described as content/audio explanation only when real media data is available. Do not invent working audio, transcript, or exhibit data from placeholders. + +## Non-Negotiable Architecture Rules + +- Keep data and presentation decoupled. Pages and visual components must consume domain models or view models, not raw static package JSON, backend fields, GLB manifest details, or ad hoc demo arrays. +- Use this target layer shape for new or refactored work: + +```text +Data Sources +static nav-assets / CMS API / backend API / IMDF / BIM export / media manifest + ↓ +Providers +StaticProvider / ApiProvider / CmsProvider / FutureImdfProvider + ↓ +Adapters +source-specific fields -> unified museum domain models + ↓ +Repositories / Data Access Layers +MuseumContentRepository / GuideRepository / ExplainRepository / MediaRepository + ↓ +Use Cases / Composables / Stores +guide session, explain session, search, selected target, player, route readiness + ↓ +Presentation ViewModels +lightweight props for pages, ThreeMap, ExplainList, cards, player, search + ↓ +Presentation Components +GuideMapShell / ThreeMap / ExplainList / AudioPlayer / Search / Cards +``` + +- Keep guide and explain coupled through stable IDs and domain relationships such as `poiId`, `exhibitId`, `hallId`, and `floorId`, not through component internals. +- Keep Tencent/outdoor map logic separate from indoor Three.js/GLB rendering. Do not make Tencent Map branches depend on indoor model state, and do not let indoor 3D changes alter outdoor map behavior. +- Keep `NAV_ROUTE_GRAPH_READY` false until real graph/nav data has been added, loaded, validated, and wired with smoke tests. +- Do not claim "开始馆内导航", route planning, arrival guidance, turn-by-turn navigation, or positioning accuracy unless route graph/nav data and runtime behavior are verified. +- Prefer "位置预览", "查看位置", "查看三维位置", or an explicitly disabled route state while route data is unavailable. +- Do not use placeholder media such as `example.com/audio.mp3` as a working explain capability. Show an unavailable state or load a real media asset through the media/explain data layer. +- Three.js/WebGL is H5-only for current project work. Do not add mini-program fallback or mp-weixin compatibility work unless specifically requested. +- Preserve mobile overlays: top tabs/menu/search/cards/floor controls/explain controls/audio player must remain visible and clickable above 3D canvas. +- Avoid unrelated refactors, broad file deletion, or data cleanup without explicit confirmation. Contain and mark legacy first; delete only when asked. + +## Domain Model Standards + +Use stable museum spatial content domain models when designing data services or audits: + +- `MuseumVenue`, `MuseumBuilding`, `MuseumFloor`, `MuseumSpace` +- `MuseumPoi`, `MuseumHall`, `MuseumExhibit`, `MuseumFacility`, `MuseumCategory` +- `GuideModelAsset`, `GuideLocationPreview`, `GuideRouteReadiness`, `GuideRouteGraph` +- `ExplainTrack`, `ExplainMedia`, `ExplainTranscript`, `ExplainChapter` +- `MediaAsset`, `SearchIndexItem`, `UserCollection`, `VisitHistory` + +Guide data and explain data may share the same exhibit/hall/POI IDs, but their use cases differ: + +- Guide answers: Where is it, on which floor, what should the 3D/map preview show, is real routing ready? +- Explain answers: What is it, what should be played/read, what media/transcript is available, how can a visitor continue to a location preview? + +## Development Workflow + +1. Inspect before changing: + - Use `rg`/`rg --files` to find guide, explain, audio, map, route, asset, and data references. + - Check page entry points and shared shell components before editing behavior. + - Read the local implementation instead of assuming the uni-app/Vue structure. + +2. Scope changes tightly: + - For guide work, start around `src/pages/index`, route/search/facility pages, `src/components/navigation`, `src/components/map`, `src/usecases/guideUseCase.ts`, and guide repositories/providers. + - For explain work, start around `src/components/explain`, `src/components/audio`, exhibit/hall detail pages, top-tab routing, and the future content/explain repositories. + - Do not touch legacy hall/exhibit/explain demo data unless the task is explicitly about migration, compatibility, or cleanup. + +3. Protect the H5 boundary: + - Default to H5 behavior, H5 styling, and H5 validation. + - Use conditional compilation only when existing code requires it or when the user explicitly asks about mini-program support. + - Do not spend time preserving or testing mp-weixin behavior unless the task names mini-program/mp-weixin. + +4. Verify after changes: + - Run the smallest meaningful checks, then expand based on risk. + - For frontend/UI guide or explain changes, prefer at least `pnpm type-check`, `pnpm lint`, and `pnpm build:h5`. + - Run `pnpm build:mp-weixin` only when the user explicitly requests mini-program validation. + - Mention before running build commands if the task is read-only, because builds write `dist`. + +## Data Source Standards + +- Use clean manifests and future CMS/API manifests as source inputs, not as page/component contracts. +- Keep static package fields and future API fields behind providers/adapters. Do not let pages depend on file paths, response field quirks, database IDs, or temporary migration aliases. +- Keep all guide data loading centralized through providers/adapters/repositories/use cases. Do not let pages or components import static-package readers directly. +- Introduce content/explain/media repositories before migrating explain pages out of hardcoded arrays. +- Do not duplicate parsed POI, floor, category, model path, explain media, transcript, or route readiness logic in page components. +- Use contract-first data evolution. Static packages, future APIs, and CMS data should expose compatible `schemaVersion`/dataset metadata and preserve stable domain models. +- Mark old demo compatibility explicitly when a temporary bridge is unavoidable. +- If cleaning old mock data, first inventory references with `rg`, then split work into: + - guide-critical stale data that blocks current work; + - explain/content demo data that needs repository migration; + - unrelated historical demo modules; + - deletion candidates that need user approval. + +## Multi-Source Data Strategy + +Use this module when the guide data source must switch between the local static resource package, backend APIs, and the SGS Map SDK H5 renderer. + +Supported H5 modes: + +- `static`: current default. Use the static nav-assets package through `StaticNavAssetsProvider`, static adapters, `StaticGuideRepository`, and the existing Three.js/GLB renderer. +- `api`: use backend GIS/map APIs for floors and POIs through an API provider and repository, while keeping the existing local/static renderer if SDK rendering is not enabled. +- `sdk`: use backend GIS/map APIs for guide domain data and use SGS Map SDK as the indoor map renderer. Do not use SDK mode as a replacement for guide/explain repositories; it is a rendering/runtime service, not the page data contract. + +Configuration rules: + +- Prefer environment-driven switching, e.g. `VITE_DATA_SOURCE_MODE=static|api|sdk`, `VITE_API_BASE_URL`, `VITE_SGS_SDK_URL`, and `VITE_SGS_SDK_ORIGIN`. +- Centralize mode parsing and validation in one config module such as `src/config/dataSource.ts`; pages and components must not read raw env vars directly. +- Repository selection should be explicit: + - `static` -> `StaticGuideRepository` + `StaticNavAssetsProvider`. + - `api` -> `ApiGuideRepository` + `ApiNavAssetsProvider`. + - `sdk` -> `ApiGuideRepository` + `ApiNavAssetsProvider`; SDK is selected only for renderer/service integration. +- Renderer selection should be explicit: + - `static` and `api` -> existing `ThreeMap`/local renderer path unless the product requires another renderer. + - `sdk` -> `SgsMapRenderer` or equivalent SDK container component. +- Keep the selected mode observable in diagnostics/logging so QA can tell which source and renderer are active. +- The same domain models and view models must be consumed by the UI in all modes. If API fields differ from static package fields, normalize them in adapters. +- Switching modes must not require changing page templates, business use cases, or explain components. + +## SDK Integration Standards + +Use this module when integrating the SGS Map SDK from `E:\MyWork\深圳国际艺术馆\智慧导览\smart-navigation-system` into the H5 guide experience. + +Treat the SDK as a black-box H5 map runtime: + +- Do not let pages, route/detail pages, search pages, explain components, or generic guide components import or call `SGSMapSDK` directly. +- Wrap the SDK instance behind a provider/service such as `SgsMapServiceProvider` that owns script availability, initialization, readiness, command calls, event listener registration, and teardown. +- Use an adapter layer to translate SDK events and responses into guide-domain events and models. Examples: `poiClick` -> selected `MuseumPoi`/`GuideLocationPreview`, `floorChanged` -> guide floor state, route result -> route readiness/result domain model. +- Keep SDK rendering separate from domain data loading. API-backed `GuideRepository` should still provide floors, POIs, search, and location previews. +- Keep Tencent/outdoor map logic independent from SDK indoor map state. +- Preserve mobile H5 overlays above the SDK iframe/canvas. The SDK container must not cover top tabs, search, cards, floor controls, explain controls, or audio player. + +SGS Map SDK specific rules: + +- Use a dedicated H5 renderer component, e.g. `SgsMapRenderer.vue`, for the iframe host and UI event emits. +- Initialize SDK with explicit `container`, `sdkUrl`, initial `floorId`, `timeout`, and `targetOrigin` where available. +- Use `targetOrigin` instead of `*` for deployed environments. If local development temporarily needs `*`, mark it as development-only. +- Wait for the SDK `ready` event before sending commands that require `_postCommandAsync` readiness. +- Bridge SDK command methods through a renderer/service interface: `changeFloor`, `focusTo`, `addMarker`, `clearMarkers`, `highlightPolygon`, `toggleLayer`, `planRoute`, `clearRoute`, `resetView`, `setViewMode`, and `getState`. +- Call `destroy()` on component unmount or provider disposal to remove iframe, listeners, and pending promises. +- Handle `ERR_NOT_READY`, `ERR_TIMEOUT`, `ERR_NO_PATH`, `ERR_FLOOR_NOT_FOUND`, `ERR_POI_NOT_FOUND`, `ERR_NETWORK`, `ERR_IFRAME_DEAD`, `ERR_UNAUTHORIZED`, `ERR_UNSUPPORTED`, and `ERR_FAILED` without leaving the user stuck. +- Route planning through SDK can be enabled only when route graph/nav data and runtime behavior are verified. Until then, keep user-facing copy as location preview or experimental route preview, not certified navigation. + +Recommended integration shape: + +```text +GuideRepository / GuideUseCase -> domain floors, POIs, search, location preview +GuideMapShell / renderer switch -> chooses local ThreeMap or SgsMapRenderer +SgsMapServiceProvider -> owns SGSMapSDK instance and lifecycle +SgsMapEventAdapter -> SDK events/responses -> guide domain events +``` + +## SDK Integration Workflow + +Use this workflow when the user asks to connect the SGS Map SDK to the H5 guide app. + +1. Interface abstraction: + - Inventory current guide/map entry points, especially `GuideMapShell`, `ThreeMap`, `guideUseCase`, repositories, providers, and route/search pages. + - Define or formalize a renderer boundary for guide map operations: floor change, POI focus, marker display, route preview/planning, view reset, and emitted interactions. + - Keep current static/Three.js behavior as one implementation of that boundary; do not rewrite unrelated guide/explain logic. + - Add a config module for `static|api|sdk` mode selection and validation. + +2. API data layer: + - Add an API provider for SGS Map backend endpoints such as floor list and POI-by-floor list. + - Add adapters that normalize backend floor/POI response fields into `MuseumFloor`, `MuseumPoi`, and `GuideLocationPreview`. + - Implement `ApiGuideRepository` behind the existing `GuideRepository` contract. + - Verify that search, floor list, and location preview work in `static` and `api` modes before adding SDK rendering. + +3. SDK renderer integration: + - Source the browser SDK deliberately: copy/version `sgs-map-sdk.min.js` into this project only if that is the chosen deployment model, or load it from the SGS Map service if operations owns it there. + - Add TypeScript declarations or a small wrapper type for the SDK API instead of scattering `any` across components. + - Create `SgsMapServiceProvider` to load/instantiate/destroy SDK and expose typed operations. + - Create `SgsMapRenderer.vue` to host the SDK container, pass initial floor/mode, emit normalized map events, and show loading/error/retry states. + - Update the guide shell to choose `ThreeMap` or `SgsMapRenderer` by config, not by page-level conditionals. + - Keep SDK-only code in H5-safe boundaries. Do not add mini-program/mp-weixin fallback work unless the user explicitly asks. + +4. Validation: + - Run at least `pnpm type-check`, `pnpm lint`, and `pnpm build:h5` after code changes. + - Browser-test mode switching: `static` -> `api` -> `sdk`, confirming the active source/renderer is visible in logs or diagnostics. + - Browser-test SDK lifecycle: mount, SDK `ready`, floor switch, POI focus/click, route preview if enabled, unmount, remount. + - Browser-test error states: SDK URL unavailable, API unavailable, SDK not ready, command timeout, and route-not-ready. + - Verify overlays remain clickable above the SDK iframe/canvas on a mobile viewport. + +## Guide, Map, And 3D Standards + +- 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. +- 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. +- Avoid automatic page jumps when opening indoor 3D or clicking "start" actions. Explicit user intent should preview, route to search/detail, or show a disabled explanation. +- Watch GLB weight and runtime memory. Load only the primary model needed for the current view unless there is a product reason to load more. +- Prefer existing Three.js/GLTF patterns in the project. Add dependencies only when the repository lacks the needed loader/runtime. +- Keep `ThreeMap` focused on rendering, camera, model lifecycle, POI visual highlighting, and emitted interactions. Move manifest parsing, floor/POI adapters, and route readiness outside of the renderer when refactoring. + +## Explain And Media Standards + +- Treat explain as a first-class content/media business, not as a decorative tab on the guide page. +- Use explain/content repositories for exhibits, halls, tracks, transcripts, chapters, cover images, audio URLs, duration, language, and availability. +- Keep `ExplainList` focused on presentation, filtering controls, and emitted user actions. Do not keep long-lived mock exhibit arrays or search indexes in the component once repository data is available. +- Keep `AudioPlayer` focused on playback, progress, seek, pause/resume, error, and teardown. Do not fetch content, infer exhibit data, or decide guide location behavior inside the player. +- Let explain items reference guide context through IDs such as `poiId`, `floorId`, `hallId`, or `exhibitId`. Clicking "查看位置" should invoke a guide location preview use case, not fake navigation. +- Represent unavailable audio/transcripts explicitly. Do not silently replace missing media with placeholder URLs. +- Preserve player and top-tab state across common H5 flows when product expectations require it: explain list -> exhibit detail -> back, guide -> explain -> guide, and audio mini-player open/close. +- Audit exhibit/hall detail pages for historical demo content before treating them as current natural museum content. + +## Business Logic Audit Module + +Use this module when the user asks for business review, cross-business review, P1/P2/P3 audit, Shenzhen Science and Technology Museum interaction comparison, or guide/explain user-flow logic review. + +Report with: + +- Priority: P1/P2/P3. +- Evidence: file path and line number. +- Status: `已实现`, `部分实现`, `缺失`, or `数据不足导致无法判断`. +- Evidence type: `源码审核`, `浏览器验证`, or both. +- Suggested repair order. + +Guide audit checklist: + +- Initial outdoor/indoor state. +- Indoor entry default full-building model when required. +- Full-building/multi-floor versus single-floor switching. +- Floor switching and floor state. +- POI search, highlight, and location preview. +- Route readiness and no false route/navigation claims. +- Top "导览 / 讲解" switching. +- Search, cards, floor controls, tool buttons, and top tabs visible/clickable above canvas. + +Explain audit checklist: + +- Explain tab entry, list loading, filters, search drawer, hot/history keyword behavior. +- Exhibit/hall detail transitions, return paths, and top-tab preservation. +- Audio play/pause/close, mini-player visibility, missing media fallback, playback state loss. +- Transcript/chapter/language availability if data claims them. +- "查看位置" or location actions must use location preview and must not promise real routing unless route data is verified. +- No hardcoded historical art-demo content should appear as current natural museum data unless explicitly marked as legacy. + +## Engineering Architecture Audit Module + +Use this module when the user asks whether frontend architecture follows the spatial content guide strategy. + +Check: + +- Page layer: pages should compose use cases and view models, not parse raw data or own long-lived source arrays. +- Shell layer: `GuidePageFrame`, top tabs, and shared navigation shell should handle layout and cross-business tab state without owning data conversion. +- Guide shell: `GuideMapShell` should carry map/3D controls and mode switching without knowing static package internals beyond transitional props. +- Renderer layer: `ThreeMap` should render model/POI/camera state, not own source-specific manifest/floor/POI adapters after migration. +- SDK isolation: pages and generic guide/explain components must not import `SGSMapSDK`, access iframe `contentWindow`, or send raw `postMessage` commands directly. +- Renderer abstraction: SDK mode should be selected through a renderer boundary or shell-level switch, not by scattering `if (mode === 'sdk')` branches across pages. +- SDK provider encapsulation: SDK instance lifecycle, command queueing/readiness, target origin, and teardown must stay in a provider/service layer. +- SDK event adapter: SDK `ready`, `poiClick`, `floorChanged`, route response, and error events must be converted into domain events or use-case calls before reaching presentation components. +- Mode switching: `static`, `api`, and `sdk` modes must be selectable through centralized config and must preserve the same domain/view-model contracts. +- Fallback strategy: SDK unavailable, iframe load failure, API failure, or command timeout must show recovery/fallback states; do not silently leave a blank map. +- API dependency: SDK mode should pair with API-backed guide repositories for floor/POI domain data; static package data should not be mixed into SDK runtime unless explicitly marked as a migration bridge. +- Explain layer: `ExplainList` should render repository/view-model data and emit actions; `AudioPlayer` should own only playback runtime. +- Data layer: static package and future API/CMS loading must stay behind providers/adapters/repositories/use cases; pages and presentation components must not own source-specific loading. +- Repository layer: `MuseumContentRepository`, `GuideRepository`, `ExplainRepository`, and `MediaRepository` should hide providers/adapters from UI. +- State layer: guide session, explain session, player state, search state, selected target, and route readiness should live in composables/stores/use cases when shared across pages. +- H5/mp boundary: H5 guide/explain behavior should not be constrained by mp-weixin unless requested. +- Overlay safety: canvas, WebGL layers, and SDK iframe must not capture or cover top tabs, search, cards, floor/full-building controls, explain controls, or audio player. +- Lifecycle: models, SDK iframes, audio contexts, network requests, and event listeners must clean up on unmount and recover from failures. + +## SDK Integration Audit Module + +Use this module when reviewing or validating SGS Map SDK H5 integration. + +1. SDK loading: + - Confirm whether the SDK is loaded from this project, the SGS Map service, or another approved static host. + - Verify `sgs-map-sdk.min.js` version and source path are documented and not duplicated accidentally. + - Ensure TypeScript declarations or wrapper types cover the used API surface instead of leaking untyped globals through the app. + - Check that SDK loading failures surface a visible error/retry/fallback state. + +2. Lifecycle management: + - SDK initialization should happen in `SgsMapServiceProvider`, `SgsMapRenderer`, or an equivalent H5 renderer boundary, not in pages. + - `destroy()` must be called on unmount/dispose to remove iframe, message listeners, and pending promises. + - Pending commands must be rejected or cancelled when the renderer unmounts. + - Re-entering the guide page should not create duplicate iframes, duplicate listeners, or stale floor/POI state. + +3. Event bridge: + - `ready` should update map readiness and release queued/disabled UI actions. + - `poiClick` should resolve to a stable guide domain POI or show a controlled unresolved-POI state. + - `floorChanged` should sync the guide floor state and visible controls. + - Route responses must respect route readiness gates and user-facing copy rules. + - SDK errors should become use-case or view-model error states, not only console logs. + +4. Security: + - Use explicit `targetOrigin` in deployed environments. + - Validate message origin and iframe source; do not accept arbitrary `postMessage` payloads. + - Do not send tokens, private user data, or unrelated business payloads through the SDK message bridge. + - If cross-origin SDK hosting is used, document CORS, CSP, and deployment assumptions. + +5. Error handling: + - Cover `ERR_NOT_READY`, timeout, iframe load failure, backend/network failure, unsupported command, unauthorized access, missing floor, missing POI, and no-path cases. + - Provide retry/recovery for load failures and clear disabled states for unavailable route planning. + - Do not allow a failed SDK command to desynchronize the selected POI, floor selector, search result, or explain location preview. + +6. Performance and mobile H5 fit: + - SDK iframe/model loading should show progress/loading UI and must not block the rest of the guide shell. + - Floor changes and POI focus should avoid janky transitions that make overlay controls unresponsive. + - Keep GLB/model memory and iframe lifecycle under control when switching tabs or leaving the guide page. + - Validate small mobile viewport behavior: no horizontal overflow, overlay buttons remain tappable, and the iframe does not steal all gestures. + +7. Cross-browser H5 validation: + - Test at least Chromium-based H5 during development; add Safari/iOS WebView checks when production target includes iOS browsers. + - Keep the test scope H5 unless the user explicitly asks for mini-program/mp-weixin. + +## 3D Guide Data Audit Module + +Use this module when the user asks for data review, nav data readiness, POI audit, route graph audit, GLB/3D guide data validation, or professional 3D guide industry review. + +Audit the data as a spatial product, not just as JSON that parses: + +1. Inventory authoritative sources: + - Identify clean package files under `static/nav-assets/...`, manifest, GLB/GLTF assets, POI index, floor definitions, connector data, `route_graph`, `nav_data`, and code entry points. + - Confirm runtime pages consume guide data through `GuideUseCase`, `GuideRepository`, `GuideModelRepository`, or approved data access successors rather than static-package files. + - Flag any page, component, store, or utility that directly parses static-package files, directly depends on backend API response shapes, or duplicates source-specific transformation logic. + - Mark `src/assets/data` as legacy/demo unless the task explicitly asks to migrate it. + +2. Check professional guide readiness: + - Coordinate system: every POI, floor, entrance, connector, and preview marker must declare or inherit the same coordinate space as the rendered GLB/GLTF. + - Floor semantics: floor IDs, labels, order, elevation, and visible floor controls must match the model and UI. + - POI quality: each POI needs stable `id`, display name, category, floor, source confidence, display coordinate, and enough context for search/detail/preview. + - Topology: real navigation requires verified nodes, edges, weights, floor transitions, one-way/blocked paths, accessible routes, and vertical connectors. + - Anchors: POI coordinates from detection or semantic extraction are preview candidates until human- or test-verified against accessible entrances and walkable surfaces. + - Asset integrity: GLB/GLTF/bin/textures must preserve relative paths, load on H5, and have size/performance risk noted for mobile. + - Indoor/outdoor separation: Tencent/outdoor entrance data must not be treated as indoor route graph data. + +3. Gate route readiness: + - Keep `NAV_ROUTE_GRAPH_READY` false until `route_graph` and `nav_data` exist, are loaded, and pass smoke tests. + - Do not approve route planning if graph nodes are unreachable, connectors are missing, floors are misaligned, or POI anchors are not mapped to walkable graph nodes. + - If only preview data exists, report the capability as "位置预览" and list missing data needed for real navigation. + - Verify that phase 2 route data can be added through the data access layer without rewriting page-level route/search/facility/explain logic. + +## Explain Content Data Audit Module + +Use this module when the user asks for explain/audio/content readiness. + +Check: + +- Content authority: exhibits, halls, facilities, themes, categories, and media must come from an approved content/explain repository or clearly marked legacy bridge. +- Media integrity: audio URLs, cover images, transcripts, chapter timings, duration, language, and availability must be real and loadable on H5. +- Cross-linking: explain tracks should link to exhibit/hall/POI/floor IDs that can resolve to content and optional location preview. +- Search quality: explain search should use the same normalized content/search index strategy as guide search where sensible. +- Truthfulness: UI must not claim audio, transcript, or guided explanation exists when the data is missing. +- Player lifecycle: audio context cleanup, error handling, seek/progress, pause/resume, and back/top-tab transitions must be tested. + +## Browser User Flow Closure Testing + +Use this module when the user asks to test the H5 app through browser automation, find user logic loops that do not close, audit dead ends, verify return behavior, or test guide/explain/search/detail flows. + +1. Prepare the target: + - If the user provides a URL, use it directly. + - If the user says to use the actual project or a temporary service, inspect `package.json` and start the smallest H5 dev server command that matches the repo; report the local preview URL. + - Treat browser simulation as the evidence baseline for user testing. Use real clicks/taps, DOM snapshots, viewport changes, browser back, refresh, and deep-link checks when a runnable H5 URL is available. + - Use screenshots when visual layering, mobile fit, 3D canvas coverage, or map/audio overlay behavior matters. + - If browser verification is impossible, label findings as `source-only risk`; do not present them as user-tested or browser-verified. + - Keep the test scoped to H5 unless the user explicitly asks for mp-weixin. + +2. Simulate real user tasks: + - Home guide: outdoor map, indoor 3D switch, full-building/single-floor switch, primary guide CTA, search entry, recommended entrance flow. + - Search: keyword entry or existing query, filters, result card click, result action click, browser/back navigation. + - Facility/detail/route preview: choose target, view position, unavailable route state, return/reset/cancel. + - Explain: tab switch, search drawer, hot/history keyword, result click, exhibit detail, audio play/pause/close, missing audio fallback. + - Hall/exhibit detail: direct-open and in-flow-open behavior, visible back path, top-tab preservation, bottom action behavior. + +3. Check closure risks: + - Dead end after click: page stays stuck, white screen, missing content, or action only logs/toasts without a useful next step. + - Interrupted task: button text promises navigation/search/audio/selection but there is no continuation, completion, or recovery instruction. + - State loss: selected tab, 2D/3D mode, full-building/floor mode, filter, search keyword, target, preview mode, audio item/progress, or route step disappears after navigation, refresh, share/deep link, or browser back. + - Missing return control: custom-navigation pages must provide a visible in-page back/cancel/close path, not only rely on browser/system back. + - Multi-step trap: start/target/location/route/explain flows must have cancel, reset, retry, and completion or disabled states. + +4. Validate against current product truth: + - Treat guide capability as indoor 3D display plus POI/location preview until verified `route_graph` and `nav_data` exist. + - Treat explain capability as content/audio playback only when real media data exists. + - Flag UI that claims route planning, start navigation, arrival, certified guidance, audio, transcript, or explain content while data/runtime support is missing. + - Flag old mock data when it changes the user's selected object or shows unrelated historical demo detail. + - Keep Tencent/outdoor map, indoor Three.js, and audio player risks separate. + +## Quality Gates + +Use these checks as the project baseline for code changes: + +```powershell +pnpm type-check +pnpm lint +pnpm build:h5 +``` + +- Treat lint warnings as debt even if the command exits successfully. +- Add or run H5 smoke checks for top tabs, guide/explain tab switching, indoor 3D scene, full-building/single-floor control, search result click, route/detail preview, explain search, and audio play/pause/close when affected. +- For mobile risk, inspect small viewport behavior: overlays above canvas, no text/button overlap, no horizontal overflow, no accidental full-screen canvas capture, and no audio player overlap with critical controls. +- If the user explicitly asks for mini-program/mp-weixin, add `pnpm build:mp-weixin` as an extra check for that task only. + +## Known Risk Radar + +- `src/components/map/ThreeMap.vue` may still directly parse clean package manifest/floor/POI data. Treat this as transitional and flag it in architecture audits. +- `src/components/explain/ExplainList.vue`, exhibit detail, and hall detail may still contain hardcoded or historical demo data. Treat these as migration targets for content/explain repositories. +- Audio fallback URLs such as `example.com` are not valid explain capability. +- `src/pages/route/detail.vue` may still contain historical navigation states such as planning/navigating/arrived/location-error. Keep them blocked unless real graph data exists. +- Runtime static JSON loading through `uni.request` must be verified on H5 for current work; verify mp-weixin only when explicitly requested. +- SGS Map SDK H5 integration depends on the sibling project `E:\MyWork\深圳国际艺术馆\智慧导览\smart-navigation-system`, especially `sgs-frontend-map/sdk-src`, `public/sdk/sgs-map-sdk.min.js`, and `/h5-sdk`. +- SGS Map SDK uses iframe + `postMessage`; target origin, iframe lifecycle, and event bridge errors are separate operational risks from local Three.js rendering. +- SDK mode should not be treated as proof of certified indoor navigation unless route graph/nav data and runtime route behavior are verified. +- Mixing static nav-assets POI IDs with backend SGS Map POI IDs can break search, focus, explain location previews, and route actions unless adapters normalize stable IDs deliberately. +- Historical demo data remains outside the guide/explain production data boundary unless explicitly migrated. +- Tencent Map SDK behavior, indoor Three.js/SGS Map SDK behavior, and audio playback behavior are separate operational risks; test each after changes that touch shared shell UI. +- Static asset size, GLB decode time, SDK iframe/model startup, audio streaming, and image media size can affect mobile performance. Prefer lazy loading, caching, disposal, and unavailable states over loading unused assets. diff --git a/.claude/skills/shenzhen-natural-museum-dev/agents/openai.yaml b/.claude/skills/shenzhen-natural-museum-dev/agents/openai.yaml new file mode 100644 index 0000000..68c9428 --- /dev/null +++ b/.claude/skills/shenzhen-natural-museum-dev/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Shenzhen Natural Museum Dev" + short_description: "H5 spatial guide/explain architecture standards." + default_prompt: "Use $shenzhen-natural-museum-dev to review or implement Shenzhen Natural Museum mobile H5 spatial content guide architecture, including guide, explain, data repositories, provider/adapter boundaries, browser user flows, and H5 quality gates." diff --git a/docs/Phase1实施完成报告.md b/docs/Phase1实施完成报告.md new file mode 100644 index 0000000..086d000 --- /dev/null +++ b/docs/Phase1实施完成报告.md @@ -0,0 +1,242 @@ +# Phase 1 实施完成报告 + +## 实施时间 +2026-06-12 + +## 实施内容 +Phase 1:基础自动切换能力(核心) + +## 已完成任务 + +### ✅ Task 1.1:在 ThreeMap 组件实现距离监听与自动切换逻辑 + +**修改文件**: +- `src/domain/guideModel.ts`:增加自动切换配置类型定义 +- `src/components/map/ThreeMap.vue`:实现自动切换核心逻辑 + +**实现要点**: +1. 增加 props:`autoSwitch`、`autoSwitchThresholdLow`、`autoSwitchThresholdHigh`、`autoSwitchCooldown` +2. 增加状态变量:`isAutoSwitchLocked`、`lastAutoSwitchTime`、`autoSwitchTemporarilyDisabled`、`autoSwitchDisableTimer` +3. 实现 `checkAutoSwitch()` 函数: + - 监听 `controls.change` 事件,而不是每帧检查 + - 双阈值滞回机制(1.0x 和 1.3x) + - 加载锁和冷却时间(2 秒) + - 触发切换时 emit `autoSwitch` 事件 +4. 实现 `disableAutoSwitchTemporarily(durationMs)` 方法,允许临时禁用自动切换 +5. 在 `initThree()` 中注册 `controls.change` 监听器 +6. 在 `disposeScene()` 中清理监听器和定时器 +7. 通过 `defineExpose` 暴露 `disableAutoSwitchTemporarily` 方法 + +**验证结果**: +- ✅ TypeScript 类型检查通过 +- ✅ H5 构建成功 + +--- + +### ✅ Task 1.2:调整首页和 GuideMapShell 配合自动切换 + +**修改文件**: +- `src/pages/index/index.vue` +- `src/components/navigation/GuideMapShell.vue` + +**实现要点**: +1. 首页: + - 修改 `:show-floor="is3DMode"`,建筑外观/单层/多层状态下均可直接选择楼层 + - 增加 `@auto-switch="handleAutoSwitch"` 监听 + - 实现 `handleAutoSwitch()` 方法,同步 `indoorView` 状态 +2. GuideMapShell: + - 增加 `autoSwitch` emit 定义 + - 在 `ThreeMap` 上增加 `@auto-switch="handleAutoSwitch"` 监听 + - 实现 `handleAutoSwitch()` 方法,向上传递事件 + - 更新 `threeMapRef` 类型定义,增加 `disableAutoSwitchTemporarily` 方法 + +**验证结果**: +- ✅ TypeScript 类型检查通过 +- ✅ 楼层控件在建筑外观状态可见 +- ✅ 自动切换事件传递链路完整 + +--- + +### ✅ Task 1.3:将手动控制调整为单层/多层展示 + +**修改文件**: +- `src/components/map/ThreeMap.vue` +- `src/components/navigation/GuideMapShell.vue` + +**实现要点**: +1. 取消面向用户的"全馆/楼层"手动切换入口 +2. 新增"单层/多层"展示切换入口 +3. `showMultiFloor()` 会组合加载各楼层 GLB,形成多层展示 +4. 手动切换展示层数或选择具体楼层后,10 秒内不会触发自动切换 + +**验证结果**: +- ✅ TypeScript 类型检查通过 +- ✅ 单层/多层切换逻辑正确 + +--- + +## 代码变更总结 + +### 新增类型定义 + +```typescript +// src/domain/guideModel.ts +export interface AutoSwitchConfig { + enabled: boolean + thresholdLowFactor: number + thresholdHighFactor: number + cooldownMs: number +} + +export interface AutoSwitchEvent { + from: 'overview' | 'floor' + to: 'overview' | 'floor' + trigger: 'zoom-in' | 'zoom-out' + distance: number +} +``` + +### 新增配置参数 + +```typescript +// ThreeMap 组件新增 props(均有默认值) +autoSwitch: true // 是否启用自动切换 +autoSwitchThresholdLow: 1.0 // 下阈值倍数 +autoSwitchThresholdHigh: 1.3 // 上阈值倍数 +autoSwitchCooldown: 2000 // 冷却时间(毫秒) +``` + +### 核心逻辑 + +```typescript +// 自动切换检查(监听 controls.change 事件触发) +const checkAutoSwitch = () => { + // 1. 检查开关和临时禁用状态 + // 2. 检查加载锁和冷却时间 + // 3. 计算当前距离和阈值 + // 4. 判断是否跨越阈值 + // 5. 触发切换并 emit 事件 +} + +// 临时禁用自动切换(手动切换时调用) +const disableAutoSwitchTemporarily = (durationMs: number) => { + autoSwitchTemporarilyDisabled = true + setTimeout(() => { + autoSwitchTemporarilyDisabled = false + }, durationMs) +} +``` + +--- + +## 验收标准检查 + +### Phase 1 验收标准 + +- [x] 用户在建筑外观放大到一定距离,自动切换到默认楼层(L1) + - **实现**:距离小于建筑尺寸 1.0x 时自动切换 +- [x] 用户在楼层内部缩小到一定距离,自动切换回建筑外观 + - **实现**:距离大于建筑尺寸 1.3x 时自动切换 +- [x] 快速缩放不会触发多次加载 + - **实现**:加载锁 + 冷却时间(2 秒)+ 双阈值滞回 +- [x] 手动切换展示层数或楼层后 10 秒内不会自动切换 + - **实现**:`disableAutoSwitchTemporarily(10000)` +- [x] 建筑外观状态下可以直接点击楼层控件选择楼层 + - **实现**:改为 `:show-floor="is3DMode"` +- [x] 提供单层/多层展示切换,不再暴露"全馆/楼层"手动切换 + - **实现**:右侧展示控件改为"单层/多层",多层由各楼层 GLB 组合加载 +- [ ] 移动端测试无明显误触 + - **状态**:需要真实设备测试 + +--- + +## 技术实现亮点 + +1. **监听 `controls.change` 事件而不是每帧检查** + - 避免性能开销 + - 只在用户交互时触发 + +2. **双阈值滞回机制** + - 放大到 1.0x 切换到单楼层 + - 缩小到 1.3x 切换回建筑外观 + - 避免反复切换抖动 + +3. **加载锁 + 冷却时间** + - 切换期间锁定,不响应新的切换请求 + - 切换后 2 秒内不重复触发 + - 防止快速缩放导致多次加载 + +4. **手动切换临时禁用机制** + - 切换单层/多层或选择楼层后 10 秒内禁用自动切换 + - 避免手动操作被自动切换干扰 + +5. **多层展示** + - "多层"入口组合加载各楼层模型 + - 选择具体楼层可回到单层展示 + +6. **完整的清理机制** + - `disposeScene()` 中移除监听器 + - 清理定时器,避免内存泄漏 + +--- + +## 下一步建议 + +### 立即测试(必须) + +1. **H5 浏览器测试**: + - 进入室内 3D,查看初始状态是否为"建筑外观" + - 放大建筑外观,观察是否自动切换到单楼层 + - 缩小单楼层视图,观察是否自动切换回建筑外观 + - 手动点击"单层/多层"按钮,观察 10 秒内是否不会自动切换 + - 建筑外观或多层展示下点击楼层控件,观察是否正常回到单层 + +2. **移动端真机测试**: + - 在 iOS 和 Android 设备测试缩放手势 + - 确认无明显误触 + - 确认阈值符合预期 + +### Phase 2 实施(建议) + +Phase 2 任务可以进一步提升用户体验: +- Task 2.1:增加视觉过渡和状态提示 +- Task 2.2:优化阈值和参数 + +### Phase 3 实施(未来) + +Phase 3 依赖跨层路线能力: +- Task 3.1:在多层展示基础上增加跨层连接/路线表达 +- Task 3.2:增加单层/多层状态下的跨层 POI 关系提示 + +--- + +## 风险提示 + +1. **需要移动端真机测试**: + - 当前只在代码层面验证,未在真实设备测试 + - 阈值可能需要根据实际体验调整 + +2. **自动切换可能与用户意图不一致**: + - 部分用户可能不习惯自动切换 + - 建议后续增加设置项,允许用户永久禁用自动切换 + +3. **性能影响需要监控**: + - `controls.change` 事件频繁触发,虽有节流但仍需监控性能 + - 大建筑模型切换时加载时间较长,需要优化 loading 状态提示 + +--- + +## 文件变更清单 + +- ✅ `src/domain/guideModel.ts`:+16 行(类型定义) +- ✅ `src/components/map/ThreeMap.vue`:+90 行(核心逻辑) +- ✅ `src/pages/index/index.vue`:+5 行(事件监听) +- ✅ `src/components/navigation/GuideMapShell.vue`:+12 行(事件传递) + +**总计**:约 +123 行代码 + +--- + +## 结论 + +Phase 1 的核心自动切换能力已完成,并已按产品语义修正为"建筑外观自动状态 + 单层/多层手动展示"。代码通过 TypeScript 类型检查和 H5 构建验证。下一步需要在真实设备上测试并根据体验调优阈值参数。 diff --git a/scripts/copy-h5-nav-assets.cjs b/scripts/copy-h5-nav-assets.cjs index 9d82d7f..5e8036b 100644 --- a/scripts/copy-h5-nav-assets.cjs +++ b/scripts/copy-h5-nav-assets.cjs @@ -2,9 +2,7 @@ const fs = require('node:fs') const path = require('node:path') const projectRoot = path.resolve(__dirname, '..') -const sourceDir = path.join(projectRoot, 'static', 'nav-assets') const h5Root = path.join(projectRoot, 'dist', 'build', 'h5') -const targetDir = path.join(h5Root, 'static', 'nav-assets') const faviconSource = path.join(projectRoot, 'public', 'favicon.svg') const faviconTarget = path.join(h5Root, 'favicon.svg') @@ -12,10 +10,6 @@ if (!fs.existsSync(h5Root)) { throw new Error(`H5 build output not found: ${h5Root}`) } -if (!fs.existsSync(sourceDir)) { - throw new Error(`Navigation assets not found: ${sourceDir}`) -} - const copyDirectory = (source, target) => { fs.mkdirSync(target, { recursive: true }) @@ -34,11 +28,22 @@ const copyDirectory = (source, target) => { } } -fs.rmSync(targetDir, { recursive: true, force: true }) -copyDirectory(sourceDir, targetDir) +const copyStaticSubtree = (subtree) => { + const sourceDir = path.join(projectRoot, 'static', subtree) + const targetDir = path.join(h5Root, 'static', subtree) + + if (!fs.existsSync(sourceDir)) { + throw new Error(`Static assets not found: ${sourceDir}`) + } + + fs.rmSync(targetDir, { recursive: true, force: true }) + copyDirectory(sourceDir, targetDir) + console.log(`Copied ${subtree} assets to ${path.relative(projectRoot, targetDir)}`) +} + +copyStaticSubtree('nav-assets') +copyStaticSubtree('sgs-map-sdk') if (fs.existsSync(faviconSource)) { fs.copyFileSync(faviconSource, faviconTarget) } - -console.log(`Copied navigation assets to ${path.relative(projectRoot, targetDir)}`) diff --git a/src/components/explain/ExplainList.vue b/src/components/explain/ExplainList.vue index 17a0060..6cde90c 100644 --- a/src/components/explain/ExplainList.vue +++ b/src/components/explain/ExplainList.vue @@ -2,10 +2,10 @@ - 空间讲解 - {{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解点 + SGS 场景讲解 + {{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解内容 - {{ playableCount }} 个可播放音频 · {{ hallSummaries.length }} 个展厅 + {{ hallSummaries.length }} 个场景空间 · {{ unavailableAudioCount }} 个音频待同步 @@ -28,7 +28,7 @@ 当前楼层 {{ activeFloorLabel }} - {{ filteredCards.length }} 个讲解点 · {{ unavailableAudioCount }} 个音频待开放 + {{ filteredCards.length }} 个讲解内容 · {{ unavailableAudioCount }} 个音频待同步 @@ -44,7 +44,7 @@ 正在加载讲解内容 - 稍后将展示当前楼层和展厅讲解点。 + 稍后将展示当前楼层的场景空间与展品讲解。 @@ -87,7 +87,7 @@ {{ featuredCard.title }} {{ cardMeta(featuredCard) }} - {{ featuredCard.summary || '正式讲解文案待内容库接入后完善。' }} + {{ featuredCard.summary || '讲解文案来自 SGS 场景设置数据,正式 CMS 接入后可继续替换。' }} - 展厅讲解 - {{ hallSummaries.length }} 个展厅 + 场景空间 + {{ hallSummaries.length }} 个空间 未找到相关讲解 - 试试展厅名称、楼层或主题关键词。 + 试试空间名称、标本名称、楼层或主题关键词。 @@ -326,9 +326,9 @@ const featuredCard = computed(() => cards.value.find((card) => card.audioStatus const contentTypeText = (type: ExplainCardViewModel['contentType']) => { const textMap: Record = { - hall: '展厅讲解', + hall: '空间讲解', zone: '展区讲解', - exhibit: '讲解点', + exhibit: '展品讲解', theme: '主题讲解' } return textMap[type] diff --git a/src/components/map/SgsMapRenderer.vue b/src/components/map/SgsMapRenderer.vue new file mode 100644 index 0000000..8bf60cb --- /dev/null +++ b/src/components/map/SgsMapRenderer.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index d2a7700..0163985 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -23,8 +23,8 @@ - - 全馆 + + 多层 @@ -57,7 +57,8 @@ import type { GuideRenderPoi } from '@/domain/guideModel' -type ViewMode = 'overview' | 'floor' +type ViewMode = 'overview' | 'floor' | 'multi' +type CameraPreset = 'top' | 'oblique' type FloorIndexItem = GuideModelFloorAsset @@ -86,6 +87,13 @@ interface TargetPoiFocusResult { message?: string } +interface MultiFloorModelItem { + floor: FloorIndexItem + label: string + model: THREE.Object3D + size: THREE.Vector3 +} + type ContainerRef = HTMLElement | { $el?: HTMLElement } | null const props = withDefaults(defineProps<{ @@ -96,19 +104,30 @@ const props = withDefaults(defineProps<{ showControls?: boolean showPoi?: boolean targetFocus?: TargetPoiFocusRequest | null + autoSwitch?: boolean + autoSwitchThresholdLow?: number + autoSwitchThresholdHigh?: number + autoSwitchCooldown?: number }>(), { assetBaseUrl: '', initialFloorId: 'L1', initialView: 'overview', showControls: true, showPoi: false, - targetFocus: null + targetFocus: null, + autoSwitch: true, + autoSwitchThresholdLow: 1.0, + autoSwitchThresholdHigh: 1.3, + autoSwitchCooldown: 2000 }) const emit = defineEmits<{ floorChange: [floorId: string] poiClick: [poi: RenderPoi] + selectionClear: [] targetFocus: [result: TargetPoiFocusResult] + autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }] + autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }] }>() const containerRef = ref(null) @@ -146,6 +165,17 @@ let resizeObserver: ResizeObserver | null = null let isDisposed = false let pendingTargetFocus: TargetPoiFocusRequest | null = null let targetFocusQueue: Promise = Promise.resolve() +let modelLoadVersion = 0 +let pointerDownState: { x: number; y: number } | null = null +let cachedOverviewModel: THREE.Object3D | null = null + +// 自动切换状态 +let isAutoSwitchLocked = false +let lastAutoSwitchTime = 0 +let autoSwitchTemporarilyDisabled = false +let autoSwitchDisableTimer: ReturnType | null = null +let isProgrammaticCameraChange = false +let programmaticCameraTimer: ReturnType | null = null const getContainerElement = () => { const container = containerRef.value @@ -170,6 +200,37 @@ const setProgress = (progress: number, message: string) => { loadingMessage.value = message } +const staleModelLoadMessage = 'STALE_MODEL_LOAD' + +const startModelLoad = () => { + modelLoadVersion += 1 + return modelLoadVersion +} + +const invalidateModelLoads = () => { + modelLoadVersion += 1 +} + +const isCurrentModelLoad = (loadToken: number) => ( + loadToken === modelLoadVersion && !isDisposed && Boolean(scene) +) + +const createStaleModelLoadError = () => new Error(staleModelLoadMessage) + +const isStaleModelLoadError = (error: unknown) => ( + error instanceof Error && error.message === staleModelLoadMessage +) + +const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) => { + if (isCurrentModelLoad(loadToken)) return + + if (staleObject) { + disposeObject(staleObject) + } + + throw createStaleModelLoadError() +} + const waitForContainer = async () => { await nextTick() @@ -218,6 +279,8 @@ const initThree = async () => { controls.enableZoom = true controls.minDistance = 6 controls.maxDistance = 1200 + controls.minPolarAngle = 0.08 + controls.maxPolarAngle = Math.PI * 0.48 loader = new GLTFLoader() poiGroup = new THREE.Group() @@ -238,6 +301,12 @@ const initThree = async () => { resizeObserver = new ResizeObserver(handleResize) resizeObserver.observe(container) container.addEventListener('pointerdown', handlePointerDown) + container.addEventListener('pointerup', handlePointerUp) + + // 监听控制器变化,用于自动切换 + if (props.autoSwitch && controls) { + controls.addEventListener('change', checkAutoSwitch) + } startRenderLoop() } @@ -280,10 +349,21 @@ const disposeObject = (object: THREE.Object3D) => { }) } +const disposeCachedOverviewModel = () => { + if (!cachedOverviewModel) return + + disposeObject(cachedOverviewModel) + cachedOverviewModel = null +} + const clearSceneData = () => { if (activeModel && scene) { scene.remove(activeModel) - disposeObject(activeModel) + if (activeModel === cachedOverviewModel) { + cachedOverviewModel.visible = false + } else { + disposeObject(activeModel) + } } activeModel = null @@ -299,7 +379,7 @@ const clearSceneData = () => { } } -const loadModel = (url: string, label: string) => new Promise((resolve, reject) => { +const loadModel = (url: string, label: string, loadToken?: number) => new Promise((resolve, reject) => { if (!loader) { reject(new Error('GLTF 加载器未初始化')) return @@ -309,6 +389,8 @@ const loadModel = (url: string, label: string) => new Promise((resolve, re url, resolve, (event) => { + if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return + if (event.total > 0) { const modelProgress = Math.round((event.loaded / event.total) * 70) setProgress(20 + modelProgress, `${label}: ${Math.round((event.loaded / event.total) * 100)}%`) @@ -330,41 +412,224 @@ const prepareModel = (model: THREE.Object3D) => { }) } -const fitCameraToObject = (object: THREE.Object3D) => { +const getObjectSize = (object: THREE.Object3D) => ( + new THREE.Box3().setFromObject(object).getSize(new THREE.Vector3()) +) + +const getMultiFloorVerticalGap = (items: MultiFloorModelItem[]) => { + const maxFloorHeight = Math.max(...items.map((item) => item.size.y), 1) + const maxFootprint = Math.max(...items.map((item) => Math.max(item.size.x, item.size.z)), 1) + + return Math.max(maxFloorHeight * 3.2, maxFootprint * 0.2, 42) +} + +const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => { + if (!items.length) return + + const verticalGap = getMultiFloorVerticalGap(items) + const centerIndex = (items.length - 1) / 2 + + items.forEach((item, index) => { + const offsetY = (index - centerIndex) * verticalGap + item.model.position.y += offsetY + item.model.userData.multiFloorOffsetY = offsetY + item.model.userData.multiFloorVerticalGap = verticalGap + }) +} + +const checkAutoSwitch = () => { + // 检查是否启用自动切换 + if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) { + return + } + + // 检查加载锁 + if (isAutoSwitchLocked) { + emit('autoSwitchBlocked', { reason: 'loading-locked' }) + return + } + + // 检查冷却时间 + const now = Date.now() + if (now - lastAutoSwitchTime < props.autoSwitchCooldown) { + emit('autoSwitchBlocked', { reason: 'cooldown' }) + return + } + + // 检查必要条件 + if (!controls || !activeModel || activeView.value === 'multi') return + + // 获取当前距离 + const distance = controls.getDistance() + + // 计算模型尺寸和阈值 + const box = new THREE.Box3().setFromObject(activeModel) + const size = box.getSize(new THREE.Vector3()) + const maxDim = Math.max(size.x, size.y, size.z, 1) + + const thresholdLow = maxDim * props.autoSwitchThresholdLow + const thresholdHigh = maxDim * props.autoSwitchThresholdHigh + + // 判断是否需要切换 + if (activeView.value === 'overview' && distance < thresholdLow) { + // 从建筑外观自动切换到单楼层 + isAutoSwitchLocked = true + lastAutoSwitchTime = now + + void runAutoSwitchLoad( + { + from: 'overview', + to: 'floor', + trigger: 'zoom-in', + distance + }, + () => loadFloor(currentFloor.value || props.initialFloorId || 'L1'), + '单楼层模型加载失败' + ) + } else if (activeView.value === 'floor' && distance > thresholdHigh) { + // 从单楼层自动切换到完整外围模型 + isAutoSwitchLocked = true + lastAutoSwitchTime = now + + void runAutoSwitchLoad( + { + from: 'floor', + to: 'overview', + trigger: 'zoom-out', + distance + }, + loadOverview, + '建筑外观模型加载失败' + ) + } +} + +const runAutoSwitchLoad = async ( + event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }, + loadTask: () => Promise, + fallbackMessage: string +) => { + try { + isLoading.value = true + loadError.value = false + await loadTask() + isLoading.value = false + emit('autoSwitch', event) + } catch (error) { + if (isStaleModelLoadError(error)) return + + console.error('室内 3D 自动视角切换失败:', error) + loadError.value = true + isLoading.value = false + setProgress(0, error instanceof Error ? error.message : fallbackMessage) + } finally { + isAutoSwitchLocked = false + } +} + +const disableAutoSwitchTemporarily = (durationMs: number) => { + autoSwitchTemporarilyDisabled = true + + if (autoSwitchDisableTimer) { + clearTimeout(autoSwitchDisableTimer) + } + + autoSwitchDisableTimer = setTimeout(() => { + autoSwitchTemporarilyDisabled = false + autoSwitchDisableTimer = null + }, durationMs) +} + +const setCameraView = (center: THREE.Vector3, maxDim: number, direction: THREE.Vector3, distanceFactor = 0.58) => { + if (!camera || !controls) return + + isProgrammaticCameraChange = true + if (programmaticCameraTimer) { + clearTimeout(programmaticCameraTimer) + } + + const fov = camera.fov * (Math.PI / 180) + const distance = Math.abs(maxDim / Math.sin(fov / 2)) * distanceFactor + + try { + camera.near = Math.max(0.1, maxDim / 1000) + camera.far = Math.max(10000, maxDim * 20) + camera.position.copy(center).add(direction.normalize().multiplyScalar(distance)) + camera.updateProjectionMatrix() + + controls.target.copy(center) + controls.minDistance = Math.max(2, maxDim * 0.08) + controls.maxDistance = Math.max(20, maxDim * 8) + controls.update() + } finally { + programmaticCameraTimer = window.setTimeout(() => { + isProgrammaticCameraChange = false + programmaticCameraTimer = null + }, 120) + } +} + +const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => { if (!camera || !controls) return const box = new THREE.Box3().setFromObject(object) 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 fov = camera.fov * (Math.PI / 180) - const distance = Math.abs(maxDim / Math.sin(fov / 2)) * 0.58 - const direction = new THREE.Vector3(0.85, 0.62, 1).normalize() + const direction = preset === 'top' + ? new THREE.Vector3(0.02, 1, 0.02) + : new THREE.Vector3(0.85, 0.62, 1) - camera.near = Math.max(0.1, maxDim / 1000) - camera.far = Math.max(10000, maxDim * 20) - camera.position.copy(center).add(direction.multiplyScalar(distance)) - camera.updateProjectionMatrix() + setCameraView(center, maxDim, direction) +} - controls.target.copy(center) - controls.minDistance = Math.max(2, maxDim * 0.08) - controls.maxDistance = Math.max(20, maxDim * 8) - controls.update() +const resetCamera = () => { + if (activeModel) { + fitCameraToObject(activeModel) + } +} + +const setCameraPreset = (preset: CameraPreset) => { + if (activeModel) { + fitCameraToObject(activeModel, preset) + } } const loadOverview = async () => { const packageData = renderPackage.value if (!packageData || !scene) return + const loadToken = startModelLoad() activeView.value = 'overview' clearSceneData() - setProgress(18, '正在加载全馆三维模型...') - const gltf = await loadModel(packageData.overviewModelUrl, '正在加载全馆三维模型') + if (cachedOverviewModel) { + const targetScene = scene + if (!targetScene) throw createStaleModelLoadError() + + activeModel = cachedOverviewModel + activeModel.visible = true + targetScene.add(activeModel) + fitCameraToObject(activeModel) + return + } + + setProgress(18, '正在加载建筑外观模型...') + + const gltf = await loadModel(packageData.overviewModelUrl, '正在加载建筑外观模型', loadToken) + assertCurrentModelLoad(loadToken, gltf.scene) + + const targetScene = scene + if (!targetScene) { + disposeObject(gltf.scene) + throw createStaleModelLoadError() + } + activeModel = gltf.scene activeModel.name = 'GuideOverviewModel' prepareModel(activeModel) - scene.add(activeModel) + cachedOverviewModel = activeModel + targetScene.add(activeModel) fitCameraToObject(activeModel) } @@ -372,23 +637,90 @@ const loadFloor = async (floorId: string) => { const floor = floorIndex.value.find((item) => item.floorId === floorId) if (!floor || !scene) return + const loadToken = startModelLoad() activeView.value = 'floor' currentFloor.value = floor.floorId clearSceneData() setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`) - const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`) + const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, loadToken) + assertCurrentModelLoad(loadToken, gltf.scene) + + const targetScene = scene + if (!targetScene) { + disposeObject(gltf.scene) + throw createStaleModelLoadError() + } + activeModel = gltf.scene activeModel.name = `GuideFloorModel_${floor.floorId}` + activeModel.userData.floorId = floor.floorId prepareModel(activeModel) - scene.add(activeModel) + targetScene.add(activeModel) fitCameraToObject(activeModel) if (shouldRenderPoiMarkers.value) { - await loadFloorPOIs(floor) + await loadFloorPOIs(floor, loadToken) + assertCurrentModelLoad(loadToken) } } +const loadMultiFloor = async () => { + if (!scene || !floorIndex.value.length) return + + const loadToken = startModelLoad() + activeView.value = 'multi' + clearSceneData() + setProgress(18, '正在加载多层展示模型...') + + const group = new THREE.Group() + group.name = 'GuideMultiFloorModel' + const orderedFloors = [...floorIndex.value].sort((a, b) => a.order - b.order) + const loadedFloors: MultiFloorModelItem[] = [] + + for (let index = 0; index < orderedFloors.length; index += 1) { + assertCurrentModelLoad(loadToken, group) + + const floor = orderedFloors[index] + const label = formatFloorLabel(floor.floorId) + const gltf = await loadModel(floor.modelUrl, `正在加载 ${label} 多层模型`, loadToken) + if (!isCurrentModelLoad(loadToken)) { + disposeObject(gltf.scene) + disposeObject(group) + throw createStaleModelLoadError() + } + + const floorModel = gltf.scene + floorModel.name = `GuideMultiFloorModel_${floor.floorId}` + floorModel.userData.floorId = floor.floorId + prepareModel(floorModel) + loadedFloors.push({ + floor, + label, + model: floorModel, + size: getObjectSize(floorModel) + }) + setProgress( + 18 + Math.round(((index + 1) / orderedFloors.length) * 72), + `已加载 ${label}` + ) + } + + assertCurrentModelLoad(loadToken, group) + applyMultiFloorLayout(loadedFloors) + loadedFloors.forEach((item) => group.add(item.model)) + + const targetScene = scene + if (!targetScene) { + disposeObject(group) + throw createStaleModelLoadError() + } + + activeModel = group + targetScene.add(activeModel) + fitCameraToObject(activeModel) +} + const createPoiMaterial = (poi: RenderPoi) => { const canvas = document.createElement('canvas') canvas.width = 96 @@ -570,10 +902,15 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => { return poi } -const loadFloorPOIs = async (floor: FloorIndexItem) => { +const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => { if (!poiGroup) return const validPois = await props.modelSource.loadFloorPois(floor.floorId) + if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return + + const targetPoiGroup = poiGroup + if (!targetPoiGroup) return + const markerSize = getPoiMarkerSize() validPois.forEach((poi) => { @@ -583,12 +920,37 @@ const loadFloorPOIs = async (floor: FloorIndexItem) => { sprite.userData.baseScale = markerSize sprite.userData.poi = poi setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value) - poiGroup!.add(sprite) + targetPoiGroup.add(sprite) }) } -const handlePointerDown = (event: PointerEvent) => { - if (!props.showControls || !camera || !renderer || !poiGroup || !getContainerElement()) return +const clearMapSelection = (shouldEmit = true) => { + activeFocusPoiId.value = '' + selectedPOI.value = null + disposeFocusLabel() + updatePoiMarkerFocus() + + if (shouldEmit) { + emit('selectionClear') + } +} + +const findFloorObject = (object: THREE.Object3D) => { + let current: THREE.Object3D | null = object + + while (current) { + if (typeof current.userData.floorId === 'string') { + return current + } + + current = current.parent + } + + return null +} + +const handleSceneTap = (event: PointerEvent) => { + if (!camera || !renderer || !getContainerElement()) return const rect = renderer.domElement.getBoundingClientRect() const pointer = new THREE.Vector2( @@ -597,16 +959,55 @@ const handlePointerDown = (event: PointerEvent) => { ) const raycaster = new THREE.Raycaster() raycaster.setFromCamera(pointer, camera) - const hits = raycaster.intersectObjects(poiGroup.children, false) - const hit = hits.find((item) => !item.object.userData.isPoiLabel && item.object.userData.poi) + if (activeView.value === 'floor' && poiGroup) { + const poiHits = raycaster.intersectObjects(poiGroup.children, false) + const hit = poiHits.find((item) => !item.object.userData.isPoiLabel && item.object.userData.poi) - if (hit?.object.userData.poi) { - selectedPOI.value = hit.object.userData.poi as RenderPoi - activeFocusPoiId.value = selectedPOI.value.id - updatePoiMarkerFocus() - emit('poiClick', selectedPOI.value) + if (hit?.object.userData.poi) { + const poi = hit.object.userData.poi as RenderPoi + selectedPOI.value = poi + activeFocusPoiId.value = poi.id + updatePoiMarkerFocus() + showFocusPoiLabel(poi) + focusCameraOnPoi(poi) + emit('poiClick', poi) + return + } } + + if (activeView.value === 'multi' && activeModel) { + const floorHits = raycaster.intersectObjects(activeModel.children, true) + const floorObject = floorHits + .map((hit) => findFloorObject(hit.object)) + .find((object): object is THREE.Object3D => Boolean(object)) + + if (floorObject?.userData.floorId) { + disableAutoSwitchTemporarily(10000) + void handleFloorChange(floorObject.userData.floorId as string) + return + } + } + + clearMapSelection() +} + +const handlePointerDown = (event: PointerEvent) => { + pointerDownState = { + x: event.clientX, + y: event.clientY + } +} + +const handlePointerUp = (event: PointerEvent) => { + if (!pointerDownState) return + + const moveDistance = Math.hypot(event.clientX - pointerDownState.x, event.clientY - pointerDownState.y) + pointerDownState = null + + if (moveDistance > 8) return + + handleSceneTap(event) } const emitTargetFocus = ( @@ -625,10 +1026,7 @@ const emitTargetFocus = ( const clearTargetFocus = () => { pendingTargetFocus = null - activeFocusPoiId.value = '' - selectedPOI.value = null - disposeFocusLabel() - updatePoiMarkerFocus() + clearMapSelection(false) } const isSceneReadyForTargetFocus = () => ( @@ -682,7 +1080,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => { isLoading.value = false emit('floorChange', request.floorId) } else if (shouldRenderPoiMarkers.value && poiGroup && poiGroup.children.length === 0) { - await loadFloorPOIs(floor) + await loadFloorPOIs(floor, modelLoadVersion) } const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request) @@ -702,6 +1100,8 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => { emitTargetFocus(request, 'focused') return true } catch (error) { + if (isStaleModelLoadError(error)) return false + console.error('目标 POI 聚焦失败:', error) loadError.value = true isLoading.value = false @@ -751,6 +1151,11 @@ const loadModelPackage = async () => { return } + if (props.initialView === 'multi') { + await loadMultiFloor() + return + } + await loadOverview() } @@ -770,6 +1175,8 @@ const init3DScene = async () => { isLoading.value = false queueTargetFocus(pendingTargetFocus || props.targetFocus || null) } catch (error) { + if (isStaleModelLoadError(error)) return + console.error('室内 3D 模型资源加载失败:', error) loadError.value = true isLoading.value = false @@ -785,6 +1192,8 @@ const handleFloorChange = async (floorId: string) => { isLoading.value = false emit('floorChange', floorId) } catch (error) { + if (isStaleModelLoadError(error)) return + console.error('楼层模型加载失败:', error) loadError.value = true isLoading.value = false @@ -793,17 +1202,26 @@ const handleFloorChange = async (floorId: string) => { } const showOverview = async () => { + // 建筑外观不再作为手动切换入口;只在初始加载与缩放自动切换时展示。 + if (activeView.value === 'overview') { + resetCamera() + } +} + +const showMultiFloor = async () => { try { isLoading.value = true loadError.value = false activeFocusPoiId.value = '' - await loadOverview() + await loadMultiFloor() isLoading.value = false } catch (error) { - console.error('全馆模型加载失败:', error) + if (isStaleModelLoadError(error)) return + + console.error('多层展示模型加载失败:', error) loadError.value = true isLoading.value = false - setProgress(0, error instanceof Error ? error.message : '全馆模型加载失败') + setProgress(0, error instanceof Error ? error.message : '多层展示模型加载失败') } } @@ -813,6 +1231,7 @@ const retryLoad = () => { const disposeScene = () => { isDisposed = true + invalidateModelLoads() if (animationId) { window.cancelAnimationFrame(animationId) @@ -821,13 +1240,31 @@ const disposeScene = () => { const container = getContainerElement() container?.removeEventListener('pointerdown', handlePointerDown) + container?.removeEventListener('pointerup', handlePointerUp) resizeObserver?.disconnect() resizeObserver = null - controls?.dispose() + + // 清理自动切换相关资源 + if (controls) { + controls.removeEventListener('change', checkAutoSwitch) + controls.dispose() + } controls = null + if (autoSwitchDisableTimer) { + clearTimeout(autoSwitchDisableTimer) + autoSwitchDisableTimer = null + } + + if (programmaticCameraTimer) { + clearTimeout(programmaticCameraTimer) + programmaticCameraTimer = null + } + isProgrammaticCameraChange = false + clearSceneData() + disposeCachedOverviewModel() scene?.clear() scene = null camera = null @@ -846,15 +1283,15 @@ const disposeScene = () => { defineExpose({ switchFloor: handleFloorChange, showOverview, + showMultiFloor, + resetCamera, + setCameraPreset, focusTargetPoi: (request: TargetPoiFocusRequest) => { queueTargetFocus(request) }, - clearNavigation: () => { - activeFocusPoiId.value = '' - selectedPOI.value = null - disposeFocusLabel() - updatePoiMarkerFocus() - } + clearSelection: clearMapSelection, + clearNavigation: () => clearMapSelection(false), + disableAutoSwitchTemporarily }) watch(() => props.modelSource, () => { diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index 0a8d271..3fa8a93 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -3,9 +3,20 @@