提交室内导览交互与讲解优化

This commit is contained in:
lyf
2026-06-14 23:48:13 +08:00
parent a7c1879f60
commit feb7310a46
33 changed files with 3257 additions and 361 deletions

View File

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

View File

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

View File

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

View File

@@ -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 构建验证。下一步需要在真实设备上测试并根据体验调优阈值参数。

View File

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

View File

@@ -2,10 +2,10 @@
<view class="explain-list">
<view class="spatial-stage">
<view class="stage-copy">
<text class="stage-kicker">空间讲解</text>
<text class="stage-title">{{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解</text>
<text class="stage-kicker">SGS 场景讲解</text>
<text class="stage-title">{{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解内容</text>
<text class="stage-desc">
{{ playableCount }} 可播放音频 · {{ hallSummaries.length }} 展厅
{{ hallSummaries.length }} 场景空间 · {{ unavailableAudioCount }} 音频待同步
</text>
</view>
<view class="stage-map">
@@ -28,7 +28,7 @@
<view class="summary-main">
<text class="summary-title">当前楼层 {{ activeFloorLabel }}</text>
<text class="summary-subtitle">
{{ filteredCards.length }} 个讲解 · {{ unavailableAudioCount }} 个音频待开放
{{ filteredCards.length }} 个讲解内容 · {{ unavailableAudioCount }} 个音频待同步
</text>
</view>
<view class="summary-chip">
@@ -44,7 +44,7 @@
<input
class="search-input"
type="text"
placeholder="搜索讲解、展厅、主题"
placeholder="搜索讲解、空间、标本"
v-model="searchInputValue"
@input="handleSearchInput"
@confirm="handleSearchConfirm"
@@ -60,7 +60,7 @@
<scroll-view class="panel-scroll" :scroll-y="true" :show-scrollbar="false">
<view v-if="loading" class="state-block">
<text class="state-title">正在加载讲解内容</text>
<text class="state-desc">稍后将展示当前楼层和展厅讲解</text>
<text class="state-desc">稍后将展示当前楼层的场景空间与展品讲解</text>
</view>
<view v-else-if="error" class="state-block error">
@@ -87,7 +87,7 @@
<view class="featured-body">
<text class="featured-title">{{ featuredCard.title }}</text>
<text class="featured-meta">{{ cardMeta(featuredCard) }}</text>
<text class="featured-summary">{{ featuredCard.summary || '正式讲解文案待内容库接入后完善。' }}</text>
<text class="featured-summary">{{ featuredCard.summary || '讲解文案来自 SGS 场景设置数据,正式 CMS 接入后可继续替换。' }}</text>
<view class="featured-actions">
<view
v-if="featuredCard.audioStatus === 'playable'"
@@ -126,8 +126,8 @@
<view v-if="hallSummaries.length" class="hall-section">
<view class="section-heading">
<text class="section-title">展厅讲解</text>
<text class="section-count">{{ hallSummaries.length }} 展厅</text>
<text class="section-title">场景空间</text>
<text class="section-count">{{ hallSummaries.length }} 空间</text>
</view>
<view class="hall-grid">
<view
@@ -198,7 +198,7 @@
<view v-else class="state-block empty">
<text class="state-title">未找到相关讲解</text>
<text class="state-desc">试试展厅名称楼层或主题关键词</text>
<text class="state-desc">试试空间名称标本名称楼层或主题关键词</text>
</view>
</view>
</template>
@@ -326,9 +326,9 @@ const featuredCard = computed(() => cards.value.find((card) => card.audioStatus
const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
const textMap: Record<ExplainCardViewModel['contentType'], string> = {
hall: '展厅讲解',
hall: '空间讲解',
zone: '展区讲解',
exhibit: '讲解',
exhibit: '展品讲解',
theme: '主题讲解'
}
return textMap[type]

View File

@@ -0,0 +1,316 @@
<template>
<view class="sgs-map-renderer">
<view ref="containerRef" class="sgs-map-container"></view>
<view v-if="isLoading" class="sdk-state-overlay">
<view class="sdk-state-card">
<view class="loading-spinner"></view>
<text class="sdk-state-title">正在连接 SGS 三维地图</text>
<text class="sdk-state-desc">{{ statusMessage }}</text>
</view>
</view>
<view v-else-if="loadError" class="sdk-state-overlay error-overlay">
<view class="sdk-state-card error-card">
<text class="sdk-state-title">SGS 地图加载失败</text>
<text class="sdk-state-desc">{{ statusMessage }}</text>
<view class="retry-btn" @tap="retryLoad">
<text class="retry-text">重新连接</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
dataSourceConfig,
getSgsMapTargetOrigin
} from '@/config/dataSource'
import { SgsMapService } from '@/services/sgs/SgsMapService'
import {
toAppFloorId,
toErrorResult,
toFloorLabel,
toFocusedResult,
toFocusNodeName,
toNumericFloorId,
type SgsTargetFocusRequest,
type SgsTargetFocusResult
} from '@/services/sgs/SgsMapEventAdapter'
import type {
SgsMapEvents
} from '@/types/sgs-map-sdk'
interface FloorOption {
id: string
label: string
}
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
type ViewMode = 'overview' | 'floor' | 'multi'
const props = withDefaults(defineProps<{
initialFloorId?: string
initialView?: ViewMode
floors?: FloorOption[]
targetFocus?: SgsTargetFocusRequest | null
}>(), {
initialFloorId: 'L1',
initialView: 'floor',
floors: () => [] as FloorOption[],
targetFocus: null
})
const emit = defineEmits<{
floorChange: [floorId: string]
poiClick: [poi: SgsMapEvents['poiClick']]
targetFocus: [result: SgsTargetFocusResult]
}>()
const containerRef = ref<ContainerRef>(null)
const isLoading = ref(true)
const loadError = ref(false)
const statusMessage = ref('正在加载 SDK 脚本与地图基座...')
let service: SgsMapService | null = null
let currentFocusRequestId: number | string | null = null
const getContainerElement = () => {
const container = containerRef.value
if (!container || typeof HTMLElement === 'undefined') return null
if (container instanceof HTMLElement) return container
const element = '$el' in container ? container.$el : null
return element instanceof HTMLElement ? element : null
}
const disposeService = () => {
service?.destroy()
service = null
}
const registerEvents = () => {
service?.on('floorChanged', (payload) => {
emit('floorChange', toAppFloorId(payload.floorId))
})
service?.on('poiClick', (poi) => {
emit('poiClick', poi)
})
service?.on('loadError', (error) => {
loadError.value = true
isLoading.value = false
statusMessage.value = error.detail || '地图基座 iframe 加载失败,请检查 VITE_SGS_H5_ENGINE_URL。'
})
service?.on('error', (error) => {
loadError.value = true
isLoading.value = false
statusMessage.value = error.message || error.code || 'SGS Map SDK 运行异常'
})
}
const initSdk = async () => {
disposeService()
isLoading.value = true
loadError.value = false
statusMessage.value = '正在加载 SDK 脚本与地图基座...'
await nextTick()
const container = getContainerElement()
if (!container) {
isLoading.value = false
loadError.value = true
statusMessage.value = '未找到 SGS 地图容器。'
return
}
service = new SgsMapService({
container,
scriptUrl: dataSourceConfig.sgsSdkScriptUrl,
sdkUrl: dataSourceConfig.sgsMapEngineUrl,
targetOrigin: getSgsMapTargetOrigin(),
floorId: toNumericFloorId(props.initialFloorId),
timeout: dataSourceConfig.sgsSdkTimeoutMs
})
registerEvents()
try {
await service.init()
isLoading.value = false
loadError.value = false
statusMessage.value = 'SGS 三维地图已连接。'
if (props.initialView === 'overview' || props.initialView === 'multi') {
await service.resetView().catch(() => undefined)
}
if (props.targetFocus) {
await focusTargetPoi(props.targetFocus)
}
} catch (error) {
isLoading.value = false
loadError.value = true
statusMessage.value = error instanceof Error ? error.message : 'SGS Map SDK 初始化失败。'
}
}
const switchFloor = async (floorId: string) => {
try {
await service?.changeFloor(toNumericFloorId(floorId))
} catch (error) {
loadError.value = true
statusMessage.value = error instanceof Error ? error.message : `楼层切换失败:${toFloorLabel(floorId)}`
}
}
const focusTargetPoi = async (request: SgsTargetFocusRequest) => {
if (currentFocusRequestId === request.requestId) return
currentFocusRequestId = request.requestId
try {
await switchFloor(request.floorId)
await service?.focusTo(toFocusNodeName(request))
emit('targetFocus', toFocusedResult(request))
} catch (error) {
emit('targetFocus', toErrorResult(request, error))
}
}
const showOverview = async () => {
await service?.resetView().catch(() => undefined)
}
const showMultiFloor = async () => {
await service?.resetView().catch(() => undefined)
}
const disableAutoSwitchTemporarily = () => {
// SGS SDK does not expose the local zoom auto-switch contract.
}
const retryLoad = () => {
void initSdk()
}
watch(() => props.targetFocus, (request) => {
if (request && !isLoading.value && !loadError.value) {
void focusTargetPoi(request)
}
})
onMounted(() => {
void initSdk()
})
onUnmounted(() => {
disposeService()
})
defineExpose({
switchFloor,
showOverview,
showMultiFloor,
disableAutoSwitchTemporarily
})
</script>
<style scoped lang="scss">
.sgs-map-renderer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #eef2f0;
}
.sgs-map-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.sdk-state-overlay {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
background: rgba(245, 247, 244, 0.82);
backdrop-filter: blur(10px);
}
.sdk-state-card {
width: min(280px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 18px;
box-sizing: border-box;
border-radius: 14px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 12px 30px rgba(24, 32, 21, 0.12);
text-align: center;
}
.error-card {
border-color: rgba(255, 122, 89, 0.28);
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(31, 35, 41, 0.12);
border-top-color: #1f2329;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.sdk-state-title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: #1f2329;
}
.sdk-state-desc {
font-size: 12px;
line-height: 18px;
color: #626a73;
}
.retry-btn {
min-width: 96px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 14px;
border-radius: 17px;
background: #1f2329;
}
.retry-text {
font-size: 13px;
line-height: 18px;
color: #ffffff;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@@ -23,8 +23,8 @@
</view>
<view v-if="showControls && floors.length" class="map-toolbar">
<view class="overview-btn" :class="{ active: activeView === 'overview' }" @tap="showOverview">
<text class="overview-text">全馆</text>
<view class="overview-btn" :class="{ active: activeView === 'multi' }" @tap="showMultiFloor">
<text class="overview-text">多层</text>
</view>
</view>
@@ -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<ContainerRef>(null)
@@ -146,6 +165,17 @@ let resizeObserver: ResizeObserver | null = null
let isDisposed = false
let pendingTargetFocus: TargetPoiFocusRequest | null = null
let targetFocusQueue: Promise<unknown> = 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<typeof setTimeout> | null = null
let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | 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<GLTF>((resolve, reject) => {
const loadModel = (url: string, label: string, loadToken?: number) => new Promise<GLTF>((resolve, reject) => {
if (!loader) {
reject(new Error('GLTF 加载器未初始化'))
return
@@ -309,6 +389,8 @@ const loadModel = (url: string, label: string) => new Promise<GLTF>((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<void>,
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, () => {

View File

@@ -3,9 +3,20 @@
<view class="map-layer" :class="`map-${mapType}`">
<template v-if="mapType === 'indoor'">
<!-- #ifdef H5 -->
<SgsMapRenderer
v-if="useSgsMapRenderer"
ref="indoorRendererRef"
class="indoor-three-map"
:initial-floor-id="activeFloorId"
:initial-view="indoorInitialView"
:floors="props.floors"
:target-focus="targetFocusRequest"
@target-focus="handleTargetFocus"
@floor-change="handleSdkFloorChange"
/>
<ThreeMap
v-if="indoorModelSource"
ref="threeMapRef"
v-else-if="indoorModelSource"
ref="indoorRendererRef"
class="indoor-three-map"
:asset-base-url="indoorAssetBaseUrl"
:model-source="indoorModelSource"
@@ -14,7 +25,11 @@
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:target-focus="targetFocusRequest"
@floor-change="handleThreeFloorChange"
@poi-click="handlePoiClick"
@selection-clear="handleSelectionClear"
@target-focus="handleTargetFocus"
@auto-switch="handleAutoSwitch"
/>
<!-- #endif -->
<!-- #ifndef H5 -->
@@ -89,27 +104,15 @@
<slot name="overlay"></slot>
<view
v-if="mapType === 'indoor' && showIndoorViewToggle"
class="indoor-view-toggle"
:style="indoorViewToggleStyle"
v-if="showIndoorRightControls && showLayerModeToggle"
class="layer-mode-toggle"
:style="layerModeToggleStyle"
@tap="handleLayerModeToggle"
>
<view
class="indoor-view-item"
:class="{ active: indoorView === 'overview' }"
@tap="handleIndoorViewChange('overview')"
>
<text class="indoor-view-label">全馆</text>
</view>
<view
class="indoor-view-item"
:class="{ active: indoorView === 'floor' }"
@tap="handleIndoorViewChange('floor')"
>
<text class="indoor-view-label">楼层</text>
</view>
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
</view>
<view v-if="showFloor" class="floor-switcher" :style="floorSwitcherStyle">
<view v-if="showIndoorRightControls && showFloor" class="floor-switcher" :style="floorSwitcherStyle">
<view
v-for="floor in floorLabels"
:key="floor"
@@ -141,9 +144,14 @@ import { computed, ref } from 'vue'
import TencentMap from '@/components/map/TencentMap.vue'
// #ifdef H5
import ThreeMap from '@/components/map/ThreeMap.vue'
import SgsMapRenderer from '@/components/map/SgsMapRenderer.vue'
import {
isSgsSdkMode
} from '@/config/dataSource'
// #endif
import type {
GuideModelSource
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
interface GuideFloorOption {
@@ -169,7 +177,8 @@ interface TargetPoiFocusResult {
message?: string
}
type IndoorViewMode = 'overview' | 'floor'
type IndoorViewMode = 'overview' | 'floor' | 'multi'
type LayerDisplayMode = 'single' | 'multi'
const props = withDefaults(defineProps<{
searchText?: string
@@ -177,14 +186,15 @@ const props = withDefaults(defineProps<{
activeFloor?: string
indoorView?: IndoorViewMode
indoorInitialView?: IndoorViewMode
layerMode?: LayerDisplayMode
searchTop?: string
floorTop?: string
indoorViewToggleTop?: string
layerModeToggleTop?: string
toolsTop?: string
tools?: string[]
showSearch?: boolean
showFloor?: boolean
showIndoorViewToggle?: boolean
showLayerModeToggle?: boolean
modeTop?: string
modeLayout?: 'full' | 'status'
modeStatus?: string
@@ -203,14 +213,15 @@ const props = withDefaults(defineProps<{
activeFloor: '1F',
indoorView: 'floor',
indoorInitialView: 'floor',
layerMode: 'single',
searchTop: '16px',
floorTop: '164px',
indoorViewToggleTop: '154px',
layerModeToggleTop: '154px',
toolsTop: '406px',
tools: () => [] as string[],
showSearch: true,
showFloor: true,
showIndoorViewToggle: false,
showLayerModeToggle: false,
modeTop: '60px',
modeLayout: 'full',
modeStatus: '',
@@ -231,16 +242,31 @@ const emit = defineEmits<{
floorChange: [floor: string]
toolClick: [tool: string]
indoorViewChange: [view: IndoorViewMode]
layerModeChange: [mode: LayerDisplayMode]
poiClick: [poi: GuideRenderPoi]
selectionClear: []
targetFocus: [result: TargetPoiFocusResult]
autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }]
}>()
const threeMapRef = ref<{
const indoorRendererRef = ref<{
switchFloor?: (floorId: string) => Promise<void> | void
showOverview?: () => Promise<void> | void
showMultiFloor?: () => Promise<void> | void
resetCamera?: () => void
setCameraPreset?: (preset: 'top' | 'oblique') => void
clearSelection?: (shouldEmit?: boolean) => void
disableAutoSwitchTemporarily?: (durationMs: number) => void
} | null>(null)
const floorLabels = computed(() => props.floors.map((floor) => floor.label))
const indoorModelSource = computed(() => props.indoorModelSource)
// #ifdef H5
const useSgsMapRenderer = computed(() => isSgsSdkMode())
// #endif
const showIndoorRightControls = computed(() => (
props.mapType === 'indoor' && props.indoorView !== 'overview'
))
const activeFloorId = computed(() => props.normalizeFloorId(props.activeFloor))
@@ -252,8 +278,8 @@ const floorSwitcherStyle = computed(() => ({
top: props.floorTop
}))
const indoorViewToggleStyle = computed(() => ({
top: props.indoorViewToggleTop
const layerModeToggleStyle = computed(() => ({
top: props.layerModeToggleTop
}))
const modeRowStyle = computed(() => ({
@@ -264,7 +290,15 @@ const toolStackStyle = computed(() => ({
top: props.toolsTop
}))
const shouldShowIndoorPois = computed(() => Boolean(props.targetFocusRequest))
const shouldShowIndoorPois = computed(() => (
props.mapType === 'indoor'
) || Boolean(props.targetFocusRequest))
const nextLayerMode = computed<LayerDisplayMode>(() => (
props.layerMode === 'multi' ? 'single' : 'multi'
))
const layerModeActionLabel = computed(() => (
nextLayerMode.value === 'multi' ? '多层' : '单层'
))
const handleSearchTap = () => {
emit('searchTap')
@@ -276,28 +310,79 @@ const handleModeChange = (mode: '2d' | '3d') => {
const handleFloorChange = (floor: string) => {
const floorId = props.normalizeFloorId(floor)
void threeMapRef.value?.switchFloor?.(floorId)
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
void indoorRendererRef.value?.switchFloor?.(floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
emit('floorChange', floor)
}
const handleIndoorViewChange = (view: IndoorViewMode) => {
if (view === 'overview') {
void threeMapRef.value?.showOverview?.()
const handleLayerModeChange = (mode: LayerDisplayMode) => {
// 手动切换展示层数时临时禁用缩放自动切换 10 秒
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
if (mode === 'multi') {
void indoorRendererRef.value?.showMultiFloor?.()
emit('indoorViewChange', 'multi')
} else {
void threeMapRef.value?.switchFloor?.(activeFloorId.value)
void indoorRendererRef.value?.switchFloor?.(activeFloorId.value)
emit('indoorViewChange', 'floor')
}
emit('indoorViewChange', view)
emit('layerModeChange', mode)
}
const handleLayerModeToggle = () => {
handleLayerModeChange(nextLayerMode.value)
}
const handleToolClick = (tool: string) => {
if (props.mapType === 'indoor') {
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
if (tool === '重置') {
indoorRendererRef.value?.resetCamera?.()
} else if (tool === '俯视') {
indoorRendererRef.value?.setCameraPreset?.('top')
} else if (tool === '斜视') {
indoorRendererRef.value?.setCameraPreset?.('oblique')
} else if (tool === '清除') {
indoorRendererRef.value?.clearSelection?.()
}
}
emit('toolClick', tool)
}
const handleThreeFloorChange = (floorId: string) => {
const floor = props.floors.find((item) => item.id === floorId)
emit('floorChange', floor?.label || floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
}
const handlePoiClick = (poi: GuideRenderPoi) => {
emit('poiClick', poi)
}
const handleSelectionClear = () => {
emit('selectionClear')
}
const handleTargetFocus = (result: TargetPoiFocusResult) => {
emit('targetFocus', result)
}
const handleSdkFloorChange = (floorId: string) => {
const floor = props.floors.find((item) => item.id === floorId)
if (floor) {
emit('floorChange', floor.label)
}
}
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
emit('autoSwitch', event)
}
</script>
<style scoped lang="scss">
@@ -536,40 +621,31 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
z-index: 35;
}
.indoor-view-toggle {
.layer-mode-toggle {
position: absolute;
right: 16px;
width: 58px;
padding: 5px;
width: 46px;
height: 36px;
display: flex;
flex-direction: column;
gap: 5px;
background: rgba(255, 255, 255, 0.94);
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #dde5df;
border-radius: 10px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 36;
}
.indoor-view-item {
min-height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
color: #59645d;
.layer-mode-toggle:active {
background: #f5f7f2;
}
.indoor-view-item.active {
background: var(--museum-accent);
color: #ffffff;
box-shadow: 0 6px 12px rgba(82, 107, 91, 0.18);
}
.indoor-view-label {
.layer-mode-label {
font-size: 12px;
font-weight: 700;
line-height: 1;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.floor-item {
@@ -616,7 +692,7 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
.tool-stack {
position: absolute;
right: 18px;
left: 16px;
display: flex;
flex-direction: column;
gap: 8px;

48
src/config/dataSource.ts Normal file
View File

@@ -0,0 +1,48 @@
export type DataSourceMode = 'static' | 'api' | 'sdk'
const allowedModes = new Set<DataSourceMode>(['static', 'api', 'sdk'])
const defaultSdkScriptUrl = '/static/sgs-map-sdk/index.global.js'
const defaultSgsEngineUrl = '/h5-sdk'
const defaultSdkTimeoutMs = 5000
const normalizeMode = (mode: string | undefined): DataSourceMode => {
if (mode && allowedModes.has(mode as DataSourceMode)) {
return mode as DataSourceMode
}
return 'static'
}
const normalizeUrl = (url: string | undefined, fallback: string) => {
const normalized = url?.trim()
return normalized || fallback
}
const normalizeTimeout = (timeoutValue: string | undefined) => {
const timeout = Number(timeoutValue)
return Number.isFinite(timeout) && timeout > 0 ? timeout : defaultSdkTimeoutMs
}
const inferOrigin = (url: string) => {
if (typeof window === 'undefined') return ''
try {
return new URL(url, window.location.origin).origin
} catch {
return window.location.origin
}
}
export const dataSourceConfig = {
mode: normalizeMode(import.meta.env.VITE_DATA_SOURCE_MODE),
sgsSdkScriptUrl: normalizeUrl(import.meta.env.VITE_SGS_SDK_SCRIPT_URL, defaultSdkScriptUrl),
sgsMapEngineUrl: normalizeUrl(import.meta.env.VITE_SGS_H5_ENGINE_URL, defaultSgsEngineUrl),
sgsSdkTargetOrigin: import.meta.env.VITE_SGS_SDK_ORIGIN?.trim() || '',
sgsSdkTimeoutMs: normalizeTimeout(import.meta.env.VITE_SGS_SDK_TIMEOUT_MS)
}
export const isSgsSdkMode = () => dataSourceConfig.mode === 'sdk'
export const getSgsMapTargetOrigin = () => (
dataSourceConfig.sgsSdkTargetOrigin || inferOrigin(dataSourceConfig.sgsMapEngineUrl)
)

View File

@@ -0,0 +1,202 @@
export interface SgsSceneSpaceMock {
id: number
name: string
spaceCategory: 'EXHIBITION' | 'EXPERIENCE' | 'SERVICE' | 'COMMERCIAL' | 'EDUCATION' | 'CIRCULATION' | 'BACK_OFFICE'
isPublic: boolean
area?: number
exhibitCount?: number
boundaryStatus?: 'DEFINED' | 'UNDEFINED'
floorId: string
floorLabel: string
poiId?: string
}
export interface SgsSceneExhibitMock {
id: number
name: string
code: string
category?: string
era?: string
origin?: string
description: string
descriptionEn?: string
audioUrl?: string
videoUrl?: string
coverImageUrl?: string
modelUrl?: string
spaceId: number
spaceName: string
zoneId?: number
zoneName?: string
poiId?: string
}
export interface SgsSceneExplainDatasetMock {
schemaVersion: string
sourceProject: string
sourceFiles: string[]
floorId: string
floorLabel: string
sourceFloorId: number
spaces: SgsSceneSpaceMock[]
exhibits: SgsSceneExhibitMock[]
}
export const SGS_SCENE_EXPLAIN_DATASET: SgsSceneExplainDatasetMock = {
schemaVersion: 'sgs-scene-explain-mock/v1',
sourceProject: 'smart-navigation-system/sgs-frontend-map',
sourceFiles: [
'public/mocks/v2/space-by-floor-1.json',
'public/mocks/v2/exhibit-list-by-space-1.json',
'src/types/map.ts:SpatialNodeVO, ExhibitItemVO, GuideContentVO'
],
floorId: 'L-2',
floorLabel: 'B2',
sourceFloorId: 1,
spaces: [
{
id: 101,
name: '地球厅',
spaceCategory: 'EXHIBITION',
isPublic: true,
area: 1200,
exhibitCount: 156,
boundaryStatus: 'DEFINED',
floorId: 'L-2',
floorLabel: 'B2',
poiId: 'poi_navpoi_0010'
},
{
id: 1001,
name: '宝石矿物展区',
spaceCategory: 'EXHIBITION',
isPublic: true,
area: 400,
exhibitCount: 80,
boundaryStatus: 'DEFINED',
floorId: 'L-2',
floorLabel: 'B2',
poiId: 'modelpoi_L-2_e2ec8ce8ba'
},
{
id: 1002,
name: '生命演化展区',
spaceCategory: 'EXHIBITION',
isPublic: true,
area: 600,
exhibitCount: 76,
boundaryStatus: 'DEFINED',
floorId: 'L-2',
floorLabel: 'B2'
},
{
id: 102,
name: '4D影院',
spaceCategory: 'EXPERIENCE',
isPublic: true,
area: 220,
boundaryStatus: 'DEFINED',
floorId: 'L-2',
floorLabel: 'B2'
},
{
id: 103,
name: '中央服务台',
spaceCategory: 'SERVICE',
isPublic: true,
area: 60,
boundaryStatus: 'DEFINED',
floorId: 'L-2',
floorLabel: 'B2'
},
{
id: 104,
name: '文创商店',
spaceCategory: 'COMMERCIAL',
isPublic: true,
area: 150,
boundaryStatus: 'DEFINED',
floorId: 'L-2',
floorLabel: 'B2'
},
{
id: 105,
name: '科普工坊',
spaceCategory: 'EDUCATION',
isPublic: true,
area: 180,
boundaryStatus: 'DEFINED',
floorId: 'L-2',
floorLabel: 'B2'
},
{
id: 106,
name: '中央大厅',
spaceCategory: 'CIRCULATION',
isPublic: true,
area: 800,
boundaryStatus: 'UNDEFINED',
floorId: 'L-2',
floorLabel: 'B2'
}
],
exhibits: [
{
id: 3001,
name: '橄榄岩',
code: 'EX-F1-001',
category: '岩石标本',
era: '前寒武纪',
origin: '中国内蒙古',
description: '橄榄岩是一种超基性深成岩,主要由橄榄石和辉石组成。它是地球上地幔的主要岩石类型,也是研究地球深部物质的重要窗口。',
descriptionEn: "Peridotite is an ultramafic igneous rock consisting primarily of olivine and pyroxene. It is the dominant rock of the Earth's upper mantle.",
audioUrl: '/audio/exhibits/peridotite.mp3',
videoUrl: '/video/exhibits/peridotite.mp4',
coverImageUrl: '/images/exhibits/peridotite.jpg',
modelUrl: '/models/exhibits/peridotite.glb',
spaceId: 101,
spaceName: '地球厅',
zoneId: 1001,
zoneName: '宝石矿物单元',
poiId: 'modelpoi_L-2_e2ec8ce8ba'
},
{
id: 3002,
name: '辉石',
code: 'EX-F1-002',
category: '矿物标本',
era: '古生代',
origin: '中国辽宁',
description: '辉石是主要的造岩矿物之一,属链状硅酸盐矿物。它的晶体通常呈短柱状,具有两组近于垂直的解理,是火成岩和变质岩的常见组分。',
descriptionEn: 'Pyroxene is a major group of rock-forming silicate minerals. They are common components of both igneous and metamorphic rocks.',
audioUrl: '',
videoUrl: '',
coverImageUrl: '/images/exhibits/pyroxene.jpg',
modelUrl: '/models/exhibits/pyroxene.glb',
spaceId: 101,
spaceName: '地球厅',
zoneId: 1001,
zoneName: '宝石矿物单元',
poiId: 'modelpoi_L-2_e2ec8ce8ba'
},
{
id: 3003,
name: '石英',
code: 'EX-F1-003',
category: '矿物标本',
era: '中生代',
origin: '中国江苏',
description: '石英是地球表面分布最广的矿物之一,化学成分为二氧化硅。它硬度高,化学性质稳定,晶体形态多样,是许多工业和工艺品的重要原料。',
descriptionEn: 'Quartz is a mineral composed of silicon and oxygen atoms in a continuous framework of SiO4 siliconoxygen tetrahedra.',
audioUrl: '',
videoUrl: '',
coverImageUrl: '/images/exhibits/quartz.jpg',
modelUrl: '',
spaceId: 101,
spaceName: '地球厅',
zoneId: 1001,
zoneName: '宝石矿物单元',
poiId: 'modelpoi_L-2_e2ec8ce8ba'
}
]
}

View File

@@ -3,172 +3,109 @@ import type {
MuseumHall
} from '@/domain/museum'
import {
formatNavFloorLabel
} from '@/data/adapters/navAssetsAdapter'
import {
defaultStaticNavAssetsProvider,
type StaticNavAssetsProvider,
type StaticNavPoiPayload
} from '@/data/providers/staticNavAssetsProvider'
SGS_SCENE_EXPLAIN_DATASET,
type SgsSceneExhibitMock,
type SgsSceneSpaceMock
} from '@/data/mock/sgsSceneExplainData'
export interface MuseumContentProvider {
listExhibits(): Promise<MuseumExhibit[]>
listHalls(): Promise<MuseumHall[]>
}
const contentImage = '/static/exhibit-placeholder.jpg'
const hallImage = '/static/hall-placeholder.jpg'
const normalizeContentId = (prefix: string, value: string | number) => `${prefix}-sgs-${value}`
const cleanPoiName = (poi: StaticNavPoiPayload) => poi.name
.replace(new RegExp(`^${poi.floorId}\\s*`), '')
.replace(/^L\d+(?:\.\d+)?\s+/, '')
.replace(/^L-\d+(?:\.\d+)?\s+/, '')
.replace(/^L\d+(?:\.\d+)?\s+/, '')
.trim()
const normalizeContentId = (prefix: string, value: string) => `${prefix}-${value}`
.toLowerCase()
.replace(/[^a-z0-9一-龥]+/g, '-')
.replace(/^-+|-+$/g, '')
const isTouringPoi = (poi: StaticNavPoiPayload) => (
poi.primaryCategory === 'touring_poi'
|| poi.categories?.some((category) => category.topCategory === 'touring_poi') === true
)
const isHallPoi = (poi: StaticNavPoiPayload) => {
const name = cleanPoiName(poi)
return /展厅|影院|剧场|报告厅|活动室/.test(name)
&& !/展柜|装饰/.test(name)
const categoryLabelMap: Record<SgsSceneSpaceMock['spaceCategory'], string> = {
EXHIBITION: '展陈空间',
EXPERIENCE: '体验空间',
SERVICE: '服务空间',
COMMERCIAL: '商业空间',
EDUCATION: '教育空间',
CIRCULATION: '公共交通空间',
BACK_OFFICE: '后勤空间'
}
const isExhibitCandidate = (poi: StaticNavPoiPayload) => /展柜|装饰/.test(cleanPoiName(poi))
const hallKeyFromName = (name: string) => {
const match = name.match(/(展厅\s*\d+\s*[^\s展柜装饰]+|临展厅\d+|[^\s]+厅|[^\s]+影院|自然剧场|学术报告厅|博物馆之友活动室)/)
return (match?.[1] || name)
.replace(/\s+/g, '')
.replace(/L\d+\s*/g, '')
const boundaryLabelMap: Record<NonNullable<SgsSceneSpaceMock['boundaryStatus']>, string> = {
DEFINED: '已定义空间边界',
UNDEFINED: '空间边界待完善'
}
const exhibitNameFromPoi = (poi: StaticNavPoiPayload) => cleanPoiName(poi)
.replace(/\s+/g, ' ')
.replace(/^(展厅\s*\d+\s*)/, '')
.trim()
const publicSceneSpaces = SGS_SCENE_EXPLAIN_DATASET.spaces.filter((space) => space.isPublic)
const publicSpaceById = new Map(publicSceneSpaces.map((space) => [space.id, space]))
const hallDescription = (hallName: string, floorLabel: string) => (
`${hallName}位于${floorLabel},来源于当前导览资源包中的游览 POI。当前内容用于讲解业务结构占位正式展陈文案、音频和图文媒体待 CMS/API 接入后替换。`
)
const formatArea = (area?: number) => (typeof area === 'number' ? `${area}` : undefined)
const exhibitDescription = (exhibitName: string, hallName: string, floorLabel: string) => (
`${exhibitName}${hallName}的讲解点,位置关联到${floorLabel}的导览 POI。当前仅展示内容结构和位置关联不代表正式展陈文案或可播放音频。`
)
const hallDescription = (space: SgsSceneSpaceMock) => {
const category = categoryLabelMap[space.spaceCategory]
const area = formatArea(space.area)
const boundary = space.boundaryStatus ? boundaryLabelMap[space.boundaryStatus] : undefined
const facts = [space.floorLabel, category, area, boundary].filter(Boolean).join(' · ')
return `${space.name}来自 SGS 前端地图项目“场景设置”的空间管理数据。${facts ? `当前空间信息:${facts}` : ''}讲解业务据此展示展厅/展区内容,并通过稳定空间、展品与 POI ID 关联到位置预览。`
}
const exhibitDescription = (exhibit: SgsSceneExhibitMock, hall: SgsSceneSpaceMock | undefined) => {
const facts = [
exhibit.category,
exhibit.era,
exhibit.origin,
exhibit.zoneName
].filter(Boolean).join(' · ')
return [
exhibit.description,
facts ? `展陈信息:${facts}` : '',
hall ? `所属空间:${hall.name}${hall.floorLabel})。` : ''
].filter(Boolean).join('\n\n')
}
const tagsForExhibit = (exhibit: SgsSceneExhibitMock, hall: SgsSceneSpaceMock | undefined) => [
'SGS场景设置',
exhibit.category,
exhibit.zoneName,
exhibit.audioUrl ? '源数据含音频地址' : '图文讲解',
hall ? categoryLabelMap[hall.spaceCategory] : undefined
].filter(Boolean) as string[]
const toMuseumHall = (space: SgsSceneSpaceMock): MuseumHall => ({
id: normalizeContentId('hall', space.id),
name: space.name,
floorId: space.floorId,
floorLabel: space.floorLabel,
description: hallDescription(space),
image: '',
exhibitCount: SGS_SCENE_EXPLAIN_DATASET.exhibits.filter((exhibit) => exhibit.spaceId === space.id).length || space.exhibitCount || 0,
area: formatArea(space.area),
poiId: space.poiId
})
const toMuseumExhibit = (exhibit: SgsSceneExhibitMock): MuseumExhibit => {
const hall = publicSpaceById.get(exhibit.spaceId)
return {
id: normalizeContentId('exhibit', exhibit.id),
name: exhibit.name,
hallId: hall ? normalizeContentId('hall', hall.id) : undefined,
hallName: hall?.name || exhibit.spaceName,
floorId: hall?.floorId || SGS_SCENE_EXPLAIN_DATASET.floorId,
floorLabel: hall?.floorLabel || SGS_SCENE_EXPLAIN_DATASET.floorLabel,
image: '',
description: exhibitDescription(exhibit, hall),
poiId: exhibit.poiId || hall?.poiId,
year: exhibit.era,
material: exhibit.origin,
size: exhibit.code,
tags: tagsForExhibit(exhibit, hall)
}
}
export class StaticMuseumContentProvider implements MuseumContentProvider {
private hallCache: MuseumHall[] | null = null
private exhibitCache: MuseumExhibit[] | null = null
private loading: Promise<void> | null = null
constructor(
private readonly navAssets: StaticNavAssetsProvider = defaultStaticNavAssetsProvider
) {}
async listExhibits() {
await this.ensureLoaded()
return this.exhibitCache || []
return SGS_SCENE_EXPLAIN_DATASET.exhibits.map(toMuseumExhibit)
}
async listHalls() {
await this.ensureLoaded()
return this.hallCache || []
}
private async ensureLoaded() {
if (this.hallCache && this.exhibitCache) return
if (this.loading) return this.loading
this.loading = this.navAssets.loadPoiIndex()
.then((pois) => {
const touringPois = pois.filter(isTouringPoi)
const hallPois = touringPois.filter(isHallPoi)
const hallByKey = new Map<string, MuseumHall>()
hallPois.forEach((poi) => {
const hallName = cleanPoiName(poi)
const key = `${poi.floorId}:${hallKeyFromName(hallName)}`
const floorLabel = formatNavFloorLabel(poi.floorId)
hallByKey.set(key, {
id: normalizeContentId('hall', poi.id),
name: hallName,
floorId: poi.floorId,
floorLabel,
description: hallDescription(hallName, floorLabel),
image: hallImage,
exhibitCount: 0,
area: '以导览资源包 POI 为准',
poiId: poi.id
})
})
const fallbackHallFor = (poi: StaticNavPoiPayload) => {
const poiName = cleanPoiName(poi)
const key = `${poi.floorId}:${hallKeyFromName(poiName)}`
const floorLabel = formatNavFloorLabel(poi.floorId)
const hallName = hallKeyFromName(poiName)
const existing = hallByKey.get(key)
if (existing) return existing
const hall: MuseumHall = {
id: normalizeContentId('hall', `${poi.floorId}-${hallName}`),
name: hallName,
floorId: poi.floorId,
floorLabel,
description: hallDescription(hallName, floorLabel),
image: hallImage,
exhibitCount: 0,
area: '以导览资源包 POI 为准'
}
hallByKey.set(key, hall)
return hall
}
const exhibits = touringPois
.filter(isExhibitCandidate)
.map((poi): MuseumExhibit => {
const hall = fallbackHallFor(poi)
const exhibitName = exhibitNameFromPoi(poi)
return {
id: normalizeContentId('exhibit', poi.id),
name: exhibitName,
hallId: hall.id,
hallName: hall.name,
floorId: poi.floorId,
floorLabel: formatNavFloorLabel(poi.floorId),
image: contentImage,
description: exhibitDescription(exhibitName, hall.name, formatNavFloorLabel(poi.floorId)),
poiId: poi.id,
tags: ['资源包POI', poi.primaryCategoryZh, poi.iconType || '展项'].filter(Boolean)
}
})
const exhibitCountByHallId = exhibits.reduce((result, exhibit) => {
if (exhibit.hallId) {
result.set(exhibit.hallId, (result.get(exhibit.hallId) || 0) + 1)
}
return result
}, new Map<string, number>())
this.hallCache = Array.from(hallByKey.values()).map((hall) => ({
...hall,
exhibitCount: exhibitCountByHallId.get(hall.id) || hall.exhibitCount || 0
}))
this.exhibitCache = exhibits
})
.finally(() => {
this.loading = null
})
return this.loading
return publicSceneSpaces.map(toMuseumHall)
}
}

View File

@@ -24,3 +24,17 @@ export interface GuideModelSource {
loadPackage(): Promise<GuideModelRenderPackage>
loadFloorPois(floorId: string): Promise<GuideRenderPoi[]>
}
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
}

13
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DATA_SOURCE_MODE?: 'static' | 'api' | 'sdk'
readonly VITE_SGS_SDK_SCRIPT_URL?: string
readonly VITE_SGS_H5_ENGINE_URL?: string
readonly VITE_SGS_SDK_ORIGIN?: string
readonly VITE_SGS_SDK_TIMEOUT_MS?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -59,7 +59,7 @@
</view>
<view v-if="exhibit.audio.status !== 'playable'" class="audio-note">
<text class="audio-note-title">音频待开放</text>
<text class="audio-note-title">音频待同步</text>
<text class="audio-note-desc">
{{ exhibit.audio.unavailableReason || '当前仅提供图文讲解,正式音频接入后将显示播放入口。' }}
</text>
@@ -154,6 +154,9 @@ import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
toExplainDetailPageViewModel,
type ExplainContentType,
@@ -171,11 +174,11 @@ const defaultDetail: ExplainDetailPageViewModel = {
subtitle: '位置待补充',
contentType: 'exhibit',
coverImages: ['/static/exhibit-placeholder.jpg'],
summary: '该讲解内容待正式内容补充。',
body: '该讲解内容待正式内容补充。',
summary: '该讲解内容待 SGS 场景设置或 CMS 内容补充。',
body: '该讲解内容待 SGS 场景设置或 CMS 内容补充。',
audio: {
status: 'unavailable',
unavailableReason: '正式讲解音频尚未接入媒体仓储'
unavailableReason: 'SGS 场景设置音频资源尚未同步到当前 H5 项目'
},
chapters: [],
relatedItems: []
@@ -194,14 +197,14 @@ const audioStatusText = computed(() => {
if (exhibit.value.audio.status === 'none') {
return '图文讲解'
}
return '音频待开放'
return exhibit.value.audio.unavailableReason?.includes('包含音频地址') ? '音频待同步' : '图文讲解'
})
const contentTypeText = computed(() => {
const map: Record<ExplainContentType, string> = {
hall: '展厅讲解',
hall: '空间讲解',
zone: '展区讲解',
exhibit: '讲解',
exhibit: '展品讲解',
theme: '主题讲解'
}
return map[exhibit.value.contentType]
@@ -243,7 +246,7 @@ const handlePlayAudio = async () => {
isPlaying.value = !isPlaying.value
}
const handleNavigate = () => {
const handleNavigate = async () => {
if (!exhibit.value.location?.poiId) {
uni.showToast({
title: '该讲解暂无三维位置数据',
@@ -252,6 +255,15 @@ const handleNavigate = () => {
return
}
const matchedPoi = await guideUseCase.getPoiById(exhibit.value.location.poiId)
if (!matchedPoi) {
uni.showToast({
title: '该讲解位置尚未映射到当前三维导览资源',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.value.location.poiId)}&target=${encodeURIComponent(exhibit.value.title)}&state=preview`
})

View File

@@ -22,7 +22,7 @@
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ hall.exhibitCount }}</text>
<text class="stat-label">讲解</text>
<text class="stat-label">讲解内容</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ hall.floorLabel }}</text>
@@ -32,7 +32,7 @@
<!-- 讲解列表 -->
<view class="exhibits-section">
<text class="section-title">展厅讲解内容</text>
<text class="section-title">空间讲解内容</text>
<view class="exhibits-grid">
<ExhibitCard
v-for="exhibit in exhibits"
@@ -63,6 +63,9 @@ import ExhibitCard from '@/components/content/ExhibitCard.vue'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
toExplainExhibitViewModel,
toHallDetailViewModel,
@@ -81,7 +84,7 @@ const hall = ref<HallDetailViewModel>({
id: '',
name: '展厅内容',
floorLabel: '楼层待补充',
description: '该展厅介绍待正式内容补充。',
description: '该空间介绍待 SGS 场景设置或 CMS 内容补充。',
image: '/static/hall-placeholder.jpg',
exhibitCount: 0,
area: '待补充'
@@ -111,7 +114,7 @@ const handleExhibitClick = (exhibit: any) => {
})
}
const handleNavigate = () => {
const handleNavigate = async () => {
if (!hall.value.poiId) {
uni.showToast({
title: '该展厅暂无三维位置数据',
@@ -120,6 +123,15 @@ const handleNavigate = () => {
return
}
const matchedPoi = await guideUseCase.getPoiById(hall.value.poiId)
if (!matchedPoi) {
uni.showToast({
title: '该空间位置尚未映射到当前三维导览资源',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(hall.value.poiId)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
})

View File

@@ -21,16 +21,21 @@
:normalize-floor-id="normalizeGuideFloorId"
indoor-initial-view="overview"
:indoor-view="indoorView"
:layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor"
indoor-view-toggle-top="154px"
layer-mode-toggle-top="154px"
floor-top="244px"
:show-indoor-view-toggle="is3DMode"
:show-floor="is3DMode && indoorView === 'floor'"
:tools="[]"
:show-layer-mode-toggle="is3DMode"
:show-floor="is3DMode"
:tools="guide3DTools"
@search-tap="handleGuideSearchTap"
@mode-change="handleModeChange"
@floor-change="handleFloorChange"
@indoor-view-change="handleIndoorViewChange"
@poi-click="handleGuidePoiClick"
@selection-clear="handleGuideSelectionClear"
@tool-click="handleGuideToolClick"
@auto-switch="handleAutoSwitch"
>
<template #overlay>
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
@@ -135,15 +140,22 @@ import {
import {
toExplainCardViewModel
} from '@/view-models/explainViewModels'
import type {
GuideRenderPoi
} from '@/domain/guideModel'
import type {
MuseumFloor
} from '@/domain/museum'
type GuideIndoorView = 'overview' | 'floor' | 'multi'
type LayerDisplayMode = 'single' | 'multi'
// 3D 模式状态
const is3DMode = ref(false)
const guideOutdoorState = ref<'home' | 'entrance'>('home')
const indoorView = ref<'overview' | 'floor'>('overview')
const indoorView = ref<GuideIndoorView>('overview')
const activeGuideFloor = ref('1F')
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
// 状态
const searchKeyword = ref('')
@@ -157,6 +169,16 @@ const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const indoorLayerMode = computed<LayerDisplayMode>(() => (
indoorView.value === 'multi' ? 'multi' : 'single'
))
const guide3DTools = computed(() => {
if (!is3DMode.value) return []
return selectedGuidePoi.value
? ['重置', '俯视', '斜视', '清除']
: ['重置', '俯视', '斜视']
})
const shortcutItems = [
{ id: 'toilet', name: '卫生间', abbr: '卫' },
@@ -351,11 +373,40 @@ const handleCollectMarker = (marker: MarkerDetail) => {
const handleFloorChange = (floor: string) => {
activeGuideFloor.value = floor
indoorView.value = 'floor'
selectedGuidePoi.value = null
console.log('切换楼层:', floor)
}
const handleIndoorViewChange = (view: 'overview' | 'floor') => {
const handleIndoorViewChange = (view: GuideIndoorView) => {
indoorView.value = view
if (view !== 'floor') {
selectedGuidePoi.value = null
}
}
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
console.log('自动切换视图:', event)
indoorView.value = event.to
if (event.to !== 'floor') {
selectedGuidePoi.value = null
}
}
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
selectedGuidePoi.value = poi
indoorView.value = 'floor'
const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId)
activeGuideFloor.value = matchedFloor?.label || poi.floorId
}
const handleGuideSelectionClear = () => {
selectedGuidePoi.value = null
}
const handleGuideToolClick = (tool: string) => {
if (tool === '清除') {
selectedGuidePoi.value = null
}
}
// 进入 3D 室内模式
@@ -364,6 +415,7 @@ const handleEnter3DMode = () => {
indoorView.value = 'overview'
is3DMode.value = true
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
}
const handleModeChange = (mode: '2d' | '3d') => {
@@ -372,6 +424,7 @@ const handleModeChange = (mode: '2d' | '3d') => {
if (mode === '2d') {
indoorView.value = 'overview'
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
} else {
indoorView.value = 'overview'
}
@@ -379,7 +432,9 @@ const handleModeChange = (mode: '2d' | '3d') => {
const guideStatusLabel = computed(() => {
if (is3DMode.value) {
return indoorView.value === 'overview' ? '室内全馆' : '室内楼层'
if (indoorView.value === 'overview') return '建筑外观'
if (indoorView.value === 'multi') return '室内多层'
return '室内单层'
}
if (guideOutdoorState.value === 'entrance') {
@@ -391,16 +446,26 @@ const guideStatusLabel = computed(() => {
const guideTaskTitle = computed(() => {
if (is3DMode.value) {
return indoorView.value === 'overview' ? '查看完整建筑' : '查看楼层位置'
if (selectedGuidePoi.value) return selectedGuidePoi.value.name
if (indoorView.value === 'overview') return '查看建筑外观'
if (indoorView.value === 'multi') return '查看多层关系'
return '查看单层位置'
}
return '从室外进入馆内'
})
const guideTaskSubtitle = computed(() => {
if (is3DMode.value) {
return indoorView.value === 'overview'
? '切换到楼层后,可查看 POI 与三维位置预览'
: '选择目标设施后,可查看楼层与三维位置预览'
if (selectedGuidePoi.value) {
return `${activeGuideFloor.value} · ${selectedGuidePoi.value.primaryCategoryZh || '位置预览'} · 已高亮`
}
if (indoorView.value === 'overview') {
return '放大模型可自动进入当前楼层'
}
if (indoorView.value === 'multi') {
return '用于比较楼层关系,选择具体楼层可回到单层'
}
return '缩小模型可自动回到建筑外观,也可切换多层展示'
}
return ''
@@ -408,6 +473,7 @@ const guideTaskSubtitle = computed(() => {
const guidePrimaryAction = computed(() => {
if (is3DMode.value) {
if (selectedGuidePoi.value) return '查看相关内容'
return '选择目标地点'
}
@@ -416,6 +482,13 @@ const guidePrimaryAction = computed(() => {
const handleGuidePrimaryAction = () => {
if (is3DMode.value) {
if (selectedGuidePoi.value) {
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(selectedGuidePoi.value.name)}`
})
return
}
uni.navigateTo({
url: '/pages/search/index'
})
@@ -507,7 +580,7 @@ const handleExplainFeaturedClick = async (exhibit: Exhibit) => {
await handleAudioClick(exhibit)
}
const handleExplainLocationClick = (exhibit: Exhibit) => {
const handleExplainLocationClick = async (exhibit: Exhibit) => {
if (!exhibit.poiId) {
uni.showToast({
title: '该讲解暂无三维位置数据',
@@ -516,6 +589,15 @@ const handleExplainLocationClick = (exhibit: Exhibit) => {
return
}
const matchedPoi = await guideUseCase.getPoiById(exhibit.poiId)
if (!matchedPoi) {
uni.showToast({
title: '该讲解位置尚未映射到当前三维导览资源',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.poiId)}&target=${encodeURIComponent(exhibit.title)}&state=preview`
})

View File

@@ -89,7 +89,7 @@ export class DefaultExplainRepository implements ExplainRepository {
.map<SearchIndexItem>((exhibit) => ({
id: exhibit.id,
name: exhibit.name,
desc: `${exhibit.hallName || '讲解'} · ${exhibit.floorLabel || ''}`.trim(),
desc: `${exhibit.hallName || '展品讲解'} · ${exhibit.floorLabel || ''}`.trim(),
type: 'exhibit',
exhibitId: exhibit.id,
hallId: exhibit.hallId,
@@ -106,7 +106,7 @@ export class DefaultExplainRepository implements ExplainRepository {
.map<SearchIndexItem>((hall) => ({
id: hall.id,
name: hall.name,
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解`.trim(),
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解内容`.trim(),
type: 'hall',
hallId: hall.id,
poiId: hall.poiId,

View File

@@ -1,26 +1,45 @@
import type {
MediaAsset
} from '@/domain/museum'
import {
SGS_SCENE_EXPLAIN_DATASET
} from '@/data/mock/sgsSceneExplainData'
export interface MediaRepository {
getMediaById(id: string): Promise<MediaAsset | null>
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
}
const unavailableAudio = (id: string): MediaAsset => ({
const normalizeExhibitIdFromTrack = (trackId: string) => trackId
.replace(/^track-/, '')
.replace(/^exhibit-sgs-/, '')
const unavailableAudio = (id: string, reason = 'SGS 场景设置音频资源尚未同步到当前 H5 项目'): MediaAsset => ({
id,
type: 'audio',
available: false,
unavailableReason: '正式讲解音频尚未接入媒体仓储'
unavailableReason: reason
})
const findSourceAudioById = (id: string) => {
const normalizedId = id.replace(/^media-/, '')
const exhibitSourceId = normalizeExhibitIdFromTrack(normalizedId)
return SGS_SCENE_EXPLAIN_DATASET.exhibits.find((exhibit) => String(exhibit.id) === exhibitSourceId)
}
export class DefaultMediaRepository implements MediaRepository {
async getMediaById(id: string) {
return unavailableAudio(id)
const source = findSourceAudioById(id)
if (source?.audioUrl) {
return unavailableAudio(id, `SGS 场景设置源数据包含音频地址 ${source.audioUrl},但该静态音频尚未同步到当前 H5 项目。`)
}
return unavailableAudio(id, '该讲解在 SGS 场景设置源数据中暂无音频地址')
}
async getMediaForExplainTrack(trackId: string) {
return unavailableAudio(`media-${trackId}`)
return this.getMediaById(`media-${trackId}`)
}
}

View File

@@ -81,7 +81,7 @@ export class DefaultMuseumContentRepository implements MuseumContentRepository {
.map<SearchIndexItem>((exhibit) => ({
id: exhibit.id,
name: exhibit.name,
desc: `${exhibit.hallName || '讲解'} · ${exhibit.floorLabel || ''}`.trim(),
desc: `${exhibit.hallName || '展品讲解'} · ${exhibit.floorLabel || ''}`.trim(),
type: 'exhibit',
exhibitId: exhibit.id,
poiId: exhibit.poiId,
@@ -97,7 +97,7 @@ export class DefaultMuseumContentRepository implements MuseumContentRepository {
.map<SearchIndexItem>((hall) => ({
id: hall.id,
name: hall.name,
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解`.trim(),
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解内容`.trim(),
type: 'hall',
hallId: hall.id,
poiId: hall.poiId,

View File

@@ -0,0 +1,55 @@
import type {
SgsMapEvents
} from '@/types/sgs-map-sdk'
export interface SgsTargetFocusRequest {
requestId: number | string
poiId: string
name?: string
floorId: string
floorLabel?: string
primaryCategoryZh?: string
positionGltf?: [number, number, number]
}
export interface SgsTargetFocusResult {
requestId: number | string
poiId: string
floorId: string
status: 'focused' | 'missing' | 'error'
message?: string
}
export const toNumericFloorId = (floorId: string | number | undefined) => {
if (typeof floorId === 'number') return floorId
if (!floorId) return 1
const normalized = floorId.toString().trim().toUpperCase()
const numeric = Number(normalized.replace(/^L/, '').replace(/F$/, ''))
return Number.isFinite(numeric) && numeric > 0 ? numeric : 1
}
export const toAppFloorId = (floorId: string | number | undefined) => `L${toNumericFloorId(floorId)}`
export const toFloorLabel = (floorId: string | number | undefined) => `${toNumericFloorId(floorId)}F`
export const toFocusNodeName = (request: SgsTargetFocusRequest) => (
request.name || request.poiId
)
export const toFocusedResult = (request: SgsTargetFocusRequest): SgsTargetFocusResult => ({
requestId: request.requestId,
poiId: request.poiId,
floorId: request.floorId,
status: 'focused'
})
export const toErrorResult = (request: SgsTargetFocusRequest, error: unknown): SgsTargetFocusResult => ({
requestId: request.requestId,
poiId: request.poiId,
floorId: request.floorId,
status: 'error',
message: error instanceof Error ? error.message : 'SGS Map SDK 聚焦失败'
})
export const toPoiId = (poi: SgsMapEvents['poiClick']) => String(poi.id ?? poi.poiId ?? '')

View File

@@ -0,0 +1,60 @@
import type {
SgsMapSDKConstructor
} from '@/types/sgs-map-sdk'
let loadingScriptUrl = ''
let loadRequest: Promise<SgsMapSDKConstructor> | null = null
const findExistingScript = (scriptUrl: string) => (
Array.from(document.scripts).find((script) => script.src.endsWith(scriptUrl) || script.getAttribute('src') === scriptUrl)
)
export const loadSgsMapScript = (scriptUrl: string): Promise<SgsMapSDKConstructor> => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return Promise.reject(new Error('SGS Map SDK 只能在 H5 浏览器环境中加载'))
}
if (window.SGSMapSDK) {
return Promise.resolve(window.SGSMapSDK)
}
if (loadRequest && loadingScriptUrl === scriptUrl) {
return loadRequest
}
loadingScriptUrl = scriptUrl
const request = new Promise<SgsMapSDKConstructor>((resolve, reject) => {
const existingScript = findExistingScript(scriptUrl)
const script = existingScript || document.createElement('script')
const resolveLoadedSdk = () => {
if (!window.SGSMapSDK) {
reject(new Error('SGS Map SDK 脚本已加载,但未发现 window.SGSMapSDK'))
return
}
resolve(window.SGSMapSDK)
}
script.addEventListener('load', resolveLoadedSdk, { once: true })
script.addEventListener('error', () => {
reject(new Error(`SGS Map SDK 脚本加载失败: ${scriptUrl}`))
}, { once: true })
if (!existingScript) {
script.src = scriptUrl
script.async = true
document.head.appendChild(script)
return
}
if (window.SGSMapSDK) {
resolveLoadedSdk()
}
}).finally(() => {
loadRequest = null
})
loadRequest = request
return request
}

View File

@@ -0,0 +1,164 @@
import type {
SgsMapEvents,
SgsMapSDKInstance,
SgsMarkerConfig,
SgsMapNode,
SgsRouteResult
} from '@/types/sgs-map-sdk'
import {
loadSgsMapScript
} from './SgsMapScriptLoader'
interface SgsMapServiceOptions {
container: HTMLElement
scriptUrl: string
sdkUrl: string
targetOrigin: string
floorId: number
timeout: number
}
type SgsMapListener<K extends keyof SgsMapEvents> = (payload: SgsMapEvents[K]) => void
export class SgsMapService {
private sdk: SgsMapSDKInstance | null = null
private readyPromise: Promise<SgsMapEvents['ready']> | null = null
private resolveReady: ((payload: SgsMapEvents['ready']) => void) | null = null
private rejectReady: ((error: Error) => void) | null = null
private listeners: Array<() => void> = []
private externalListeners: Array<{
event: keyof SgsMapEvents
listener: (payload: SgsMapEvents[keyof SgsMapEvents]) => void
}> = []
private disposed = false
constructor(private readonly options: SgsMapServiceOptions) {}
async init() {
if (this.readyPromise) return this.readyPromise
this.readyPromise = new Promise<SgsMapEvents['ready']>((resolve, reject) => {
this.resolveReady = resolve
this.rejectReady = reject
})
loadSgsMapScript(this.options.scriptUrl)
.then((SDKConstructor) => {
if (this.disposed) {
this.rejectReady?.(new Error('SGS Map SDK 初始化已取消'))
return
}
this.sdk = new SDKConstructor({
container: this.options.container,
sdkUrl: this.options.sdkUrl,
targetOrigin: this.options.targetOrigin,
floorId: this.options.floorId,
timeout: this.options.timeout
})
this.externalListeners.forEach(({ event, listener }) => {
this.registerSdkListener(event, listener)
})
this.registerSdkListener('ready', (payload) => {
this.resolveReady?.(payload)
})
this.registerSdkListener('loadError', (payload) => {
this.rejectReady?.(new Error(payload.detail || 'SGS Map SDK 地图基座加载失败'))
})
this.registerSdkListener('error', (payload) => {
if (!this.sdk?.getIsReady()) {
this.rejectReady?.(new Error(payload.message || payload.code || 'SGS Map SDK 初始化失败'))
}
})
})
.catch((error) => {
this.rejectReady?.(error instanceof Error ? error : new Error('SGS Map SDK 初始化失败'))
})
return this.readyPromise
}
on<K extends keyof SgsMapEvents>(event: K, listener: SgsMapListener<K>) {
this.externalListeners.push({
event,
listener: listener as (payload: SgsMapEvents[keyof SgsMapEvents]) => void
})
if (this.sdk) {
this.registerSdkListener(event, listener)
}
}
private registerSdkListener<K extends keyof SgsMapEvents>(event: K, listener: SgsMapListener<K>) {
if (!this.sdk) return
this.sdk.on(event, listener)
this.listeners.push(() => {
this.sdk?.off(event, listener)
})
}
private async getReadySdk() {
if (!this.sdk) {
await this.init()
}
await this.readyPromise
if (!this.sdk || this.disposed) {
throw new Error('SGS Map SDK 已销毁')
}
return this.sdk
}
async changeFloor(floorId: number) {
const sdk = await this.getReadySdk()
return sdk.changeFloor(floorId, this.options.timeout)
}
async focusTo(node: SgsMapNode | string) {
const sdk = await this.getReadySdk()
sdk.focusTo(node)
}
async addMarker(config: SgsMarkerConfig) {
const sdk = await this.getReadySdk()
sdk.addMarker(config)
}
async clearMarkers() {
const sdk = await this.getReadySdk()
sdk.clearMarkers()
}
async resetView() {
const sdk = await this.getReadySdk()
sdk.resetView()
}
async setViewMode(mode: '2d' | '3d') {
const sdk = await this.getReadySdk()
sdk.setViewMode(mode)
}
async planRoute(from: SgsMapNode | string, to: SgsMapNode | string, options?: unknown): Promise<SgsRouteResult> {
const sdk = await this.getReadySdk()
return sdk.planRoute(from, to, options, this.options.timeout)
}
destroy() {
this.disposed = true
this.listeners.splice(0).forEach((cleanup) => cleanup())
this.sdk?.destroy()
this.sdk = null
this.rejectReady?.(new Error('SGS Map SDK 已销毁'))
this.resolveReady = null
this.rejectReady = null
this.readyPromise = null
}
}

88
src/types/sgs-map-sdk.ts Normal file
View File

@@ -0,0 +1,88 @@
export type SgsMapNode =
| { type: 'coord'; floorId: number; x: number; z: number; y?: number }
| { type: 'poiId'; value: number }
| { type: 'nodeName'; value: string }
export interface SgsSnapDetail {
original: { x: number; z: number }
nearest: { x: number; z: number }
distance: number
}
export interface SgsRouteResult {
success: boolean
distance: number
duration: number
pathPoints: Array<{ x: number; y: number; z: number }>
startSnap: SgsSnapDetail
endSnap: SgsSnapDetail
computeTimeMs: number
polyCount: number
warnings: string[]
errorCode?: string
message?: string
}
export interface SgsMapSDKOptions {
container: string | HTMLElement
sdkUrl?: string
targetOrigin?: string
floorId?: number
timeout?: number
}
export interface SgsMapEvents {
ready: { engineVersion: string; protocolVersion: number }
poiClick: { id?: number; poiId?: number; name: string; type: string; floorId?: number; description?: string }
floorChanged: { floorId: number }
error: { code: string; message: string }
loadError: { type: string; detail: string }
}
export type SgsErrorCode =
| 'ERR_TIMEOUT'
| 'ERR_NO_PATH'
| 'ERR_FLOOR_NOT_FOUND'
| 'ERR_NOT_READY'
| 'ERR_POI_NOT_FOUND'
| 'ERR_NETWORK'
| 'ERR_IFRAME_DEAD'
| 'ERR_UNAUTHORIZED'
| 'ERR_UNSUPPORTED'
| 'ERR_FAILED'
export interface SgsMarkerConfig {
id: string
name?: string
iconUrl?: string
anchorNodeName?: string
position?: [number, number, number] | { x: number; y: number; z: number }
}
export interface SgsMapSDKInstance {
on<K extends keyof SgsMapEvents>(event: K, listener: (payload: SgsMapEvents[K]) => void): void
off<K extends keyof SgsMapEvents>(event: K, listener: (payload: SgsMapEvents[K]) => void): void
getIsReady(): boolean
getCurrentFloor(): number
focusTo(node: SgsMapNode | string): void
changeFloor(floorId: number, timeout?: number): Promise<{ success: boolean; floorId: number }>
addMarker(config: SgsMarkerConfig): void
removeMarker(markerId: string): void
clearMarkers(): void
highlightPolygon(nodeName: string): void
planRoute(from: SgsMapNode | string, to: SgsMapNode | string, options?: unknown, timeout?: number): Promise<SgsRouteResult>
clearRoute(): void
getState(): Promise<unknown>
resetView(): void
setViewMode(mode: '2d' | '3d'): void
toggleLayer(types: string[], visible: boolean, timeout?: number): Promise<{ success: boolean; visibleTypes: string[] }>
destroy(): void
}
export type SgsMapSDKConstructor = new (options: SgsMapSDKOptions) => SgsMapSDKInstance
declare global {
interface Window {
SGSMapSDK?: SgsMapSDKConstructor
}
}

View File

@@ -72,8 +72,9 @@ export interface HallDetailViewModel {
}
const contentTypeFor = (exhibit: MuseumExhibit): ExplainContentType => {
if (exhibit.tags?.some((tag) => /体验|教育|商业|公共/.test(tag))) return 'zone'
if (exhibit.tags?.some((tag) => /主题/.test(tag))) return 'theme'
if (exhibit.hallName && exhibit.tags?.some((tag) => /展厅/.test(tag))) return 'hall'
if (exhibit.hallName && exhibit.tags?.some((tag) => /展厅|空间/.test(tag))) return 'hall'
return 'exhibit'
}
@@ -84,7 +85,7 @@ const buildSubtitle = (exhibit: MuseumExhibit) => [
const buildBadges = (exhibit: MuseumExhibit) => {
const badges = [
exhibit.hallName ? '展厅讲解' : '讲解',
exhibit.hallName ? '空间讲解' : '展品讲解',
exhibit.floorLabel || '',
...(exhibit.tags || [])
].filter(Boolean)
@@ -92,52 +93,77 @@ const buildBadges = (exhibit: MuseumExhibit) => {
return Array.from(new Set(badges)).slice(0, 3)
}
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => ({
id: exhibit.id,
title: exhibit.name,
subtitle: buildSubtitle(exhibit),
summary: exhibit.description || '讲解内容待正式内容库补充。',
coverImage: exhibit.image,
contentType: contentTypeFor(exhibit),
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
durationLabel: undefined,
languageLabel: undefined,
audioStatus: 'unavailable',
audioStatusText: '图文讲解',
badges: buildBadges(exhibit),
progress: undefined,
poiId: exhibit.poiId,
locationActionText: '查看位置'
})
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
const sourceHasAudio = exhibit.tags?.includes('源数据含音频地址') === true
return {
id: exhibit.id,
title: exhibit.name,
subtitle: buildSubtitle(exhibit),
summary: exhibit.description || '讲解内容来自 SGS 场景设置数据,正式 CMS 文案接入后可继续替换。',
coverImage: exhibit.image,
contentType: contentTypeFor(exhibit),
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
durationLabel: undefined,
languageLabel: '中文',
audioStatus: 'unavailable',
audioStatusText: sourceHasAudio ? '音频待同步' : '图文讲解',
badges: buildBadges(exhibit),
progress: undefined,
poiId: exhibit.poiId,
locationActionText: exhibit.poiId ? '查看位置' : undefined
}
}
export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibitViewModel => toExplainCardViewModel(exhibit)
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => ({
id: exhibit.id,
title: exhibit.name,
subtitle: buildSubtitle(exhibit),
contentType: contentTypeFor(exhibit),
coverImages: [exhibit.image || '/static/exhibit-placeholder.jpg'].filter(Boolean),
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
summary: exhibit.description || '该讲解内容待正式内容库补充。',
body: exhibit.description || '该讲解内容待正式内容库补充。',
audio: {
status: 'unavailable',
unavailableReason: '正式讲解音频尚未接入媒体仓储'
},
chapters: [],
transcript: undefined,
location: exhibit.poiId
? {
poiId: exhibit.poiId,
actionText: '查看三维位置',
previewOnly: true
}
: undefined,
relatedItems: []
})
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => {
const sourceHasAudio = exhibit.tags?.includes('源数据含音频地址') === true
const metadataLines = [
exhibit.size ? `展品编号:${exhibit.size}` : '',
exhibit.year ? `年代:${exhibit.year}` : '',
exhibit.material ? `来源:${exhibit.material}` : ''
].filter(Boolean)
const body = [
exhibit.description || '该讲解内容来自 SGS 场景设置数据,正式 CMS 文案接入后可继续替换。',
metadataLines.join('\n')
].filter(Boolean).join('\n\n')
return {
id: exhibit.id,
title: exhibit.name,
subtitle: buildSubtitle(exhibit),
contentType: contentTypeFor(exhibit),
coverImages: [exhibit.image || '/static/exhibit-placeholder.jpg'].filter(Boolean),
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
summary: exhibit.description || '该讲解内容来自 SGS 场景设置数据。',
body,
audio: {
status: 'unavailable',
language: 'zh-CN',
unavailableReason: sourceHasAudio
? 'SGS 场景设置源数据包含音频地址,但静态音频文件尚未同步到当前 H5 项目。'
: '该讲解在 SGS 场景设置源数据中暂无音频地址。'
},
chapters: metadataLines.length
? metadataLines.map((line, index) => ({
id: `${exhibit.id}-metadata-${index}`,
title: line
}))
: [],
transcript: exhibit.description,
location: exhibit.poiId
? {
poiId: exhibit.poiId,
actionText: '查看三维位置',
previewOnly: true
}
: undefined,
relatedItems: []
}
}
export const toExplainDetailViewModel = (exhibit: MuseumExhibit): ExplainDetailViewModel => toExplainDetailPageViewModel(exhibit)
@@ -145,7 +171,7 @@ export const toHallDetailViewModel = (hall: MuseumHall): HallDetailViewModel =>
id: hall.id,
name: hall.name,
floorLabel: hall.floorLabel || '楼层待补充',
description: hall.description || '该展厅介绍待正式内容补充。',
description: hall.description || '该空间介绍待 SGS 场景设置或 CMS 内容补充。',
image: hall.image || '/static/hall-placeholder.jpg',
exhibitCount: hall.exhibitCount || 0,
area: hall.area,

View File

@@ -0,0 +1,21 @@
# SGS Map SDK Changelog
## [2.0.0] - 2026-06-12
### 🚀 新特性 (New Features)
- **双域双向通信架构**:采用不可见 iframe 挂载基座,通过安全沙箱和 `postMessage` 握手进行通信,从源头上解决 WebGL Context 内存泄漏和生命周期挂起问题。
- **动态点位系统 (Markers API)**:新增 `addMarker` / `removeMarker` / `clearMarkers` 接口,支持锚定三维节点(防模型漂移)和绝对坐标系定位。
- **异步寻路确权系统**`planRoute``changeFloor` 升级为基于 `requestId` 的 Promise 异步确权模式,支持 Timeout 拦截机制。
- **高精模型与坐标系重构**:全面接入米级统一坐标系(废弃 CAD 毫米系),支持多楼层分层物理拆分加载。
### 🐞 修复与强化 (Fixes & Improvements)
- `HELLLO``ENGINE_READY` 双向握手协议对齐。
- `targetOrigin` 自动推导增强,严格拦截非法的跨域源消息,免疫跨站攻击。
- SDK Iframe 生命周期的 `onerror` 捕捉增强,能正确派发 `loadError` 事件。
- 补齐了丢失的 `floorChanged` 运行时事件透传。
- 完善 `package.json` 工程化暴露,同时提供 IIFE 与 ESM 产物开启源码映射SourceMap
- `addMarker` 接口强制引入强类型定义 `MarkerConfig`
### 🗑️ 废弃与移除 (Deprecations)
- 彻底移除半成品接口 `startNavigateAnim` / `stopNavigateAnim`
- 彻底移除废弃接口 `addBusinessMarker` / `removeBusinessMarker`,统一收编为 `addMarker`

View File

@@ -0,0 +1,54 @@
# SGS Map SDK 交付包
> **版本**V2.0.0
> **定位**:深圳自然博物馆统一三维高精地图导航服务平台 H5 SDK
欢迎接入 SGS Map SDK本 SDK 将复杂的 WebGL 三维渲染、GLB 模型管线与 NavMesh 物理寻路引擎封装在服务端基座中业务端H5 / 大屏 / 小程序)只需通过几行代码即可极速唤起 3D 地图。
## 📁 目录结构
```text
sgs-map-sdk-release/
├── dist/ # SDK 代码产物包(核心)
│ ├── index.global.js # 给大屏端或传统网页使用的 IIFE 格式(通过 <script> 引入)
│ ├── index.global.js.map # IIFE source map
│ ├── index.mjs # 给 Webpack / Vite / Next.js 等现代工程使用的 ESM 格式
│ ├── index.mjs.map # ESM source map
│ ├── index.d.ts # TypeScript 类型声明文件
│ └── index.d.mts # TypeScript 模块声明文件
├── example/
│ └── index.html # 完整接入示例(带控制台、日志面板、与后端 API 联调)
├── package.json # NPM 元信息
├── sdk-quickstart.md # 【必读】10 分钟快速接入指南(含完整示例代码)
├── sdk-protocol.md # 【选读】底层通信协议与安全沙箱规范说明
└── README.md # 当前说明文档
```
## 🚀 环境对接配置信息 (非常重要)
在下游业务端执行 `new SGSMapSDK({ ... })` 初始化时,必须要填入由服务端分配的**环境对接变量**
1. **`sdkUrl`(渲染基座地址)**
- 释义:独立渲染基座的 URL 路径SDK 会自动在您的页面中创建一个不可见的 iframe 或 Web-View 连接到这里。
- **请联系地图管理平台管理员获取最新的正式/测试域名**,例如:`https://map.museum.com/h5-sdk`
2. **`targetOrigin`(安全通信域)**
- 释义:这是指**地图渲染基座的来源域名**(即 `sdkUrl` 的 Origin。为了安全SDK 只接收来自该域名 iframe 的消息。
- 配置要求:请传入您的地图基座部署域名,例如:`https://map.museum.com`。如果不传SDK 会自动从 `sdkUrl` 参数推导。注意:**不要**填成您业务页面的域名(业务页面的域名是交给 h5-sdk 做白名单校验的)。
## 📦 独立渲染基座部署与版本关系
- **部署要求**:本 SDK 是“双域架构”。除了在您的业务前端引入 `sgs-map-sdk` 库以外,**SGS 统一地图管理平台必须在您的服务器或内网环境中完成独立部署**(基座项目为 `sgs-frontend-map`)。
- **版本对应**SDK 与基座严格遵守大版本一致原则。例如SDK `V2.x.x` 必须对应基座引擎的 `V2.x.x`。如果强行跨版本混用,`HELLO` 协议握手将被拒绝并抛出 `ERR_NOT_READY`
## 📚 如何开始?
- **如果您是普通前端业务开发 / 微信小程序开发**
直接打开 `sdk-quickstart.md`复制里面的示例代码10分钟即可完成接入。
*(注:微信小程序团队完全无需引入 `dist` 下的代码,请仔细阅读文档中小程序 `<web-view>` 的原生接入方式)*
- **如果您想看一份能直接跑的完整示例**
打开 `example/index.html`,用本地静态服务器(如 `npx serve`)打开即可,里面包含楼层切换、聚焦、寻路、图钉、超时、销毁等全场景演示。需要先把基座 `sgs-frontend-map` 跑起来并把 `sdkUrl` 改成对应的本地地址。
- **如果您是对架构感兴趣的高级开发**
可以阅读 `sdk-protocol.md`,了解我们如何通过双域确权协议解决 `postMessage` 多实例安全问题,以及 WebGL 的安全释放机制。

143
static/sgs-map-sdk/index.d.ts vendored Normal file
View File

@@ -0,0 +1,143 @@
declare class EventEmitter<Events extends Record<string, any>> {
private events;
constructor();
on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void;
off<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void;
emit<K extends keyof Events>(event: K, data: Events[K]): void;
}
/** 统一位置描述符 */
type SgsMapNode = {
type: 'coord';
floorId: number;
x: number;
z: number;
y?: number;
} | {
type: 'poiId';
value: number;
} | {
type: 'nodeName';
value: string;
};
/** 单点吸附详情 */
interface SnapDetail {
original: {
x: number;
z: number;
};
nearest: {
x: number;
z: number;
};
distance: number;
}
/** 寻路结果 */
interface RouteResult {
success: boolean;
distance: number;
duration: number;
pathPoints: Array<{
x: number;
y: number;
z: number;
}>;
startSnap: SnapDetail;
endSnap: SnapDetail;
computeTimeMs: number;
polyCount: number;
warnings: string[];
errorCode?: string;
message?: string;
}
/** 寻路选项 */
interface RouteOptions {
mode?: 'walk' | 'accessible';
}
/** SDK 初始化配置 */
interface SGSMapSDKOptions {
container: string | HTMLElement;
sdkUrl?: string;
targetOrigin?: string;
floorId?: number;
timeout?: number;
}
/** SDK 事件映射 */
interface SGSMapEvents {
ready: {
engineVersion: string;
protocolVersion: number;
};
poiClick: {
id: number;
name: string;
type: string;
floorId?: number;
description?: string;
};
floorChanged: {
floorId: number;
};
error: {
code: string;
message: string;
};
loadError: {
type: string;
detail: string;
};
}
/** SDK 错误码 */
type SGSErrorCode = 'ERR_TIMEOUT' | 'ERR_NO_PATH' | 'ERR_FLOOR_NOT_FOUND' | 'ERR_NOT_READY' | 'ERR_POI_NOT_FOUND' | 'ERR_NETWORK' | 'ERR_IFRAME_DEAD' | 'ERR_UNAUTHORIZED' | 'ERR_UNSUPPORTED' | 'ERR_FAILED';
/** 图钉配置参数 */
interface MarkerConfig {
id: string;
name?: string;
iconUrl?: string;
anchorNodeName?: string;
position?: [number, number, number] | {
x: number;
y: number;
z: number;
};
}
declare class SGSMapSDK extends EventEmitter<SGSMapEvents> {
private container;
private sdkUrl;
private floorId;
private targetOrigin;
private iframe;
private isReady;
private defaultTimeout;
private promiseQueue;
private _messageListener;
constructor(options: SGSMapSDKOptions);
private _initIframeBridge;
private _generateRequestId;
private _postCommand;
private _postCommandAsync;
getIsReady(): boolean;
getCurrentFloor(): number;
focusTo(node: SgsMapNode | string): void;
changeFloor(floorId: number, timeout?: number): Promise<{
success: boolean;
floorId: number;
}>;
addMarker(config: MarkerConfig): void;
removeMarker(markerId: string): void;
clearMarkers(): void;
highlightPolygon(nodeName: string): void;
planRoute(from: SgsMapNode | string, to: SgsMapNode | string, options?: any, timeout?: number): Promise<RouteResult>;
clearRoute(): void;
getState(): Promise<any>;
resetView(): void;
setViewMode(mode: '2d' | '3d'): void;
toggleLayer(types: string[], visible: boolean, timeout?: number): Promise<{
success: boolean;
visibleTypes: string[];
}>;
destroy(): void;
}
export { type MarkerConfig, type RouteOptions, type RouteResult, type SGSErrorCode, type SGSMapEvents, type SGSMapSDKOptions, type SgsMapNode, type SnapDetail, SGSMapSDK as default };

View File

@@ -0,0 +1,2 @@
"use strict";(()=>{var m=class{constructor(){this.events={}}on(r,e){this.events[r]||(this.events[r]=[]),this.events[r].push(e)}off(r,e){this.events[r]&&(this.events[r]=this.events[r].filter(t=>t!==e))}emit(r,e){this.events[r]&&this.events[r].forEach(t=>{try{t(e)}catch(s){console.error(`[EventEmitter] Error emitting event ${String(r)}:`,s)}})}};var l=class extends m{constructor(e){super();this.iframe=null;this.isReady=!1;this.promiseQueue={};this._messageListener=null;let t=typeof e.container=="string"?document.getElementById(e.container):e.container;if(!t)throw new Error(`[SGSMapSDK] Cannot find container: ${e.container}`);this.container=t,this.sdkUrl=e.sdkUrl||"/h5-sdk",this.floorId=e.floorId||1,this.defaultTimeout=e.timeout||5e3,this.targetOrigin=e.targetOrigin,this.targetOrigin||(this.sdkUrl.startsWith("http")?this.targetOrigin=new URL(this.sdkUrl).origin:typeof window<"u"?this.targetOrigin=window.location.origin:this.targetOrigin="*"),this._initIframeBridge()}_initIframeBridge(){let e=document.createElement("iframe");e.src=`${this.sdkUrl}?floorId=${this.floorId}`,e.style.cssText="width:100%;height:100%;border:none;background:transparent;",this.container.innerHTML="",this.container.appendChild(e),this.iframe=e,this._messageListener=t=>{if(this.targetOrigin!=="*"&&t.origin!==this.targetOrigin){console.warn(`[SGSMapSDK] Origin mismatch blocked: ${t.origin} !== ${this.targetOrigin}`);return}if(!this.iframe||t.source!==this.iframe.contentWindow)return;let s=t.data;if(s){if(s.type==="SGS_MAP_EVENT"){let{action:i,payload:n}=s;i==="ON_POI_CLICK"?this.emit("poiClick",n):i==="ENGINE_READY"?(this.isReady=!0,this.emit("ready",n),console.log("[SGSMapSDK] Handshake complete: Engine Ready received \u2705")):i==="ON_FLOOR_CHANGED"&&this.emit("floorChanged",n)}if(s.type==="SGS_MAP_RESPONSE"){let{action:i,requestId:n,payload:a,error:o}=s,d=this.promiseQueue[n];if(d){if(a&&a.success!==!1)d.resolve(a);else{let f=o||{code:"ERR_FAILED",message:`${i} command failed`};d.reject(f)}delete this.promiseQueue[n]}}}},window.addEventListener("message",this._messageListener),e.onload=()=>{this.isReady||(this._postCommand("HELLO",{protocolVersion:2,sdkVersion:"2.0.0"}),console.log("[SGSMapSDK] Sent HELLO handshake command, waiting for ENGINE_READY..."))},e.onerror=t=>{console.error("[SGSMapSDK] Iframe load error",t),this.emit("loadError",{type:"iframe_error",detail:"Failed to load map engine iframe."})}}_generateRequestId(){return`req_sdk_${Date.now()}_${Math.floor(Math.random()*1e5)}`}_postCommand(e,t,s){if(!this.iframe||!this.iframe.contentWindow){console.error("[SGSMapSDK] SDK Iframe is not ready yet!");return}this.iframe.contentWindow.postMessage({type:"SGS_MAP_COMMAND",action:e,requestId:s,payload:t},this.targetOrigin)}_postCommandAsync(e,t,s=this.defaultTimeout){if(!this.isReady)return Promise.reject({code:"ERR_NOT_READY",message:'SGSMapSDK is not ready. Wait for the "ready" event before calling methods.'});let i=this._generateRequestId();return new Promise((n,a)=>{let o=setTimeout(()=>{this.promiseQueue[i]&&(this.promiseQueue[i].reject({code:"ERR_TIMEOUT",message:`Command "${e}" timed out after ${s}ms`}),delete this.promiseQueue[i])},s);this.promiseQueue[i]={resolve:d=>{clearTimeout(o),n(d)},reject:d=>{clearTimeout(o),a(d)}},this._postCommand(e,t,i)})}getIsReady(){return this.isReady}getCurrentFloor(){return this.floorId}focusTo(e){typeof e=="string"?this._postCommand("FOCUS_TO",{nodeName:e}):this._postCommand("FOCUS_TO",e)}changeFloor(e,t){return this._postCommandAsync("CHANGE_FLOOR",{floorId:e},t).then(s=>(this.floorId=e,s))}addMarker(e){this._postCommand("ADD_MARKER",e)}removeMarker(e){this._postCommand("REMOVE_MARKER",{markerId:e})}clearMarkers(){this._postCommand("CLEAR_MARKERS")}highlightPolygon(e){this._postCommand("HIGHLIGHT_POLYGON",{nodeName:e})}planRoute(e,t,s,i=5e3){let n=typeof e=="string"?e:void 0,a=typeof t=="string"?t:void 0,o={};return n?o.startNode=n:o.from=e,a?o.endNode=a:o.to=t,s&&(o.options=s),this._postCommandAsync("PLAN_ROUTE",o,i)}clearRoute(){this._postCommand("CLEAR_ROUTE")}getState(){return this._postCommandAsync("GET_STATE")}resetView(){this._postCommand("RESET_VIEW")}setViewMode(e){this._postCommand("SET_VIEW_MODE",{mode:e})}toggleLayer(e,t,s){return this._postCommandAsync("TOGGLE_LAYER",{types:e,visible:t},s)}destroy(){this.iframe&&(this.iframe.parentNode&&this.iframe.parentNode.removeChild(this.iframe),this.iframe=null),Object.keys(this.promiseQueue).forEach(e=>{let t=this.promiseQueue[e];t&&t.reject({code:"ERR_TIMEOUT",message:"SGSMapSDK has been destroyed. Pending promises were rejected."}),delete this.promiseQueue[e]}),this._messageListener&&(window.removeEventListener("message",this._messageListener),this._messageListener=null),this.isReady=!1,console.log("[SGSMapSDK] Destroyed successfully. Cleaned up iframe, listeners and pending queues.")}};typeof window<"u"&&(window.SGSMapSDK=l);})();
//# sourceMappingURL=index.global.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,25 @@
{
"name": "@sgs/map-sdk",
"version": "2.0.0",
"description": "SGS Map SDK",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
},
"sideEffects": true,
"files": [
"dist",
"example",
"README.md",
"sdk-quickstart.md",
"sdk-protocol.md"
],
"devDependencies": {},
"author": "SGS Team",
"license": "MIT"
}

View File

@@ -0,0 +1,116 @@
# SGS Map SDK 通信协议规格 (Protocol Specification)
> 版本: V2.0.0
> 适用对象: SGS Map SDK 核心开发者、渲染基座 (h5-sdk) 开发者
本文档定义了外壳 SDK (`sgs-map-sdk.js`) 与 独立渲染基座 iframe (`h5-sdk/page.tsx`) 之间的 `postMessage` 通信契约。所有消息传递必须严格遵循此结构,以保障多域环境下的确权、安全过滤与异步生命周期追踪。
---
## 1. 消息模型抽象
双方通信产生的 JSON 对象统一分为三大类:
### 1.1 命令 (Command): SDK -> 基座
外壳 SDK 向基座发起的主动操作。
```json
{
"type": "SGS_MAP_COMMAND",
"action": "PLAN_ROUTE", // [必填] 行为动词
"requestId": "req_sdk_123_45", // [可选] 异步确权追踪信标
"payload": { ... } // [可选] 负载参数
}
```
### 1.2 响应 (Response): 基座 -> SDK
基座处理完 `Command` 后(同步或异步计算),向 SDK 携带结果的被动回执。
```json
{
"type": "SGS_MAP_RESPONSE",
"action": "PLAN_ROUTE", // [必填] 原样回显 Command 的 action
"requestId": "req_sdk_123_45", // [必填] 原样回显 Command 的 requestId
"payload": { "success": true }, // [可选] 成功时的返回值
"error": { "code": "ERR_NO_PATH", "message": "无路径" } // [可选] 失败时的异常信息
}
```
### 1.3 事件 (Event): 基座 -> SDK
基座中发生的不可预知行为(如鼠标点击)或系统状态变更,主动向外广播。
```json
{
"type": "SGS_MAP_EVENT",
"action": "ON_POI_CLICK", // [必填] 事件动词 (ON_开头)
"payload": { "poiId": 1234 } // [可选] 事件负载
}
```
---
## 2. 握手时序 (Handshake Sequence)
为了解决 iframe 初始化过程中的“假死”与状态竞态,必须采用基于事件的确权握手。
```mermaid
sequenceDiagram
participant SDK as 外壳 SDK
participant Iframe as 浏览器 DOM
participant Base as 渲染基座 (h5-sdk)
SDK->>Iframe: 动态创建 iframe(src)
Iframe-->>SDK: 触发 iframe.onload
Note over SDK,Base: 此时 HTML 刚加载WebGL/GLB 模型并未解析完成
SDK->>Base: 发送 HELLO { protocolVersion: 2, sdkVersion: "2.0.0" }
Base->>Base: 挂载 WebGL拉取 GLB 及 POI 接口
Base-->>SDK: 内部加载完毕,推送 ENGINE_READY 事件
SDK->>SDK: 解锁 isReady = true清空缓存命令队列
```
---
## 3. 指令字典 (Command Action Dictionary)
基座通过严格的**白名单机制**过滤 `action`,任何未在下表中声明的指令都将被立刻退回 `ERR_UNSUPPORTED` 响应。
| Action | Payload 负载签名 | 成功 Response 负载 | 说明 |
|--------|------------------|--------------------|------|
| `HELLO` | `{ protocolVersion, sdkVersion }` | (无,以 `ENGINE_READY` 代替回执) | 初始心跳探测 |
| `CHANGE_FLOOR` | `{ floorId: number }` | `{ success: true, floorId }` | 触发析构当前层并加载新层 |
| `FOCUS_TO` | `SgsMapNode` | `{ success: true }` | 镜头平滑转移,改变相机视角 |
| `PLAN_ROUTE` | `{ startNode?: SgsMapNode, endNode?: SgsMapNode, from?: SgsMapNode, to?: SgsMapNode, options? }` | `RouteResult` | 寻路计算。基座将转换为微服务调用 |
| `CLEAR_ROUTE` | (空) | `{ success: true }` | 销毁当前的 NavMeshPathLayer |
| `GET_STATE` | (空) | `{ floorId, isReady, ... }` | 查询内部状态机当前切片 |
| `ADD_MARKER` | `MarkerConfig` | `{ success: true }` | 生成三维图钉并依附到骨骼节点 |
| `REMOVE_MARKER`| `{ markerId: string }` | `{ success: true }` | 释放指定图钉的材质 |
| `CLEAR_MARKERS`| (空) | `{ success: true }` | 卸载所有动态业务图钉 |
| `HIGHLIGHT_POLYGON`| `{ nodeName: string }` | `{ success: true }` | 激活发光材质特效 |
| `RESET_VIEW` | (空) | `{ success: true }` | 重置相机视角至初始状态 |
| `SET_VIEW_MODE` | `{ mode: '2d' | '3d' }` | `{ success: true }` | 切换 2D/3D 视角模式 |
| `TOGGLE_LAYER` | `{ types: string[], visible: boolean }` | `{ success: true, visibleTypes: string[] }` | 批量控制某类底座设施的显示隐藏 |
> **类型定义备注:**
> `SgsMapNode` 及 `RouteResult` 的精确结构请参考 TypeScript 源码定义:`sdk-src/types.ts`。
---
## 4. 事件广播列表 (Event Action List)
| 事件 Action | Payload 结构 | SDK 派发事件名 | 触发时机 |
|-------------|--------------|----------------|----------|
| `ENGINE_READY` | `{ engineVersion, protocolVersion, sdkVersion }` | `ready` | 基座完全挂载WebGL 画布可渲染 |
| `ON_POI_CLICK` | `{ id, name, type }` | `poiClick` | 用户在触摸屏或鼠标点击了高亮要素 |
| `ON_FLOOR_CHANGED`| `{ floorId }` | `floorChanged` | 楼层完全切换完毕后自动抛出 |
---
## 5. 安全沙箱规则 (Security & Sandboxing)
1. **Origin 发射校验 (SDK 侧)**
- `sgs-map-sdk.js` 使用 `iframe.contentWindow.postMessage(msg, targetOrigin)` 发射指令。若 `targetOrigin` 错误,浏览器底层会进行跨域阻断。
2. **Origin 监听校验 (SDK 侧)**
- 收到事件时比对 `event.origin !== targetOrigin`,丢弃第三方注入的伪造消息。
- 收到事件时比对 `event.source !== this.iframe.contentWindow`,丢弃宿主页面其他无关 iframe 的干扰。
3. **安全内存释放 (GC)**
- 断网或后服务宕机时SDK 侧的 `Promise` 将在设定的 `timeout` (默认 5000ms) 到达后自动 `reject`
- SDK 实例调用 `destroy()` 后,会清除自身 DOM 及事件树,杜绝闭包引用与孤儿 WebGL Context。

View File

@@ -0,0 +1,147 @@
# SGS Map SDK 快速接入指南
> 版本: V2.0.0 | 支持终端: H5 浏览器、大屏终端、微信小程序
## 1. 引入方式
SDK 提供多种模块规范的产出文件,适配不同的应用场景:
### 1.1 Script 标签引入 (浏览器直连)
`dist/index.global.js`IIFE 格式)通过 `<script>` 标签引入SDK 会挂载全局变量 `window.SGSMapSDK`
```html
<script src="./dist/index.global.js"></script>
<script>
const sdk = new SGSMapSDK({ ... });
</script>
```
### 1.2 ESM Import (Webpack / Vite / Next.js)
在现代前端工程中,直接使用 ESM 格式:
```javascript
import SGSMapSDK from 'path/to/sdk/index.mjs';
const sdk = new SGSMapSDK({ ... });
```
### 1.3 微信小程序 WebView 接入
在微信小程序中,无需引入外壳 SDK直接通过 `web-view` 组件加载地图基座,并通过 URL 传递参数。
```html
<!-- 小程序 WXML -->
<web-view src="https://map.museum.com/h5-sdk?floorId=1&mode=miniapp"></web-view>
```
---
## 2. 初始化与生命周期
### 2.1 创建实例
```javascript
const map = new SGSMapSDK({
container: 'map-container', // 挂载的 DOM 容器 ID
sdkUrl: 'https://map.museum.com/h5-sdk', // 渲染基座的 URL
targetOrigin: 'https://map.museum.com', // [重要] 安全防范,配置只允许该域名通信
floorId: 1, // 初始楼层 ID
timeout: 5000 // API 调用的默认超时时间(毫秒)
});
```
### 2.2 等待就绪事件
**重要:** SDK 的所有控制指令(如聚焦、寻路等)必须在 `ready` 事件触发后调用。
```javascript
map.on('ready', (info) => {
console.log('地图引擎加载完成', info.engineVersion);
// 现在可以安全调用 API 了
});
```
### 2.3 销毁实例
当页面卸载时,为了避免内存泄漏,必须调用 `destroy()`
```javascript
// 清除 DOM、注销所有监听事件并拒绝所有挂起的异步调用
map.destroy();
```
---
## 3. 常用场景 API
### 3.1 楼层切换
切换楼层是**异步**的SDK 会在后台销毁旧楼层 WebGL 显存,并加载新楼层模型。
```javascript
map.changeFloor(3).then((res) => {
console.log("成功切换至楼层: ", res.floorId);
}).catch(err => {
console.error("切换失败: ", err.message);
});
```
### 3.2 搜索与对焦
当用户在搜索栏查找到具体要素后,使镜头平滑飞至要素上方:
```javascript
// 通过 POI ID 对焦(推荐)
map.focusTo({ type: 'poiId', value: 10102 });
// 通过三维节点名称对焦
map.focusTo({ type: 'nodeName', value: 'Hall_DomeTheater' });
```
### 3.3 路线导航
起点与终点均支持绝对坐标或 POI ID。
```javascript
map.planRoute(
{ type: 'poiId', value: 8001 }, // 起点
{ type: 'poiId', value: 9005 }, // 终点
{ mode: 'walk' } // 选项walk 或 accessible(无障碍)
).then((result) => {
if (result.success) {
console.log(`路径规划成功:总距离 ${result.distance} 米,预计步行 ${result.duration}`);
// 基座会自动在 3D 场景中渲染青色流光折线
}
}).catch(err => {
if (err.code === 'ERR_NO_PATH') {
alert("抱歉,无法找到可达的路线。");
}
});
// 清除路线
map.clearRoute();
```
### 3.4 响应 POI 点击
游客在三维场景中点击任意可交互物体时SDK 会派发 `poiClick` 事件。
```javascript
map.on('poiClick', (poi) => {
console.log(`用户点击了:${poi.name} (ID: ${poi.poiId})`);
// 业务侧可在此处弹出底部展品详情栏或抽屉
});
```
### 3.5 动态撒图钉 (Marker)
```javascript
// 添加一个悬浮图标
map.addMarker({
markerId: 'marker-user-1',
anchorNodeName: 'Hall_Dinosaur',
imageUrl: 'https://domain.com/user-avatar.png',
width: 64,
height: 64,
yOffset: 2 // 悬浮于物体上方 2 米
});
// 清除图钉
map.removeMarker('marker-user-1');
map.clearMarkers();
```
---
## 4. 错误处理
SDK 采用 Promise 风格的异步响应,所有 `catch` 捕获的错误都遵循 `{ code, message }` 格式。
| 错误码 | 触发场景 |
|--------|----------|
| `ERR_TIMEOUT` | 网络拥塞或基座无响应,超出了构造时设置的超时时间 |
| `ERR_NOT_READY` | 在 `ready` 事件触发前,强行调用了 API |
| `ERR_NO_PATH` | 起点与终点之间没有连通的 NavMesh 物理网格 |
| `ERR_UNAUTHORIZED` | 通信双方跨域安全策略 (`targetOrigin`) 校验失败 |