Compare commits

..

8 Commits

Author SHA1 Message Date
lyf
4bcd1af1f4 修复讲解页标题与嵌入导航
Some checks are pending
CI / verify (push) Waiting to run
2026-07-24 23:09:40 +08:00
lyf
f0d27441d6 完善讲解配置与H5构建校验
Some checks failed
CI / verify (push) Has been cancelled
2026-07-24 22:34:16 +08:00
lyf
d07d68af0a docs: 更新导览站点端口与证书部署说明
Some checks failed
CI / verify (push) Has been cancelled
2026-07-22 20:17:53 +08:00
lyf
5d0c38e726 修复讲解列表展厅标题
Some checks failed
CI / verify (push) Has been cancelled
2026-07-22 19:44:16 +08:00
lyf
4d8cc55a5c 适配讲解详情聚合接口 2026-07-22 19:40:17 +08:00
cxk
dd29ead041 docs: 更新正式环境部署手册
Some checks failed
CI / verify (push) Has been cancelled
2026-07-21 15:31:21 +08:00
cxk
b90c58451c 修复首页快捷入口与三维点位同步
Some checks failed
CI / verify (push) Has been cancelled
2026-07-21 00:40:06 +08:00
cxk
1f89e8b3e0 修复导览点位可见性与构建门禁 2026-07-20 23:54:19 +08:00
46 changed files with 2702 additions and 372 deletions

View File

@@ -10,7 +10,7 @@ Use this skill for the Shenzhen Natural Museum `frontend-miniapp` project. Treat
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.
- `explain`: hall catalog -> explain-object catalog -> detail, plus audio explanation, transcript/chapters/media metadata, search/filtering, player state, and optional linkage from an explain object to a guide location preview.
## Project Ground Truth
@@ -22,6 +22,9 @@ The product has two first-class H5 businesses:
- 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.
- Current explain information architecture is `讲解 -> 展厅列表 -> 讲解对象列表 -> 讲解详情`. Do not insert a business-unit/outline list between a hall and its explain objects, and do not expose an "未分配单元" group.
- Use `讲解对象` in user-facing H5 titles, cards, loading/empty/error states, and acceptance criteria. Treat backend `guide stop` / `STOP` / `stopId` and existing `ExplainGuideStop` names as integration or internal-domain terms; do not leak `讲解点` into new presentation copy or perform a broad internal rename without a scoped migration reason.
- Treat `E:\MyWork\深圳国际艺术馆\智慧导览\smart-navigation-system\doc\miniapp_integration.md` as the authoritative explain API contract. Load halls from `/app-api/gis/guide/catalog/halls`, then page hall objects from `/app-api/gis/guide/catalog/halls/{hallId}/stops/page`; treat outline and non-paged hall-stop endpoints as migration compatibility only.
## Client Visual Design Standards
@@ -79,6 +82,7 @@ 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 the explain presentation flow hall-based. Hall selection must navigate directly to a hall-scoped explain-object list; `outlineCount`, business-unit grouping, and legacy outline routes must not control the current UI hierarchy.
- 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.
@@ -112,7 +116,7 @@ Guide data and explain data may share the same exhibit/hall/POI IDs, but their u
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.
- For explain work, start around `src/pages/explain`, `src/components/explain`, `src/components/audio`, exhibit/hall detail pages, top-tab routing, `ExplainRepository`, `explainUseCase`, and explain providers/adapters.
- Do not touch legacy hall/exhibit/explain demo data unless the task is explicitly about migration, compatibility, or cleanup.
3. Protect the H5 boundary:
@@ -134,6 +138,7 @@ Guide data and explain data may share the same exhibit/hall/POI IDs, but their u
- 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.
- Keep hall explain-object pagination behind the provider/repository/use-case boundary. Normalize `data.list` and `data.total`, preserve snowflake IDs as strings, and keep `pageNo`, `pageSize`, and `lang` out of presentation components except through a page/query view model.
- 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;
@@ -248,6 +253,9 @@ Use this workflow when the user asks to connect the SGS Map SDK to the H5 guide
## Explain And Media Standards
- Treat explain as a first-class content/media business, not as a decorative tab on the guide page.
- Enforce the two-list flow: the explain entry shows halls; tapping a hall opens that hall's paged explain-object list; tapping an object opens detail. Do not create, retain, or route through a business-unit list for the current product flow.
- Use the paged hall endpoint as the primary catalog path. Do not build the new list from `/outlines`, `/outlines/{outlineId}/stops`, or the non-paged `/halls/{hallId}/stops` compatibility endpoints.
- Render catalog labels as `讲解对象` while preserving `STOP` and `stopId` for detail, playback, and text API requests. Use catalog `playTargetType` and `playTargetId` when available.
- 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.
@@ -282,6 +290,8 @@ Guide audit checklist:
Explain audit checklist:
- Explain tab entry, list loading, filters, search drawer, hot/history keyword behavior.
- Hall click navigates directly to a hall-scoped `讲解对象` list; no business-unit page, outline grouping, or "未分配单元" appears.
- Explain-object pagination avoids duplicate rows across pages, stops at `total`, preserves string IDs, and exposes loading, empty, error/retry, and load-more failure states.
- 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.
@@ -306,6 +316,7 @@ Check:
- 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.
- Explain routing: remove the legacy hall -> business unit -> stop dependency from current entry points, deep links, and back fallbacks. Object-list back returns to the hall list; detail back returns to the same hall/object-list context where feasible.
- 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.
@@ -413,7 +424,7 @@ Use this module when the user asks to test the H5 app through browser automation
- 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.
- Explain: tab switch, hall click -> paged explain-object list -> object detail -> back to the same list -> back to halls; also verify search, audio play/pause/close, missing audio fallback, direct-open behavior, and retry/load-more failure states.
- Hall/exhibit detail: direct-open and in-flow-open behavior, visible back path, top-tab preservation, bottom action behavior.
3. Check closure risks:
@@ -442,6 +453,7 @@ 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 explain hierarchy changes, add focused tests for hall -> object-list routing, paged repository/use-case loading, `讲解对象` copy, detail targets, deep links, and list/detail back behavior. Assert that current entry points do not navigate to a business-unit route.
- 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.
@@ -449,6 +461,7 @@ pnpm build:h5
- `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.
- `src/pages/explain/business-unit-list.vue`, business-unit routes, unit-stage UI, and `listGuideStopsByBusinessUnit` calls are legacy presentation debt for the current explain flow. Do not use them as the new hall entry path; retire or isolate them only within the requested implementation scope while preserving unrelated dirty work.
- 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.

View File

@@ -10,7 +10,7 @@ Use this skill for the Shenzhen Natural Museum `frontend-miniapp` project. Treat
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.
- `explain`: hall catalog -> explain-object catalog -> detail, plus audio explanation, transcript/chapters/media metadata, search/filtering, player state, and optional linkage from an explain object to a guide location preview.
## Project Ground Truth
@@ -22,6 +22,9 @@ The product has two first-class H5 businesses:
- 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.
- Current explain information architecture is `讲解 -> 展厅列表 -> 讲解对象列表 -> 讲解详情`. Do not insert a business-unit/outline list between a hall and its explain objects, and do not expose an "未分配单元" group.
- Use `讲解对象` in user-facing H5 titles, cards, loading/empty/error states, and acceptance criteria. Treat backend `guide stop` / `STOP` / `stopId` and existing `ExplainGuideStop` names as integration or internal-domain terms; do not leak `讲解点` into new presentation copy or perform a broad internal rename without a scoped migration reason.
- Treat `E:\MyWork\深圳国际艺术馆\智慧导览\smart-navigation-system\doc\miniapp_integration.md` as the authoritative explain API contract. Load halls from `/app-api/gis/guide/catalog/halls`, then page hall objects from `/app-api/gis/guide/catalog/halls/{hallId}/stops/page`; treat outline and non-paged hall-stop endpoints as migration compatibility only.
## Client Visual Design Standards
@@ -79,6 +82,7 @@ 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 the explain presentation flow hall-based. Hall selection must navigate directly to a hall-scoped explain-object list; `outlineCount`, business-unit grouping, and legacy outline routes must not control the current UI hierarchy.
- 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.
@@ -112,7 +116,7 @@ Guide data and explain data may share the same exhibit/hall/POI IDs, but their u
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.
- For explain work, start around `src/pages/explain`, `src/components/explain`, `src/components/audio`, exhibit/hall detail pages, top-tab routing, `ExplainRepository`, `explainUseCase`, and explain providers/adapters.
- Do not touch legacy hall/exhibit/explain demo data unless the task is explicitly about migration, compatibility, or cleanup.
3. Protect the H5 boundary:
@@ -134,6 +138,7 @@ Guide data and explain data may share the same exhibit/hall/POI IDs, but their u
- 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.
- Keep hall explain-object pagination behind the provider/repository/use-case boundary. Normalize `data.list` and `data.total`, preserve snowflake IDs as strings, and keep `pageNo`, `pageSize`, and `lang` out of presentation components except through a page/query view model.
- 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;
@@ -248,6 +253,9 @@ Use this workflow when the user asks to connect the SGS Map SDK to the H5 guide
## Explain And Media Standards
- Treat explain as a first-class content/media business, not as a decorative tab on the guide page.
- Enforce the two-list flow: the explain entry shows halls; tapping a hall opens that hall's paged explain-object list; tapping an object opens detail. Do not create, retain, or route through a business-unit list for the current product flow.
- Use the paged hall endpoint as the primary catalog path. Do not build the new list from `/outlines`, `/outlines/{outlineId}/stops`, or the non-paged `/halls/{hallId}/stops` compatibility endpoints.
- Render catalog labels as `讲解对象` while preserving `STOP` and `stopId` for detail, playback, and text API requests. Use catalog `playTargetType` and `playTargetId` when available.
- 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.
@@ -282,6 +290,8 @@ Guide audit checklist:
Explain audit checklist:
- Explain tab entry, list loading, filters, search drawer, hot/history keyword behavior.
- Hall click navigates directly to a hall-scoped `讲解对象` list; no business-unit page, outline grouping, or "未分配单元" appears.
- Explain-object pagination avoids duplicate rows across pages, stops at `total`, preserves string IDs, and exposes loading, empty, error/retry, and load-more failure states.
- 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.
@@ -306,6 +316,7 @@ Check:
- 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.
- Explain routing: remove the legacy hall -> business unit -> stop dependency from current entry points, deep links, and back fallbacks. Object-list back returns to the hall list; detail back returns to the same hall/object-list context where feasible.
- 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.
@@ -413,7 +424,7 @@ Use this module when the user asks to test the H5 app through browser automation
- 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.
- Explain: tab switch, hall click -> paged explain-object list -> object detail -> back to the same list -> back to halls; also verify search, audio play/pause/close, missing audio fallback, direct-open behavior, and retry/load-more failure states.
- Hall/exhibit detail: direct-open and in-flow-open behavior, visible back path, top-tab preservation, bottom action behavior.
3. Check closure risks:
@@ -442,6 +453,7 @@ 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 explain hierarchy changes, add focused tests for hall -> object-list routing, paged repository/use-case loading, `讲解对象` copy, detail targets, deep links, and list/detail back behavior. Assert that current entry points do not navigate to a business-unit route.
- 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.
@@ -449,6 +461,7 @@ pnpm build:h5
- `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.
- `src/pages/explain/business-unit-list.vue`, business-unit routes, unit-stage UI, and `listGuideStopsByBusinessUnit` calls are legacy presentation debt for the current explain flow. Do not use them as the new hall entry path; retire or isolate them only within the requested implementation scope while preserving unrelated dirty work.
- 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.

View File

@@ -4,10 +4,10 @@
VITE_APP_PUBLIC_BASE=/
# 主开关:导览业务与讲解业务分离配置,避免互相切换。
VITE_GUIDE_DATA_SOURCE_MODE=sdk
VITE_EXPLAIN_CONTENT_SOURCE_MODE=static
VITE_EXPLAIN_CONTENT_SOURCE_MODE=remote
# 兼容旧变量:新代码优先读取上方显式主开关。
VITE_DATA_SOURCE_MODE=sdk
VITE_GUIDE_CONTENT_SOURCE_MODE=static
VITE_GUIDE_CONTENT_SOURCE_MODE=remote
VITE_EXPLAIN_ALLOW_STATIC_FALLBACK=false
VITE_GUIDE_STATIC_DATA_BASE_URL=/static/guide-data
VITE_API_BASE_URL=/app-api
@@ -24,9 +24,9 @@ VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST=
VITE_PUBLIC_LEGACY_AUDIO_HOST=
# Vite 开发服务器代理目标,只由 vite.config.ts 读取。
DEV_PROXY_APP_API_TARGET=http://localhost:3001
DEV_PROXY_ENGINE_TARGET=http://localhost:3001
DEV_PROXY_SDK_TARGET=http://localhost:3001
DEV_PROXY_APP_API_TARGET=http://1.92.206.90:3001
DEV_PROXY_ENGINE_TARGET=http://1.92.206.90:3001
DEV_PROXY_SDK_TARGET=http://1.92.206.90:3001
DEV_PROXY_MUSEUM_ASSETS_TARGET=http://1.92.206.90:9000
DEV_PROXY_MINIO_TARGET=http://localhost:3001
DEV_PROXY_AUDIO_TARGET=http://localhost:19000
DEV_PROXY_MINIO_TARGET=http://1.92.206.90:3001
DEV_PROXY_AUDIO_TARGET=http://1.92.206.90:19000

View File

@@ -32,7 +32,7 @@ jobs:
run: pnpm lint
- name: Build H5
run: pnpm build:h5
run: pnpm build:test:h5
- name: Build WeChat Mini Program
run: pnpm build:mp-weixin

View File

@@ -0,0 +1,425 @@
# Codex 双模型审核与执行工作流
> 更新日期2026-07-16
> 适用项目:`frontend-miniapp` 及其他已登记到 Codex App 的本地项目
> 工作模式:`gpt-5.6-sol / xhigh` 审核,`gpt-5.6-terra / medium` 执行
> 文档版本1.1(禁止子代理上下文分叉)
---
## 1. 文档目的
本说明用于处理需要先深入审核、再自动启动独立执行线程实施修复的开发任务。
推荐流程:
```mermaid
flowchart LR
A["整理真实需求"] --> B["手动创建 sol / xhigh 审核线程"]
B --> C["源码审核与浏览器复现"]
C --> D["生成实施级修复方案"]
D --> E["自动创建 terra / medium 执行线程"]
E --> F["修改、测试与浏览器验收"]
F --> G["检查执行报告"]
```
这种方式适合以下任务:
- 问题涉及多个页面、状态管理、异步流程或渲染器生命周期。
- 需要先确认问题是否真实存在,不能直接按用户猜测修改。
- 希望高推理模型负责根因分析,由成本和速度更平衡的模型负责实施。
- 希望分析线程与修改线程职责分离,减少分析过程中误改文件的风险。
简单文案调整、单文件确定性修改或无需调查的小任务通常不需要使用该流程。
---
## 2. 当前环境能力
本机当前已验证的 Codex App 能力:
- `codex_app__list_projects`:获取 Codex App 已登记项目及 `projectId`
- `codex_app__create_thread`:创建带初始提示词的新线程,并立即启动任务。
- `codex_app__read_thread`:读取新线程状态,确认任务是否已接收和执行。
- 创建线程时可以指定项目、运行环境、模型和推理强度。
### 2.1 必须区分的两类机制
| 机制 | 典型工具 | 是否用于本流程 | 原因 |
| --- | --- | --- | --- |
| 协作子代理 | `spawn_agent``fork_turns` | 禁止 | 需要分叉当前线程上下文,可能触发 Responses Lite 的上下文枚举兼容错误 |
| 独立 Codex 线程 | `codex_app__create_thread` | 必须 | 使用完整、自包含的初始提示词启动独立项目会话,不依赖子代理上下文分叉 |
| 历史线程分叉 | `codex_app__fork_thread` | 禁止 | 会复制线程历史,本流程不需要继承上下文,且不能替代明确的模型化执行线程 |
本流程中的“执行线程”始终指 `codex_app__create_thread` 创建的独立 Codex App 项目线程,不是 `spawn_agent` 创建的子代理。
已知错误:
```text
X-OpenAI-Internal-Codex-Responses-Lite requires ***.context to be all_turns.
```
该错误发生在子代理上下文分叉请求进入模型执行之前。即使公开参数传入 `fork_turns: "all"`,部分 Codex App/协作工具版本仍可能没有将其正确转换为内部要求的 `all_turns`。这不是项目源码、Git、目标业务模型或测试命令报错。
本流程的处理原则是彻底绕过该路径:
- 当前审核线程独立完成审核,不启动任何子代理。
- 不设置或传递 `fork_turns`
- 不使用 `codex_app__fork_thread` 复制上下文。
- 审核完成后只调用 `codex_app__create_thread`
- 将审核证据完整写入新线程的初始提示词,使执行线程不依赖父线程上下文。
- `create_thread` 不可用或失败时如实报告阻塞,不得回退到 `spawn_agent`
当前验证可用的组合:
| 阶段 | 模型 | 推理强度 | 创建方式 |
| --- | --- | --- | --- |
| 审核 | `gpt-5.6-sol` | `xhigh`(界面显示为“极高”) | 用户手动创建 |
| 执行 | `gpt-5.6-terra` | `medium`(界面显示为“中”) | 审核线程自动创建 |
注意:模型 ID、推理档位和线程工具属于当前 Codex App 运行环境能力,升级 Codex 后应重新确认。在线 Codex 手册在本文编写时因 HTTP `403` 无法获取,因此上述能力以本机实际暴露的工具定义为准。
---
## 3. 提问前准备
每个新需求至少整理以下信息:
1. 项目绝对路径。
2. 用户实际操作步骤。
3. 当前实际结果。
4. 预期正确结果。
5. 已知影响范围。
6. 不允许修改的内容或架构约束。
7. 可以执行的测试和验收方式。
推荐使用以下需求结构:
```text
业务场景:
[用户在什么状态下执行什么操作]
复现步骤:
1. [步骤一]
2. [步骤二]
3. [步骤三]
实际结果:
[当前发生了什么]
预期结果:
[正确行为是什么]
补充约束:
[H5/小程序范围、性能、兼容性、禁止重构区域等]
```
不要只写“功能有问题,请分析并修复”。缺少操作路径和预期结果时,审核模型可能只能根据源码猜测产品意图。
---
## 4. 标准操作步骤
### 4.1 创建审核线程
在 Codex App 中手动新建项目会话:
1. 选择目标项目。
2. 模型选择 `gpt-5.6-sol`
3. 推理强度选择“极高”,对应 `xhigh`
4. 粘贴第 5 节的通用提示词。
5. 替换其中的项目路径、需求、预期结果和约束。
### 4.2 审核线程职责
审核线程必须只进行以下工作:
- 检查工作区和项目规则。
- 审核源码调用链和状态变化。
- 对比正常路径与异常路径。
- 通过 H5 浏览器或测试复现。
- 判断问题是否真实存在。
- 输出根因、文件位置、修复步骤、测试矩阵和验收标准。
审核线程不得修改、格式化、暂存、提交或回滚项目文件。
审核线程还必须遵守以下工具路由限制:
- 禁止调用 `spawn_agent` 或任何等价的协作子代理工具。
- 禁止传递 `fork_turns`,无论其值是 `all``none` 还是其他值。
- 禁止调用 `codex_app__fork_thread`
- 禁止为了并行审核创建专家子代理;所有审核由当前主线程完成。
### 4.3 自动创建执行线程
审核完成后,审核线程必须在当前回合结束前实际执行:
1. 调用 `codex_app__list_projects`
2. 使用项目绝对路径匹配正确的 `projectId`
3. 调用 `codex_app__create_thread`
4. 指定 `environment.type``local`
5. 指定模型为 `gpt-5.6-terra`
6. 指定推理强度为 `medium`
7. 将完整审核证据和修复方案放入新线程的初始提示词。
8. 调用 `codex_app__read_thread` 检查一次启动状态。
本阶段只允许使用 `codex_app__list_projects``codex_app__create_thread``codex_app__read_thread` 完成交接。不得使用 `spawn_agent``fork_turns``fork_thread` 作为替代方案。
核心创建参数应符合:
```json
{
"target": {
"type": "project",
"projectId": "<通过 list_projects 解析出的真实 ID>",
"environment": {
"type": "local"
}
},
"model": "gpt-5.6-terra",
"thinking": "medium",
"prompt": "<完整审核证据、修复方案、测试矩阵和验收标准>"
}
```
`codex_app__create_thread` 的初始提示词会启动新线程。不能只在回复中展示上述 JSON也不能要求用户再手动复制修复方案。
### 4.4 执行线程职责
执行线程应:
- 直接按照审核结论实施,避免重新进行宽泛分析。
- 保护用户已有未提交修改。
- 优先修复共享状态、数据或架构边界,不使用页面级临时标志掩盖问题。
- 增加与风险匹配的单元测试、集成测试和浏览器流程测试。
- 执行项目要求的类型检查、lint、测试和构建命令。
- 报告修改文件、验证结果和剩余风险。
### 4.5 验收交接
审核线程最终回复至少应提供:
- 审核结论和根因摘要。
- 修复方案摘要。
- 新执行线程 ID。
- 执行模型和推理强度。
- 新线程启动状态。
- 创建失败时的真实错误。
执行线程最终回复至少应提供:
- 实际修改的文件。
- 实施内容和关键设计决策。
- 测试命令及结果。
- 浏览器验证步骤及结果。
- 未完成项和剩余风险。
---
## 5. 新需求通用提示词
将下面的内容粘贴到手动创建的 `gpt-5.6-sol / xhigh` 线程,并替换方括号内容。
```text
这是一个新的开发任务,请按照“高模型审核 -> 新线程执行”的双阶段流程完成。
最高优先级工具路由约束:
1. 本任务禁止使用任何协作子代理或子 Agent。
2. 禁止调用 spawn_agent、followup_task、send_message 或其他 collaboration 子代理工具。
3. 禁止设置、传递或重试 fork_turns包括 fork_turns="all"。
4. 禁止调用 codex_app__fork_thread。
5. 当前主线程必须独立完成全部审核,不得为了并行分析创建专家子代理。
6. 审核完成后的执行任务,只能通过 codex_app__create_thread 创建独立 Codex App 项目线程。
7. create_thread 不可用或调用失败时,必须如实报告阻塞;不得回退到 spawn_agent。
8. 新执行线程必须获得完整、自包含的初始提示词,不能依赖父线程上下文继承。
项目目录:
[项目绝对路径]
需求/问题:
[真实业务场景、复现步骤和异常现象]
预期行为:
[正确结果和验收标准]
补充约束:
[不能修改的模块、运行端范围、数据来源、性能要求等]
第一阶段:当前线程只审核
1. 当前线程只进行源码审核、问题复现和修复方案设计,不修改文件。
2. 先检查项目规则、AGENTS.md、相关 SKILL.md、架构文档和未提交修改。
3. 不要直接接受我的根因判断,必须通过源码证据和实际运行验证问题是否存在。
4. 对比正常路径和异常路径,追踪完整调用链、状态变化、异步请求和生命周期。
5. 尽可能通过浏览器或项目测试复现;不能复现时标记为“仅源码风险”。
6. 输出实施级方案,包括根因、文件/函数/行号、修改步骤、测试矩阵和验收标准。
第二阶段:强制创建并启动独立 Codex App 执行线程
用户明确授权你通过 codex_app__create_thread 创建和启动一个新的独立 Codex 项目线程执行修改。该线程不是协作子代理。
完成审核方案后,不得直接结束当前回合,必须:
1. 实际调用 codex_app__list_projects解析上述项目的 projectId。
2. 实际调用 codex_app__create_thread 创建新线程。
3. 新线程必须使用 environment.type=local。
4. 新线程模型必须使用 gpt-5.6-terra。
5. 新线程推理强度必须使用 medium。
6. 新线程初始提示词必须完整嵌入复现证据、根因、文件位置、修复步骤、测试矩阵和验收标准。
7. 不能只输出线程创建参数或交接提示词,必须实际调用工具。
8. 创建后使用 codex_app__read_thread 检查一次,确认新线程已经收到任务并开始执行。
9. 不得重复创建多个执行线程。
10. 不得调用 spawn_agent、fork_turns 或 codex_app__fork_thread失败时不得切换到子代理方案。
执行线程必须:
1. 直接实施已经审核完成的方案,不重新开始宽泛分析。
2. 保留用户已有未提交修改,不回滚无关变更。
3. 修复真正的共享逻辑或架构边界,避免临时标志和数据特例。
4. 增加与风险相匹配的测试。
5. 运行项目规定的类型检查、lint、测试和构建命令。
6. 对实际用户操作路径进行浏览器验证。
7. 报告修改文件、测试结果、浏览器证据和剩余风险。
当前审核线程最终回复必须包含:
- 审核结论。
- 根因和修复方案摘要。
- 新执行线程 ID。
- 执行模型和推理强度。
- 新线程启动及首次状态检查结果。
- 创建失败时的真实错误。
```
---
## 6. 本项目追加要求
`frontend-miniapp` 中使用该流程时,建议在提示词中继续加入:
```text
开始前完整阅读并遵循:
- .agents/skills/shenzhen-natural-museum-dev/SKILL.md
- 与本任务相关的 references 文档
默认只处理移动端 H5不处理 mp-weixin除非需求明确提出。
验证基线至少包括:
- pnpm type-check
- pnpm lint
- 相关单元测试
- pnpm build:h5
- 受影响用户路径的移动端浏览器验证
```
涉及室内模型初始状态、楼层切换、POI 聚焦或详情返回时,还必须阅读:
```text
.agents/skills/shenzhen-natural-museum-dev/references/guide-model-state-baseline.md
```
---
## 7. 常见问题
### 7.1 出现 Responses Lite `context` / `all_turns` 错误
错误示例:
```text
X-OpenAI-Internal-Codex-Responses-Lite requires ***.context to be all_turns.
```
这表示流程错误地调用了 `spawn_agent` 或其他需要分叉上下文的协作工具。它不是项目代码故障,也不表示子代理已经开始执行。
处理步骤:
1. 停止重试 `spawn_agent`,不要继续传递 `fork_turns: "all"`
2. 由当前主线程直接完成剩余审核。
3. 审核完成后使用 `codex_app__create_thread` 创建独立执行线程。
4. 把审核结果完整写入 `create_thread.prompt`,不要依赖上下文继承。
5. 如果仍然发生该错误,检查实际工具调用记录;正确的流程中不应出现 `spawn_agent``fork_turns`
6. 更新或重启 Codex App 后可重新验证子代理能力,但本工作流仍不需要子代理。
错误发生在模型执行前,因此通常不会修改项目文件或 Git 状态。仍应通过 `git status` 确认工作区没有其他线程产生的变化。
### 7.2 Codex 只输出了修复方案,没有创建线程
通常是提示词没有明确授予创建新线程的权限,或者只要求“生成交接提示词”。
必须明确写出:
```text
用户明确授权你实际调用 codex_app__list_projects 和 codex_app__create_thread。
不能只输出参数或建议,必须在当前回合结束前创建并启动线程。
```
### 7.3 新线程没有拿到完整上下文
新线程不会可靠地自动获得父线程尚未完成的全部分析过程。审核线程必须把关键证据直接写入 `create_thread.prompt`,不能只写“按照父线程方案执行”。
### 7.4 创建到了错误项目
不要猜测 `projectId`。必须先调用 `list_projects`,再根据规范化后的项目绝对路径匹配。
### 7.5 执行线程模型或推理强度不正确
创建线程时显式设置:
```text
model: gpt-5.6-terra
thinking: medium
```
如果 Codex App 升级后不再支持该组合,应以工具返回的可用模型和推理档位为准,并向用户说明差异。
### 7.6 执行线程覆盖了用户修改
提示词必须要求两个线程先检查工作区状态。审核线程保持只读;执行线程只能在任务范围内工作,不得回滚、覆盖或格式化无关改动。
### 7.7 为什么使用 local 而不是 worktree
`local` 让执行线程在已登记项目的当前工作目录中工作,可以看到用户现有未提交修改。使用它时必须避免多个修改线程同时编辑同一文件。
只有在明确需要隔离开发、并且已经决定从哪个 Git 状态开始时,才选择 `worktree`
### 7.8 线程已经创建,是否等于开始执行
带初始 `prompt``codex_app__create_thread` 会创建并启动线程。仍应使用 `codex_app__read_thread` 检查一次,确认任务已被接收。不要高频轮询没有变化的线程状态。
---
## 8. 执行前检查清单
- [ ] 已提供项目绝对路径。
- [ ] 已写清复现步骤、实际结果和预期结果。
- [ ] 已填写任务范围与禁止修改项。
- [ ] 手动审核线程为 `gpt-5.6-sol / xhigh`
- [ ] 提示词明确要求当前线程只审核、不修改。
- [ ] 提示词明确禁止 `spawn_agent``fork_turns``codex_app__fork_thread`
- [ ] 当前审核由主线程独立完成,不创建专家子代理。
- [ ] 提示词明确授权实际创建新线程。
- [ ] 执行线程指定为 `gpt-5.6-terra / medium`
- [ ] 执行环境指定为 `local`
- [ ] 要求把完整审核证据嵌入新线程提示词。
- [ ] 要求创建后读取一次线程状态。
- [ ] 已定义测试命令和浏览器验收路径。
- [ ] 已要求保护用户未提交修改。
---
## 9. 版本维护
Codex App 升级后,建议重新确认:
1. `codex_app__list_projects` 是否仍可用。
2. `codex_app__create_thread` 的参数结构。
3. `codex_app__read_thread` 的状态读取方式。
4. `gpt-5.6-sol``gpt-5.6-terra` 是否仍可选。
5. `xhigh``medium` 是否仍受对应模型支持。
6. 工具调用记录中是否意外出现 `spawn_agent``fork_turns``fork_thread`
如果能力发生变化,应先更新本文第 2 节和第 5 节提示词,再继续使用该工作流。

View File

@@ -1,6 +1,6 @@
# frontend-miniapp 正式环境部署手册
最后更新2026-07-15
最后更新2026-07-21
## 适用范围
@@ -35,6 +35,7 @@ git remote -v
正式环境当前依赖一组本地生产补丁,拉取远程代码前必须保护。常见补丁文件包括:
```text
.env.development
.env.production
src/config/dataSource.ts
src/data/adapters/guideDataAdapter.ts
@@ -49,19 +50,34 @@ static/guide-data/guide-stops.json
static/guide-data/manifest.json
```
这些文件中的改动属于正式环境本地补丁,拉取远程代码时不要直接丢弃。当前正式环境相关配置要点:
- `VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST=172.20.14.21`,避免正式包混入测试服务器 `1.92.206.90`
- `VITE_EXPLAIN_ALLOW_STATIC_FALLBACK=true`,讲解数据接口异常时允许使用静态兜底数据。
- `VITE_SGS_SDK_ORIGIN=https://guide.sznhmuseum.org.cn`,正式域名不再使用 `:4430`
- `static/guide-data/manifest.json``sourceHost` 应为 `172.20.14.21`
- 腾讯地图正式 key 只通过环境变量 `VITE_TENCENT_MAP_KEY` 注入,不能写入文档或提交记录。
## 拉取远程最新代码
先临时保存本地生产补丁:
```powershell
$stamp = Get-Date -Format 'yyyyMMddHHmmss'
$msg = "pre-deploy-local-prod-fixes-$stamp"
$msg = "pre-deploy-prod-local-fixes-$stamp"
if (git status --porcelain) {
git stash push -u -m $msg
Write-Output "STASHED=$msg"
}
```
执行成功时会看到类似输出,表示本地补丁已 stash
```text
Saved working directory and index state On master: pre-deploy-prod-local-fixes-YYYYMMDDHHmmss
STASHED=pre-deploy-prod-local-fixes-YYYYMMDDHHmmss
```
拉取远程最新代码:
```powershell
@@ -83,6 +99,8 @@ git status --short --branch --untracked-files=all
如出现冲突,先解决冲突并重新确认生产配置,再继续构建。
恢复后重点检查 `.env.production``static/guide-data/manifest.json``src/utils/publicUrl.ts`,确认正式环境仍指向 `172.20.14.21``https://guide.sznhmuseum.org.cn`
## 本地构建
安装依赖并做类型检查:
@@ -447,3 +465,4 @@ https://guide.sznhmuseum.org.cn/ HTTP 200
- 2026-07-13确认 `pnpm build:h5` 在 terser 阶段可能 OOM正式部署可使用 `build:h5:no-minify` 兜底。
- 2026-07-13新增并验证 `static/icons/poi/shortcut-icons.svg` 线上可访问。
- 2026-07-15排查并修复页面 200 但空白问题。根因为 PowerShell 手工替换构建 JS 时破坏 UTF-8已新增 `scripts/finalize-h5-build.cjs` 统一处理资源复制、腾讯地图 key 替换、JS 语法校验和正式环境字符串扫描。
- 2026-07-20拉取远程最新代码前已执行 `git stash push -u -m pre-deploy-prod-local-fixes-20260720232941`,确认“本地补丁已 stash”随后快进到 `735c5cd`,执行 `git stash pop` 恢复正式环境补丁,无冲突。构建入口为 `assets/index-CsFOrYfU.js`,主节点备份为 `/data/nginx/html/_backups/mobile-before-deploy-20260720233343.tar.gz`,同步脚本最终输出 `All frontend worker nodes synced.`

View File

@@ -0,0 +1,232 @@
# SGS SDK 与当前 H5 三维渲染对照研究报告
> 版本2026-07-18
> 范围:`frontend-miniapp` 当前本地 ThreeMap 渲染器,与 `smart-navigation-system/sgs-frontend-map/sdk-engine` 的 SDK Engine。
> 结论边界:本报告是参数、空间契约与验证方案研究,**不是**将当前 H5 迁移为 SDK iframe 的建议;不将外观首页和 SDK 单楼层 Demo 误判为应像素级一致的场景。
## 1. 执行摘要
当前 H5 应继续采用“本地 ThreeMap 渲染 + SGS REST 数据”的架构。`src/config/dataSource.ts:91-94` 中的 SDK 脚本和 Engine URL 只是配置能力;实际 SDK 数据路径是 `sgsSdkApiProvider``uni.request`,经 `GuideModelRepository` 转为本地模型资源供 Three.js 加载。既有的本地渲染和 SDK Engine 在背景、相机投影、DPR、渲染器、色调映射、阴影与三灯参数上已基本对齐因此不建议进行无目标的颜色/灯光再调参。
当前最高优先级不是视觉参数,而是 Bundle 的模型变换契约缺失SDK Engine 会尝试读取变换但静默回退单位矩阵H5 类型、领域模型和仓储也只传递 URL。实时 Bundle 已确认不含变换字段;但尚未证明生产数据库中存在非单位变换。因此 P0 仅应完成真实激活模型的只读数据核验,不能先行扩展运行时代码。
SDK 与 H5 的相机、POI 密度与标签实现有刻意的产品策略差异。SDK 单楼层 AutoFit 与 H5 外观/楼层状态机不是相同视图;后续视觉验收必须固定同一楼层、同一模型、同一视口、同一 DPR 和同一 view mode。POI LOD 可做受控实验,但应保留 H5 的 WebGL 命中目标、DOM 标签和碰撞规避。
## 2. 证据口径
| 标签 | 含义 | 本报告用法 |
| --- | --- | --- |
| 源码事实 | 可定位到当前两仓源码的声明或执行路径 | 可以用于架构、参数和契约判断 |
| 实时接口事实 | 2026-07-18 对已部署接口的实际响应 | 只说明当时、该地图/楼层的数据 |
| 浏览器观察 | 指定服务、URL、视口下的实际运行结果 | 不能外推为所有发布物或设备 |
| 尚未证明 | 当前证据不足以作出肯定结论 | 列为核验或回归项,不写作已修复 |
## 3. 两项目架构边界与数据链
| 维度 | 当前 H5 (`frontend-miniapp`) | SDK Engine / Web Demo | 判断 |
| --- | --- | --- | --- |
| 数据取得 | `sgsSdkApiProvider``uni.request` 请求 `/app-api/gis/sdk/*``src/data/providers/sgsSdkApiProvider.ts:381,458,489` | Engine 读取 SDK API / Bundle | 数据可比,渲染运行时不同 |
| 模型资源 | `GuideModelRepository``modelUrl` / `fallbackModelUrl`,生成本地 `GuideModelFloorAsset``src/repositories/GuideModelRepository.ts:364-378` | Engine 直接消费 Bundle model | 都依赖 Bundle但不可假设变换已传递 |
| 渲染器 | 本地 Three.js `ThreeMap.vue` | SDK iframe 内 Engine | 不建议因本次研究替换 H5 渲染器 |
| SDK URL 配置 | 存在 `sgsSdkScriptUrl``sgsMapEngineUrl` 和 timeout`src/config/dataSource.ts:91-94` | 发布 Demo 用相对 `../engine/` | 配置存在不等于当前 H5 走 iframe |
| 页面能力 | H5 有自己的楼层基线、状态机、DOM 标签和 WebGL 命中目标 | 单楼层 AutoFit、HTML POI 层、可选服务端 LOD | 应比较契约/行为,不要求视觉像素等价 |
**源码事实。** 当前 H5 的领域类型 `GuideModelFloorAsset` 只含楼层、标签、顺序和 URL 类字段(`src/domain/guideModel.ts:24-31`);这与 REST 提供者 `SgsModelInfoPayload` 的字段集合一致(`src/data/providers/sgsSdkApiProvider.ts:194-202`)。本报告不建议跨仓抽取共享运行时包:两端发布、部署和生命周期边界不同,优先使用契约快照防漂移。
## 4. 三维基础渲染参数对照
| 项 | SDK Engine | 当前 H5 | 结论 |
| --- | --- | --- | --- |
| 场景背景 | `#ECEFF1` | `0xeceff1``ThreeMap.vue:106-108,1918-1920` | 一致 |
| PerspectiveCamera | FOV 42、near 0.1、far 20000`Viewport3D.tsx:509-558` | FOV 42、near 0.1、far 20000`ThreeMap.vue:108-112,1923-1928` | 一致 |
| Renderer | DPR cap 1.5、high-performance、antialias、alpha=false | 同值,`ThreeMap.vue:114-121,1931-1937` | 一致 |
| 色调/曝光/阴影 | NoToneMapping、1.0、关闭阴影 | 同值,`ThreeMap.vue:119-121,1939-1941` | 一致 |
| 半球灯 | `#fff/#b0bec5`、1.7 | 同值,`ThreeMap.vue:123-127,1968-1973` | 一致 |
| 主光 | `[80,120,80]`、2.2 | 同值,`ThreeMap.vue:129-132,1975-1980` | 一致 |
| 补光 | `[-60,70,-50]`、0.55 | 同值,`ThreeMap.vue:134-137,1982-1987` | 一致 |
**源码事实。** SDK 参数来源为 `sgs-frontend-map/sdk-engine/src/components/Viewport3D.tsx:509-558`H5 参数集中在 `src/components/map/ThreeMap.vue:106-137,1918-1987`。因此基础色彩、灯光和 renderer 默认值不是当前主要根因;除非有受控的同场景截图证据,否则不应启动另一轮全局视觉调参。
## 5. 相机与视图策略
SDK AutoFit 以 `__model_root__``Box3`、模型尺寸和预设计算相机(`sdk-engine/src/components/AutoFitCamera.tsx:164-321`),常规距离约为 `fitDim * 1.2`,高度/目标依赖 preset。预设 `sdk-kiosk-landscape``sdk-mobile-portrait``default` 定义在 `sdk-engine/src/config/viewport-presets.ts:67-160`;竖屏会旋转约 90 度并设置 `minFitDimension=260`
H5 则保存自己的参考外观相机和楼层基线:楼层基线从模型盒、屏幕偏移和相机方向计算(`ThreeMap.vue:3505-3554``setCameraView` / `fitCameraToObject` 根据 `overview``floor` 使用不同方向、距离与偏移(`ThreeMap.vue:3833-3921`),楼层恢复还会回放快照(`ThreeMap.vue:4245-4250`)。
**源码事实与验收结论。** H5 外观首页和 SDK 单楼层 Demo 是不同产品视图,不能要求像素级相同。视觉对照的前置条件是:相同 floorId、模型 URL/版本、视口尺寸、DPR、view mode、相机 preset/基线以及无 POI 焦点状态。此前宽屏初始化后缩窄的空白,固定 `430x720` 刷新未复现,故为“尚未证明的生产缺陷”;保留“相机快照污染”回归测试即可。
## 6. 模型变换契约
| 层级 | 现状 | 证据类型 | 风险 |
| --- | --- | --- | --- |
| SDK Engine | Bundle model 被 `any` 读取,尝试 `translationX/Y/Z``rotationX/Y/Z``scaleX/Y/Z`;缺失回退 `[0,0,0] / [0,0,0] / [1,1,1]` | 源码事实:`H5SdkEngine.tsx:5078-5099,5224-5239` | 非单位数据会被静默忽略 |
| SDK Bundle VO | 只发布 URL、压缩/版本、节点数和大小 | 源码事实:`SdkModelInfoVO.java:15-36` | 外部契约未承载矩阵 |
| H5 Provider / Domain / Repo | 同样只承载 URL 等字段 | 源码事实:`sgsSdkApiProvider.ts:194-202``guideModel.ts:24-31``GuideModelRepository.ts:364-378` | H5 也使用单位变换 |
| 另一后端模型接口/DO | 存在变换字段 | 源码事实:`AppGisModelController.java:70-83``Map3dModelDO.java:57-87` | 数据能力与 SDK Bundle 契约脱节 |
| L2 实时 Bundle | `floorId=2065808921578303490``code=0`、30 POImodel keys 仅 URL/压缩/版本/统计字段,所有变换字段缺失 | 实时接口事实2026-07-18 | 只证明该 Bundle 未发布变换 |
**根因判断。** 如果数据库中存在非单位模型变换,当前 SDK 与 H5 都会静默按单位矩阵渲染可能导致模型、POI、路线和点击坐标失配。**尚未证明:** 当前没有证据证明任一激活模型的数据库变换为非单位值;不可把该风险表述为已发生故障。
**P0 核验。** 在不改运行代码的前提下,通过只读 SQL 或管理接口枚举所有激活模型的平移、旋转、缩放;记录 modelId、floorId、版本和原始字段。只有发现非单位值才进入 P2。
## 7. POI、标签与 LOD 策略
SDK 的 `PoiMarkerLayer` 是 HTML 标签,结合类型配置、遮挡检测与绝对相机距离阈值;非 kiosk 为 far=400、mid=150`sdk-engine/src/components/layers/PoiMarkerLayer.tsx:771-855,1080-1086`)。服务端 LOD API 能力存在(`sdk-engine/src/api/sdk-api-loader.ts:524-533`),但 Engine real-preview 默认关闭,仅 URL 带 `enablePoiLabelLod=1/true` 才启用(`H5SdkEngine.tsx:1187-1225`);当前 Web Demo 未传该参数。
实时接口 `/app-api/gis/sdk/maps/1/poi-label-lod` 返回 9 层;总体为 far=22、mid=181、near=183、sourcePoiCount=183、clusterCount=24、singleCount=362L2 为 far=3 / mid=26 / near=26实时接口事实2026-07-18。这证明服务端可供实验不证明当前 Engine Demo 已消费它。
H5 使用模型尺度归一化:点位阈值 `0.32/0.20`、标签阈值 `0.42/0.26``ThreeMap.vue:1260-1328`);点位数量 tight=5、balanced=9、full=无限,屏幕间距 `116/78/0``ThreeMap.vue:1300-1309,1425-1551`)。标记为 WebGL Sprite另设命中 Sprite`ThreeMap.vue:4805-4828,4880-4897`DOM 标签带碰撞规避与 `ResizeObserver``ThreeMap.vue:4550-4591`)。
**建议。** 仅以 feature flag 引入服务端 LOD 作为候选数据源;保留 DOM 标签、碰撞规避及 WebGL 点击命中目标,失败时回退本地策略。不要直接用 SDK HTML POI 替换移动 H5 实现。
## 8. Web Demo 与运行证据
| 观察 | 证据类型 | 判断与后续动作 |
| --- | --- | --- |
| 发布 Demo `app.js:482-530` 从 scene/floorCode 读取参数、固定相对 `../engine/`,等待 `sdk.whenReady()` | 源码事实 | 宿主和 Engine 发布目录是关键依赖 |
| 本地 5555 Demo 持续“连接中”30 秒 `whenReady` 超时iframe 指向 `5555/engine`;直开 `5556/engine` 成功 | 浏览器观察 | 优先查静态托管目录/发布版本不一致;不能归咎渲染器 |
| 直开 SDK Engine1280x720 Canvas 非空L2 Bundle 约117ms、30 POI、Draco 约0.24MB、160节点AutoFit 约262x133m、distance 314m、height 220m发出 `ENGINE_READY` | 浏览器观察 | Engine 在该直接入口可运行 |
| 曾出现一次 `webglcontextlost`,最终画面非空;`Viewport3D.tsx:531-539` 有恢复监听 | 浏览器观察 + 源码事实 | 仍需 context lost/restored 回归,不得视为已完全排除 |
| 当前 H5 外观页正常;`430x720` 刷新后诊断 API 切到 1F`activeView=floor`、单位变换误差0、7个 WebGL 点、2个 DOM 标签都在 Canvas 内 | 浏览器观察 | 证明此固定条件可用,非全部设备证明 |
## 9. 风险分级与分阶段实施方案
| 优先级 | 风险 / 目标 | 实施范围 | 退出条件 |
| --- | --- | --- | --- |
| P0 | Bundle 模型变换是否漏传 | 报告 + 所有激活模型的只读 SQL/管理接口核验;不改运行代码 | 有带版本的单位/非单位清单 |
| P1 | 两仓参数与 Bundle schema 漂移 | 增加参数与 schema 契约快照/测试;不抽取跨仓共享 runtime 包 | 两仓变化能在 CI/本地契约检查中定位 |
| P2 | 仅在 P0 发现非单位变换时,补齐变换契约 | `SdkModelInfoVO`、service mapper、SDK TS 类型、H5 provider/domain/repo/render matrix | 模型、POI、路线、点击坐标共用矩阵 |
| P3 | 验证服务端 POI LOD 是否优于本地密度策略 | feature flag、候选数据源与对照记录保持现有标签/命中目标 | 通过完整 LOD 验收,否则回退 |
| P4 | Demo 宿主/Engine 版本闭环和 WebGL 韧性 | 统一静态发布目录与版本链;增加 context lost/restored 回归 | `whenReady``ENGINE_READY` 稳定闭环 |
P2 的最小正确实现不是“给模型加 transform”即可而是定义同一 `M`:模型使用 `M`POI 与路线使用同一正向投影,点击/射线结果用 `M^-1` 回到业务坐标。同步覆盖单位矩阵和非单位平移/旋转/缩放,禁止局部补偿。
## 10. 验收矩阵
| 验收主题 | 条件 | 必须记录 / 断言 | 失败处理 |
| --- | --- | --- | --- |
| Bundle 变换 | P0 全部激活模型 | 原始字段、floor/model/version、是否单位矩阵 | 未完成前禁止宣称 P2 必要 |
| 变换契约(若 P2 | 单位与非单位矩阵 | 模型位置、POI 投影、路线点、点击反变换、同楼层视觉验证 | 任一失配阻断上线 |
| 视觉对照 | 同楼层、同模型版本、同视口、同 view mode | 相机参数、DPR、截图及差异说明 | 不可用跨场景截图判定 |
| LOD 实验 | L1/L2/EXTERIOR390x844DPR 1 与 3 | 可见标签数、重叠数、点击命中、帧时间 | 失败立即回退当前本地策略 |
| Demo 发布 | Demo 与 Engine 同一发布目录/版本 | `whenReady` resolve、`ENGINE_READY`、楼层切换、30 POI、模型 Canvas 非空 | 调查宿主/资源/版本链 |
| WebGL 恢复 | 主动或受控触发 context lost/restored | 日志、恢复后 Canvas、楼层、POI、交互状态 | 失败阻断 P4 闭环 |
| H5 相机回归 | 固定刷新与宽屏后缩窄 | 外观/楼层快照、Canvas 非空、标签边界 | 标记相机快照污染并修复后复验 |
## 11. 实测与测试证据
- **测试事实:** 当前 H5 已执行 `tests/unit/poiDomLabels.spec.ts``GuideMapShell.spec.ts``GuideModelStateReset.spec.ts`,共 9/9 通过。
- **测试事实:** SDK 相关测试 19/20 通过;`model-load-lifecycle.test.ts` 的唯一失败来自只匹配 LF 的正则读取 CRLF 文件。实际 remount key 代码存在,因此这是 Windows 跨平台测试脆弱性,不是生命周期实现缺失。
- **证据边界:** 上述结果是既有审核提供的执行证据,本报告只整理记录,未在本次文档任务重跑测试或 build避免写入 `output` / `dist`
## 12. 明确不建议事项
1. 不因本报告将当前 H5 本地 ThreeMap 替换为 SDK iframe。
2. 不在没有同场景证据时继续做全局颜色、灯光或 renderer 参数调参。
3. 不把外观首页与 SDK 单楼层 AutoFit 作为像素级等价对象。
4. 不在未核验数据库存在非单位变换前,盲目扩展 Bundle、SDK 或 H5 运行时代码。
5. 不单独变换模型而遗漏 POI、路线和点击坐标也不采用页面级坐标补丁。
6. 不直接照搬 SDK HTML POI替换 H5 的 DOM 碰撞标签和 WebGL 命中 Sprite。
7. 不把 5555 Demo “连接中”直接归因为渲染器故障先证明发布目录、Engine 版本和静态资源链一致。
8. 不以单次 `webglcontextlost` 后画面恢复为 context-recovery 已验收。
## 13. 交接清单
后续执行者应先完成 P0 只读核验,再决定是否开启 P2并在每个阶段把“源码事实、实时接口事实、浏览器观察、尚未证明”分列记录。任何变更前保留当前本地策略作为回退基线特别是移动端 DOM 标签、WebGL 命中目标、楼层基线和相机快照状态机。
## 14. SDK Demo 业务功能与当前 H5 对比
### 14.1 范围、概念与运行边界
本节分析的 SDK Demo 是 `E:/MyWork/深圳国际艺术馆/智慧导览/smart-navigation-system/sgs-map-sdk-release/demo/`,主要证据为 `README.md``index.html``app.js`,以及 Engine 的 `sgs-frontend-map/sdk-engine/src/H5SdkEngine.tsx`。Demo README 明确其定位是“展示 SDK 能开发成什么效果,同时用于调试”,独立静态页不直接调用 `/app-api``/admin-api`,而是由 SDK iframe/Engine 连接远端地图服务(`demo/README.md:1-35`)。它不是当前博物馆移动 H5 的产品需求清单。
本节严格区分四个概念:
| 概念 | 判定依据 | 不能推导出的结论 |
| --- | --- | --- |
| Demo 展示入口 | `index.html` Tab/按钮和 `app.js` 处理函数存在 | 不代表发布链可用,也不代表适合移动端 |
| SDK 已实现能力 | SDK 公开方法可调用,且 Engine 有对应命令/事件处理 | 不代表业务数据已发布或产品流程已设计 |
| 后端接口能力 | Engine 调用的数据接口/Bundle 字段存在 | 不代表 Demo 或 H5 已做端到端错误恢复 |
| 当前 H5 已产品化能力 | H5 页面/组件、Repository/provider 和用户流程共同存在 | 不以按钮文案、类型声明或历史 mock 单独认定 |
**浏览器观察2026-07-18** URL 场景为本地 `5555` 发布 Demoiframe 指向 `5555/engine` 时持续“连接中”,约 30 秒 `whenReady()` 超时;直接打开 `5556/engine` 则可获得非空 Canvas 和 `ENGINE_READY`。因此Demo 的入口和源代码均已验证,但完整宿主链未闭环;本节不把 Demo 所列业务能力标记为“5555 发布版已实测成功”。具体发布链限制见第 8 节。
### 14.2 SDK Demo 功能清单
| 功能域 | Demo 触发入口 | SDK 方法 / 事件 | Engine / 数据接口证据 | 运行状态 |
| --- | --- | --- | --- | --- |
| 初始化与状态 | URL `server/env/mapId/floorCode/token`,创建 `SGSMapSDK` | `whenReady()``on('ready')` | `app.js:479-535`Engine 收到 `HELLO` 后发 `ENGINE_READY``H5SdkEngine.tsx:2521-2525` | 直接 Engine 已观察成功5555 Demo 链未闭环 |
| 事件/错误日志 | “事件”Tab | `ready``poiClick``floorChanged``error``loadError``routeTransfer`、模型/Bundle/路线/漫游事件 | `app.js:554-643` | 展示和诊断入口,不是访客业务 UI |
| 地图/楼层/图层/视图 | 楼层浮栏、2D/3D、全局、Tab 切换 | `getManifest``changeFloor``setVisibleLayers``setViewMode``resetView``setRouteViewActive` | `app.js:172-315,655-704,718-795``CHANGE_FLOOR` 在 Engine 实现,`H5SdkEngine.tsx:2527-2546` | 方法存在;宿主发布链仍是前置风险 |
| 空间查询与聚焦 | “空间”Tab搜索、列表、详情 | `getSpaces()``highlightPolygon()` | `app.js:890-999,999-1068``GET_SPACES` 返回 Bundle spaces`H5SdkEngine.tsx:4111-4145`;高亮设置空间与 fly-to`3188-3218` | SDK/接口能力可确认Demo 是否完整运行受 `whenReady` 限制 |
| POI 查询、筛选、详情、聚焦 | “POI”Tab类型 chip、搜索、列表 | `getFloorPois()``focusTo()``poiClick` | `app.js:1137-1288``GET_FLOOR_POIS``H5SdkEngine.tsx:4070-4104`Canvas 点击上报并 fly-to`4920-4938` | SDK/Engine 已实现Demo 全链未闭环 |
| 讲解点查询 | “讲解点”Tab跨楼层扫描、搜索、详情 | `getGuideStops()``getGuideStopsByHall()` | `app.js:1289-1464``GET_GUIDE_STOPS` 返回 Bundle guideStops`H5SdkEngine.tsx:4159-4176`,按展厅查询 `4178-4193` | 查询能力,不等于讲解内容产品流程 |
| 同层/跨层路径 | “导航”Tab起终点楼层/地点、无障碍、overview/handoff、推荐路线 | `getNavigablePlaces()``planRouteV2()`/`planRoute()``clearRoute()` | `app.js:803-825,1465-1777,1814-1986`Engine `PLAN_ROUTE` 支持 displayMode 并激活跨层 viewer`2785-2816` | SDK 实现存在;真实路网、起终点与发布 Demo 仍应单独验收 |
| 路线分段/换层/漫游 | 路线结果分段按钮、漫游控制 | `routeTransfer``routeReady``startRoaming()`、暂停/停止 | `app.js:587-602,827-889`Engine 有 `START_ROAMING` 命令(`H5SdkEngine.tsx:3243-3299` | SDK 技术能力;不是 H5 当前可宣称的馆内导航 |
| 推荐/精品动线 | “导航”推荐按钮及“动线”Tab | `getFeaturedRoutes()``getFeaturedRoute()``planFeaturedRoutePreview()``showFlowline()` | `app.js:1793-1986,2348-2563`Engine `GET_FEATURED_ROUTES``4437-4471` | 可供后续产品评估,未证明当前博物馆数据已可用 |
| 商户 POI | “商户”Tab餐饮/商店/咖啡厅筛选 | `getBusinessPois()``focusTo()` | `index.html:177-193``app.js:2145-2245`Engine `GET_BUSINESS_POIS``4378-4396` | 后端/SDK 可调用,不代表自然博物馆有商户业务需求 |
| 诊断 | “诊断”Tab | `getDiagnostics()``getFloorDiagnostics()` | `index.html:161-175``app.js:1987-2144`Engine `GET_DIAGNOSTICS``4300-4333` | 仅调试/运维能力,不应作为访客入口 |
| 场景模式 | URL `scene=desktop/mobile/kiosk` | Engine 查询参数与 view preset | `demo/README.md:25-34``index.html:47-64`SDK 预设见第 5 节 | 展示适配维度,不代表 H5 应复制 kiosk UI |
### 14.3 当前 H5 的实际业务路径
当前 H5 不使用 Demo iframe仍是本地 ThreeMap 加 REST 数据(第 3 节)。其已可由源码证明的产品能力如下:
| 用户目标 | 真实 H5 路径与实现 | 证据 | 产品化判断 |
| --- | --- | --- | --- |
| 进入馆内/切换楼层/查看模型 | 首页 -> `GuideMapShell` -> `ThreeMap`,带外观/楼层/多层状态、楼层请求与失败状态 | `src/pages/index/index.vue:11-51,395-445``src/components/navigation/GuideMapShell.vue:12-26,136-165``src/components/map/ThreeMap.vue:27-43,444-452` | 已产品化 |
| 搜索并定位 POI | 首页或搜索页 -> `PoiSearchPanel` 分类、关键词、楼层结果 -> 详情/聚焦请求 | `src/components/search/PoiSearchPanel.vue:3-203,263-324``src/pages/index/index.vue:157-179,1637-1709` | 已产品化;不同于 Demo 的单列表/类型 chip |
| 地图 POI 点击、卡片和聚焦 | ThreeMap 发出 `poiClick`H5 使用自己的渲染、命中和详情卡片状态 | `src/components/map/ThreeMap.vue:43-45,4805-4828,4880-4897`DOM 标签见第 7 节 | 已产品化,采用 WebGL 命中 Sprite + DOM 标签而非 iframe HTML 图层 |
| 小屏可读标签 | DOM 标签投影、碰撞规避、`ResizeObserver`,配合模型尺度和屏幕密度策略 | `ThreeMap.vue:1260-1328,1425-1551,4550-4591` | 已产品化,优于直接复制 Demo 标签层 |
| 讲解浏览与详情 | `讲解 -> 展厅列表 -> 讲解对象列表 -> 讲解详情`;列表按展厅分页 | `src/utils/explainNavigation.ts:1-3``src/usecases/explainUseCase.ts:481-513``src/components/explain/ExplainGuideStopCatalog.vue:40-75` | 已产品化的内容 IA不能退化成 Demo 按楼层查“讲解点” |
| 音频讲解 | 讲解详情选择语言、播放/重试/进度;全局播放器具备 H5 audio lifecycle | `src/pages/exhibit/detail.vue:76-114,291-316``src/components/audio/AudioPlayer.vue:122-158,231-295` | 有源码能力;具体对象音频仍取决于真实内容数据 |
| 位置预览而非导航承诺 | 路线 use case 先读取 readiness不就绪返回“暂不提供正式室内导航” | `src/usecases/guideRouteUseCase.ts:43-76``src/pages/route/detail.vue:144-148,361-363,736-766` | 产品意图明确:不可将 Demo 路径规划视为当前 H5 缺陷 |
| 来馆导航桥接 | H5 通过微信小程序宿主位置页传入经校验的目标 | `src/services/WechatMiniProgramBridgeService.ts:55-121` | 已实现桥接;保证结果是微信内置地图能力,不等同直接拉起任意第三方地图 |
### 14.4 完整业务功能矩阵
| 功能域 | SDK Demo 能力 | 当前 H5 能力 | 差异状态 | 差异根因 | 产品建议 | 源码证据 |
| --- | --- | --- | --- | --- | --- | --- |
| 初始化、ready、错误日志 | `whenReady` + 12 类事件日志 | 本地模型加载/失败和诊断事件,无 SDK iframe ready | 不适用 | 渲染架构不同Demo 还承担调试 | 保持 H5 本地生命周期;仅复用可观测性字段 | Demo `app.js:479-643`H5 `ThreeMap.vue:451-452` |
| Manifest/楼层切换 | Manifest、SDK changeFloor、重置视图 | Repository/Guide shell/ThreeMap 楼层状态、基线与预加载 | 部分覆盖 | H5 有自己的数据/相机契约 | 保持 H5可对齐错误码和诊断快照 | Demo `655-795`H5 `GuideMapShell.vue:136-165``ThreeMap.vue:3505-3554` |
| 图层/2D-3D/场景 | Tab 驱动 `setVisibleLayers`、2D/3D、desktop/mobile/kiosk | overview/floor/multi、自动切换非 Demo 场景模式 | 部分覆盖 | 产品体验不同 | 不接 kiosk只在同场景评估必要的 view control | Demo `172-315`H5 `ThreeMap.vue:90,3833-3921` |
| 空间面查询/详情 | 搜索空间、`highlightPolygon` | 已有馆内 POI/展厅位置预览;未证明有独立空间面详情产品 | 部分覆盖 | H5 面向访客 POI不是 GIS 空间管理 | P2仅当“展厅/区域详情”有真实内容与模型锚点 | Demo `999-1136`Engine `4111-4145`H5 `GuideRepository.ts:115-153` |
| POI 搜索/分类/详情/聚焦 | 当前楼层类型筛选、搜索、focus、点击事件 | 分类搜索、跨楼层结果、详情/定位、点击卡片 | 相同(流程不同) | H5 针对移动端任务流程深化 | 保持 H5不迁移 iframe 列表实现 | Demo `1137-1288`H5 `PoiSearchPanel.vue:3-203``index.vue:1637-1709` |
| POI 标签与点击命中 | Engine HTML 标签、poiClick/fly-to | WebGL Sprite + hit target + DOM 碰撞标签 | 相同(实现不同) | H5 需移动端性能与命中稳定性 | 保持 H5P3 才实验服务端 LOD | Engine `4920-4938`H5 `ThreeMap.vue:4550-4591,4805-4897` |
| 讲解点查询 | 按楼层扫描/搜索/详情 | 按展厅分页的讲解对象列表和详情 | 部分覆盖H5 更产品化) | Demo 以 SDK 数据查询为主H5 是内容 IA | 保持 H5 IA可在后台按 hallId 补充关联,不改变用户导航 | Demo `1289-1464`Engine `4159-4193`H5 `explainUseCase.ts:481-513` |
| 讲解内容/音频 | Demo 仅展示 guide stop 字段 | 讲解详情、语言、音频/图文状态、播放器 | 当前 H5 更高 | Demo 未形成内容播放流程 | 保持 H5先补真实内容数据而非接 SDK UI | H5 `exhibit/detail.vue:76-114,291-316``AudioPlayer.vue:231-295` |
| 同层/跨层路线 | 可选起终点、轮椅、overview/handoff、路线段、换层和漫游 | 有 readiness 门控的位置关系/预览;不把其称为正式导航 | 缺失(有意门控) | 路网/导航数据与真实运行未验收 | P0 数据核验后再评估,不能直接打开功能 | Demo `1465-1777,827-889`H5 `guideRouteUseCase.ts:43-76` |
| 推荐/精品动线 | 内置推荐和 featured-route 预览/展示 | 未证明有当前博物馆的路线产品数据 | 当前业务不需要/数据未证实 | Demo 是 SDK showcase数据依赖待确认 | P2先做内容/运营需求与路线数据评审 | Demo `1793-1986,2348-2563`Engine `4437-4471` |
| 商户 POI | 餐饮/商店/咖啡筛选 | H5 有通用服务/设施和搜索,但未证明商户运营域 | 不适用 | 自然博物馆当前产品域与商业体不同 | 不接入,除非业务明确新增餐饮/商店运营数据 | Demo `index.html:177-193``app.js:2145-2245` |
| 诊断与事件面板 | 地图/楼层诊断、Event Log | 开发诊断、测试和控制台,不呈现访客面板 | 仅调试能力 | 受众不同,暴露技术细节会损害访客体验 | 不接入访客 H5可做受权限保护的 QA 工具 | Demo `1987-2144,554-643`Engine `4300-4333` |
| 来馆/室外导航 | 未见宿主导航桥接 | 微信小程序宿主位置页桥接 | H5 独有 | H5 部署容器和用户任务不同 | 保持现状 | `WechatMiniProgramBridgeService.ts:86-121` |
### 14.5 建议接入、保持现状与不接入
| 决策 | 能力 | 优先级 | 前置条件与原因 |
| --- | --- | --- | --- |
| 建议接入 | 路网/可达性只读核验与契约快照 | P0 | 先验证每个激活楼层 `routePlanningReady`、节点/边、可导航地点、无障碍连接器和真实路径;没有数据不得开放导航。可复用 `SdkGuideRouteRepository` 的诊断逻辑(`src/repositories/SdkGuideRouteRepository.ts:92-155`),不接 iframe。 |
| 建议接入 | Bundle/参数/路线 readiness 自动化契约检查 | P1 | 继承本报告第 9 节 P1SDK Engine 的 `GET_NAVIGABLE_PLACES` / diagnostics 仅说明接口存在(`H5SdkEngine.tsx:4205-4238,4300-4333`),需要 H5 数据层的稳定契约。 |
| 建议接入 | 受 feature flag 控制的服务端 POI LOD 对照 | P1 | 遵循第 7、10 节,保留 DOM 标签、碰撞与 WebGL 点击。只采集对比数据,不迁移 SDK HTML 标签层。 |
| 建议接入 | 精品路线的产品/数据可行性探索 | P2 | 先取得博物馆运营定义、真实 routeId/waypoints、审核后图和无障碍规则再在 H5 `GuideRouteRepository`/use case 后实现,不采用 Demo 硬编码推荐路线。 |
| 建议接入 | 访客需要的展厅/区域空间详情 | P2 | 必须有空间-内容稳定 ID、模型面/锚点和有效详情数据;走 Repository/view model不能把 `highlightPolygon` 当作完整产品。 |
| 保持现状 | 本地 ThreeMap + REST、H5 搜索、卡片、相机基线、POI DOM/WebGL 混合策略 | P0 | 当前架构和移动体验已针对本项目;详见第 3、5、7 节。 |
| 保持现状 | 展厅列表 -> 讲解对象列表 -> 讲解详情及音频路径 | P0 | 这是当前内容业务 IADemo 的按楼层 guide stop 查询不应改变此流程。 |
| 不接入 | Demo iframe/Engine 的整体替换 | 不适用 | 发布 `whenReady` 链未闭环,且会破坏本地 ThreeMap 的相机、标签、点击和状态机契约。 |
| 不接入 | 访客端诊断/事件日志、模型性能日志 | 不适用 | 仅调试/运维能力;需要时应做独立、受权限保护的 QA 面板。 |
| 不接入 | kiosk/desktop 控制面与商户 POI 业务 | 不适用 | 设备和业务域不匹配;不能仅因 SDK Demo 展示而迁移。 |
| 不接入 | 在路网未验收时的漫游、跨层 turn-by-turn、无障碍承诺 | 不适用 | 属于真实产品缺口但不应以 UI 模拟填补,必须先满足 P0 数据与现场验收。 |
### 14.6 后续实施边界、接口契约与验收
若通过 P0 证明路网和目标业务数据可用,实施边界必须保持如下分层,禁止页面直接调 SDK 或后端接口:
| 目标 | 文件边界 | 必要契约 | 测试与移动端验收 |
| --- | --- | --- | --- |
| 路线 readiness | `src/data/providers/sgsSdkApiProvider.ts` -> `src/repositories/SdkGuideRouteRepository.ts` -> `src/usecases/guideRouteUseCase.ts` -> `src/pages/route/detail.vue` / `src/pages/index/index.vue` | 楼层、route node/edge、navigable place、connector、wheelchair constraint、数据版本失败必须给出不可用原因 | 单测覆盖无数据/部分楼层/全就绪390x844 验证禁用、重试、返回位置预览,不显示“开始导航”承诺 |
| 路线渲染(仅获批后) | `GuideRouteRepository` / `guideRouteUseCase` -> `GuideMapShell.vue` -> `ThreeMap.vue` | 同一模型矩阵 M 下的 route point、floorId、段类型、换层连接器、起终点与第 6 节变换契约一并演进 | 单位/非单位矩阵、同层/跨层/无障碍路线、路线点/点击反变换390x844、DPR 1/3 下分段切楼层与取消清理 |
| 精品路线 | 新 Repository/provider/use case 与页面 view model不在 `ThreeMap.vue` 硬编码路线 | routeId、name、waypoints、运营状态、无障碍规则、内容关联、版本 | 空/失效 waypoints、跨层、重复进入、关闭恢复内容审核和运营验收后才暴露入口 |
| 空间详情 | provider/adapter/repository -> `GuideMapShell` 或专用详情页 | `spaceId``hallId/poiId`、几何锚点、展示内容、有效状态 | 空间缺面/缺内容/锚点不匹配降级;不遮挡移动端控件 |
| POI LOD 实验 | provider/adaptor 候选数据 -> `ThreeMap.vue` feature flag | server LOD 层、POI 稳定 ID、当前本地优先级和回退标志 | L1/L2/EXTERIOR、390x844、DPR 1/3记录标签数/重叠/命中/帧时间;失败回退 |
所有实施都应补充:同一发布目录下的 SDK Demo `whenReady resolve``ENGINE_READY`、楼层切换、模型非空、30 POI 与 context lost/restored 验收,但这些是 SDK 发布物的独立回归项,不能作为把 iframe 引入当前 H5 的理由。

View File

@@ -0,0 +1,718 @@
# guide.whaoyue.com 静态站点部署说明
## 1. 部署目标
- 域名:`guide.whaoyue.com`
- 服务器备注:自然博物馆-测试服务器
- 公网 IP`1.92.206.90`
- SSH 用户:`root`
- 系统Huawei Cloud EulerOS 2.0 aarch64
- 部署内容Nginx 静态测试页面 + Lets Encrypt HTTPS 证书
- 页面地址:<https://guide.whaoyue.com:4433/>
- 首次部署日期2026-06-08
- 端口调整日期2026-07-22
> 当前公网 HTTPS 入口是 `4433`,访问地址必须显式携带端口。`https://guide.whaoyue.com/` 使用默认 `443`,不再是当前入口。
## 2. 当前部署拓扑
服务器已安装系统级 Nginx 包。Docker Nginx 容器内仍监听标准 `80/443`,宿主机公网映射已调整为 `8888/4433`
```text
公网用户
-> guide.whaoyue.com
-> 1.92.206.90:8888/4433
-> Docker 容器 nginx-server
-> 宿主机 8888 -> 容器 80
-> 宿主机 4433 -> 容器 443
-> /dmdata/nginx/html/guide/index.html
```
Docker Nginx 容器:
```bash
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}"
```
当前端口映射:
| 宿主机端口 | 容器端口 | 用途 |
| --- | --- | --- |
| `8888` | `80` | HTTP 入口,跳转到 `https://guide.whaoyue.com:4433/` |
| `4433` | `443` | HTTPS 静态站点和反向代理 |
| `7080` | `7080` | 既有服务,端口调整时必须保留 |
| `7081` | `7081` | 既有服务,端口调整时必须保留 |
关键挂载:
| 宿主机路径 | 容器路径 | 用途 |
| --- | --- | --- |
| `/dmdata/nginx/conf.d` | `/etc/nginx/conf.d` | Nginx 站点配置 |
| `/dmdata/nginx/html` | `/usr/share/nginx/html` | 静态页面 |
| `/dmdata/nginx/ssl` | `/etc/nginx/ssl` | SSL 证书 |
| `/dmdata/nginx/logs` | `/var/log/nginx` | 访问日志和错误日志 |
## 3. 静态页面
静态测试页路径:
```bash
/dmdata/nginx/html/guide/index.html
```
页面用途:
- 验证域名解析
- 验证 Nginx 静态站点
- 验证 HTTP 到 HTTPS 跳转
- 验证 Lets Encrypt 证书
如需替换正式页面,将构建后的静态文件发布到:
```bash
/dmdata/nginx/html/guide/
```
## 4. Nginx 配置
站点配置文件:
```bash
/dmdata/nginx/conf.d/guide.whaoyue.com.conf
```
配置行为:
- `http://guide.whaoyue.com:8888/` 自动跳转到 `https://guide.whaoyue.com:4433/`
- `/.well-known/acme-challenge/` 路径仍保留,但 Lets Encrypt HTTP-01 不会验证 `8888`,续期限制见第 5 节
- `https://guide.whaoyue.com:4433/` 返回静态页面
- `https://guide.whaoyue.com:4433/app-api/` 反向代理到宿主机 `48080/yudao-server/app-api/`
- `https://guide.whaoyue.com:4433/h5-sdk` 配置为反向代理到宿主机 `3001`2026-07-22 公网抽查为 `502`,当前不可作为可用能力
- `https://guide.whaoyue.com:4433/museum-assets/``/minio/museum-assets/` 反向代理到 MinIO
- 静态资源设置 30 天缓存
### 4.1 H5 SDK 与后端代理规则
H5 页面运行在 HTTPS 下,前端运行时代码不能直接请求:
```text
http://1.92.206.90:3001/app-api/...
http://1.92.206.90:9000/museum-assets/...
```
否则浏览器会拦截 mixed content表现为 SDK 后端接口请求失败或 3D 模型资源 `Failed to fetch`
前端 H5 构建环境应使用同源路径:
```dotenv
VITE_DATA_SOURCE_MODE=sdk
VITE_API_BASE_URL=/app-api
VITE_SGS_API_BASE_URL=/app-api
VITE_SGS_MAP_ID=1
VITE_SGS_SDK_SCRIPT_URL=/static/sgs-map-sdk/index.global.js
VITE_SGS_H5_ENGINE_URL=/h5-sdk
VITE_SGS_SDK_ORIGIN=https://guide.whaoyue.com:4433
```
`VITE_SGS_H5_ENGINE_URL=/h5-sdk` 仅在 H5 SDK 上游恢复后可用2026-07-22 公网响应为 `502`
Nginx 容器内的 `443 ssl` server 中必须包含以下代理规则。宿主机 `4433` 通过 Docker 映射进入容器 `443`
```nginx
location ^~ /app-api/ {
proxy_pass http://172.17.0.1:48080/yudao-server/app-api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location ^~ /h5-sdk {
proxy_pass http://172.17.0.1:3001/h5-sdk;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location ^~ /minio/museum-assets/ {
proxy_pass http://172.17.0.1:9000/museum-assets/;
proxy_set_header Host 172.17.0.1:9000;
expires 30d;
add_header Cache-Control "public";
}
location ^~ /museum-assets/ {
proxy_pass http://172.17.0.1:9000/museum-assets/;
proxy_set_header Host 172.17.0.1:9000;
expires 30d;
add_header Cache-Control "public";
}
```
每次修改后检查并重载:
```bash
docker exec nginx-server nginx -t
docker exec nginx-server nginx -s reload
```
检查配置:
```bash
docker exec nginx-server nginx -t
```
重载配置:
```bash
docker exec nginx-server nginx -s reload
```
重启容器:
```bash
docker restart nginx-server
```
### 4.2 Docker 端口映射
当前容器没有 compose 管理,端口映射保存在容器启动参数中。检查当前值:
```bash
docker inspect nginx-server --format '{{json .HostConfig.PortBindings}}'
```
期望结果包含:
```text
宿主机 8888 -> 容器 80
宿主机 4433 -> 容器 443
宿主机 7080 -> 容器 7080
宿主机 7081 -> 容器 7081
```
需要重建容器时,先保存检查信息并保留旧容器用于回滚:
```bash
ts=$(date +%Y%m%d%H%M%S)
docker inspect nginx-server \
> "/dmdata/nginx/backups/nginx-server-inspect-before-port-change-${ts}.json"
docker stop nginx-server
docker rename nginx-server "nginx-server-before-port-change-${ts}"
docker run -d \
--name nginx-server \
--restart unless-stopped \
-p 8888:80 \
-p 4433:443 \
-p 7080:7080 \
-p 7081:7081 \
-v /dmdata/nginx/conf.d:/etc/nginx/conf.d:ro \
-v /dmdata/nginx/ssl:/etc/nginx/ssl:ro \
-v /dmdata/nginx/html:/usr/share/nginx/html:ro \
-v /dmdata/nginx/logs:/var/log/nginx \
arm64v8/nginx:stable-alpine
```
服务器防火墙必须持久化放行新端口:
```bash
firewall-cmd --permanent --add-port=8888/tcp
firewall-cmd --permanent --add-port=4433/tcp
firewall-cmd --reload
```
启动后先执行 `nginx -t` 和第 6 节公网验证。全部通过后再删除已停止的旧容器。
## 5. SSL 证书
证书客户端:`acme.sh`
证书颁发机构Lets Encrypt
证书路径:
```bash
/dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem
/dmdata/nginx/ssl/guide.whaoyue.com/privkey.pem
```
证书信息:
```text
Subject: CN = guide.whaoyue.com
Issuer: Let's Encrypt YR2
Not Before: 2026-06-07 15:33:10 GMT
Not After: 2026-09-05 15:33:09 GMT
```
查看证书:
```bash
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -subject -issuer -dates
```
自动续期:
```bash
crontab -l | grep acme
```
当前续期任务:
```text
11 7 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null
```
当前证书配置使用 Webroot HTTP-01
```text
Le_Webroot='/dmdata/nginx/html/guide'
```
Lets Encrypt HTTP-01 固定验证公网 `80`,不会访问 `8888`。因此改为 `8888/4433`cron 存在不代表证书能够自动续期。必须在证书到期前完成以下任一方案:
1. 续期窗口临时恢复安全组、防火墙和 Docker 的公网 `80 -> 容器 80` 映射,续期完成后再关闭。
2.`acme.sh` 迁移为 DNS-01并配置可用的 DNS API 凭据。
当前证书到期时间为 `2026-09-05 15:33:09 GMT`,迁移或续期测试应提前完成。
`acme.sh` 已配置证书安装后的 reload 命令:
```bash
docker exec nginx-server nginx -s reload
```
满足公网 `80` 可验证或已迁移 DNS-01 后,才可执行手动续期测试:
```bash
/root/.acme.sh/acme.sh --cron --home /root/.acme.sh --force
```
测试后必须重新检查证书到期时间,并从公网验证 `4433`
```bash
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -dates
curl -I https://guide.whaoyue.com:4433/
```
## 6. 部署步骤复盘
1. 确认 DNS
```powershell
Resolve-DnsName guide.whaoyue.com -Type A
```
结果应指向:
```text
1.92.206.90
```
2. 确认公网端口:
```powershell
Test-NetConnection guide.whaoyue.com -Port 8888
Test-NetConnection guide.whaoyue.com -Port 4433
```
3. 创建静态测试页:
```bash
mkdir -p /dmdata/nginx/html/guide/.well-known/acme-challenge
```
4. 写入 Nginx 站点配置:
```bash
/dmdata/nginx/conf.d/guide.whaoyue.com.conf
```
5. 申请证书(以下是 2026-06-08 使用公网 `80` 完成首次签发的历史命令;当前端口映射下不可直接用于 HTTP-01 续期):
```bash
/root/.acme.sh/acme.sh --set-default-ca --server letsencrypt
/root/.acme.sh/acme.sh --issue --server letsencrypt -d guide.whaoyue.com -w /dmdata/nginx/html/guide --keylength 2048
```
6. 安装证书:
```bash
/root/.acme.sh/acme.sh --install-cert -d guide.whaoyue.com \
--key-file /dmdata/nginx/ssl/guide.whaoyue.com/privkey.pem \
--fullchain-file /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem \
--reloadcmd "docker exec nginx-server nginx -s reload"
```
7. 验证访问:
```powershell
curl.exe -I http://guide.whaoyue.com:8888/
curl.exe -I https://guide.whaoyue.com:4433/
curl.exe -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
curl.exe -I https://guide.whaoyue.com:4433/h5-sdk
curl.exe -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
curl.exe -I https://guide.whaoyue.com:4433/museum-assets/optimized/20260617/draco_EXTERIOR.glb_1781708354536.glb
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
```
期望结果:
```text
HTTP 301 -> https://guide.whaoyue.com:4433/
HTTPS 200 OK
/app-api/... 200 application/json
/h5-sdk 当前为 502修复上游或移除废弃规则前不得记为通过
/static/sgs-map-sdk/index.global.js 200 application/javascript
模型 GLB 200 model/gltf-binary
```
## 7. 已处理的问题
本节中的无端口 URL、404 和 Mixed Content 文本是端口调整前的历史故障原文;当前复测命令统一使用 `8888/4433`
### 7.1 Docker Nginx 配置校验失败
容器内 `nginx -t` 曾失败,原因是 Linux Docker 容器无法解析 `host.docker.internal`
处理方式:
-`/dmdata/nginx/conf.d/https.conf` 中的 `host.docker.internal` 代理目标改为 Docker 默认网关 `172.17.0.1`
- 重新执行 `docker exec nginx-server nginx -t`
- 配置校验通过后 reload Nginx
### 7.2 公网端口调整为 8888/4433
系统级 Nginx 已安装,但站点继续由 `nginx-server` 容器承载。2026-07-22 将宿主机映射从 `80:80``443:443` 调整为 `8888:80``4433:443`,并保留 `7080/7081` 映射。Nginx 容器内仍监听标准 `80/443`
HTTP server 的跳转目标必须显式包含新 HTTPS 端口:
```nginx
return 301 https://$host:4433$request_uri;
```
### 7.3 H5 SDK 接口请求混合内容
现象:
```text
Request URL http://1.92.206.90:3001/app-api/gis/sdk/maps/1/manifest
Referrer Policy strict-origin-when-cross-origin
```
页面实际运行在 `https://guide.whaoyue.com:4433/`,但构建产物中固化了 HTTP 后端地址,浏览器会拦截 mixed content 请求。若 Nginx 未配置 `/app-api/` 代理,改成同源路径后还会被 SPA 回退到 `index.html`
处理方式:
- 前端构建环境改为同源 `/app-api``/h5-sdk``/static/sgs-map-sdk/index.global.js`
- Nginx 增加 `/app-api/``/h5-sdk` 代理到宿主机 `3001`
- 重新构建并部署 H5 包
- 扫描构建产物,确认运行时代码不再包含 `1.92.206.90:3001`
验证:
```powershell
curl.exe -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
curl.exe -I https://guide.whaoyue.com:4433/h5-sdk
curl.exe -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
```
### 7.4 SDK 模型资源请求混合内容
现象:
- SDK 接口已经返回 `200 JSON`
- 页面仍提示 `3D 模型加载失败``Failed to fetch`
- 后端 bundle 中部分 `modelUrl``http://1.92.206.90:9000/museum-assets/...`
原因:
HTTPS 页面加载 HTTP MinIO 模型资源会被浏览器拦截;另外后端也可能返回 `/minio/museum-assets/...`,如果 Nginx 未配置该前缀,会落入 SPA 回退并返回 `index.html`
处理方式:
- Nginx 同时配置 `/museum-assets/``/minio/museum-assets/` 到 MinIO
- 前端对已知 `http://1.92.206.90:9000/museum-assets/...` 模型地址改写为同源 `/museum-assets/...`
- 重新构建并部署 H5 包
验证:
```powershell
curl.exe -I https://guide.whaoyue.com:4433/museum-assets/optimized/20260617/draco_EXTERIOR.glb_1781708354536.glb
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
```
期望均返回:
```text
HTTP/1.1 200 OK
Content-Type: model/gltf-binary
```
### 7.5 展厅图标与占位图静态资源 404
现象:
```text
GET https://guide.whaoyue.com/static/icons/halls/biology.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/evolution.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/dinosaur.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/earth.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/human.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/homeland.jpg 404
GET https://guide.whaoyue.com/static/icons/halls/universe.jpg 404
GET https://guide.whaoyue.com/static/exhibit-placeholder.jpg 404
```
原因:
- 源码目录存在 `static/icons/halls/*.jpg`,但 H5 构建后的补充复制脚本只复制了 `nav-assets``guide-data``sgs-map-sdk``three`,没有稳定复制 `static/icons`
- 页面与音频播放器中曾使用 `/static/exhibit-placeholder.jpg``/static/hall-placeholder.jpg` 作为兜底图,但静态目录里没有对应文件。
- 旧浏览器缓存的 chunk 可能仍请求旧占位图路径,因此即使新代码改用 SVG 兜底,也需要保留兼容文件。
处理方式:
- 更新 `frontend-miniapp/scripts/copy-h5-nav-assets.cjs`,将 `static/icons` 纳入 H5 构建产物复制清单。
- 新代码兜底图改为已存在且会随包发布的:
- `/static/icons/marker-exhibit.svg`
- `/static/icons/marker-hall.svg`
- 同时补充兼容文件:
- `/static/exhibit-placeholder.jpg`
- `/static/hall-placeholder.jpg`
- 重新执行 `pnpm type-check``pnpm build:h5` 并部署 H5 包。
验证:
```powershell
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/biology.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/evolution.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/dinosaur.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/earth.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/human.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/homeland.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/halls/universe.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/exhibit-placeholder.jpg
curl.exe -I https://guide.whaoyue.com:4433/static/hall-placeholder.jpg
```
期望返回:
```text
HTTP/1.1 200 OK
Content-Type: image/jpeg
```
其中 SVG 兜底图验证:
```powershell
curl.exe -I https://guide.whaoyue.com:4433/static/icons/marker-exhibit.svg
curl.exe -I https://guide.whaoyue.com:4433/static/icons/marker-hall.svg
```
期望返回:
```text
HTTP/1.1 200 OK
Content-Type: image/svg+xml
```
### 7.6 TTS 语音播放 Mixed Content
现象:
```text
Mixed Content: The page at 'https://guide.whaoyue.com/#/pages/exhibit/detail?id=715805000000000009&tab=explain'
was loaded over HTTPS, but requested an insecure audio file
'http://1.92.206.90:9000/tts-audio/audio/2026/06/30/947228d3e5174f3697f22cceac30075e.mp3'.
This request has been blocked; the content must be served over HTTPS.
详情音频不可播放: 音频加载失败,当前提供图文讲解。
H5 音频播放被拦截或失败: NotSupportedError: Failed to load because no supported source was found.
```
原因:
- 页面运行在 HTTPS 下,但后端语音播放接口返回的 `playUrl` 指向 `http://1.92.206.90:9000/tts-audio/...`
- 浏览器不会把 IP 地址 HTTP 音频自动升级为 HTTPS因此音频请求被 mixed content 策略拦截。
- 服务器 Nginx 已配置 `/tts-audio/` 到 MinIO 的 HTTPS 同源代理,因此核心修复点是前端 URL 归一化,而不是新增代理。
当前 Nginx 代理规则应包含:
```nginx
location ^~ /tts-audio/ {
proxy_pass http://172.17.0.1:9000/tts-audio/;
proxy_set_header Host 172.17.0.1:9000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, Origin, Accept, Content-Type" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" always;
add_header Cache-Control "public, max-age=604800" always;
}
```
处理方式:
- 更新前端统一公共 URL 归一化逻辑 `normalizeSameOriginPublicUrl`
- `http://1.92.206.90:9000/museum-assets/...` -> `/museum-assets/...`
- `http://1.92.206.90:9000/tts-audio/...` -> `/tts-audio/...`
- `http://47.120.48.148:19000/nhm/audio/...` -> `/nhm/audio/...`
- 确认 `AudioPlayInfoRepository`、后端讲解数据 adapter、静态导览 adapter 均通过该统一方法处理音频 URL。
- 重新构建并部署 H5 包。
- 扫描线上运行时代码,确认不再包含 `http://1.92.206.90:9000/tts-audio` 或其他 MinIO HTTP 直连。
验证时不要复用已经删除的历史 MinIO 对象。先从当前播放信息接口取得 `playUrl`
```powershell
curl.exe "https://guide.whaoyue.com:4433/app-api/gis/guide/audio/play-info?targetType=STOP&targetId=865546638960193536&lang=zh-CN"
curl.exe -I https://guide.whaoyue.com:4433/minio/museum-assets/tts-audio/audio/2026/07/17/63058096489b4f4aa516fb07fe4a1144.mp3
```
期望返回:
```text
HTTP/1.1 200 OK
Content-Type: audio/mpeg
Accept-Ranges: bytes
```
线上运行时代码扫描:
```bash
grep -R 'http://1\.92\.206\.90:9000/tts-audio\|http://1\.92\.206\.90:9000' \
-n /dmdata/nginx/html/guide/assets /dmdata/nginx/html/guide/index.html || true
```
期望无输出。
## 8. 回滚方式
配置备份目录:
```bash
/dmdata/nginx/backups/20260608002938
/dmdata/nginx/backups/20260608003215
/dmdata/nginx/backups/guide.whaoyue.com.conf-before-port-change-20260722175845
/dmdata/nginx/backups/nginx-server-inspect-before-port-change-20260722175845.json
```
回滚配置示例:
```bash
cp -a /dmdata/nginx/backups/20260608003215/conf.d/* /dmdata/nginx/conf.d/
docker exec nginx-server nginx -t
docker exec nginx-server nginx -s reload
```
如需回滚 2026-07-22 的端口调整,应根据检查快照重建容器为 `80:80``443:443``7080:7080``7081:7081`,同时恢复站点配置中的标准 HTTPS 跳转。执行前必须再次备份当前容器检查信息和站点配置。
如需移除本域名站点:
```bash
rm -f /dmdata/nginx/conf.d/guide.whaoyue.com.conf
docker exec nginx-server nginx -t
docker exec nginx-server nginx -s reload
```
## 9. 运行维护检查清单
日常检查:
```bash
docker ps --filter name=nginx-server
docker exec nginx-server nginx -t
curl -I http://guide.whaoyue.com:8888/
curl -I https://guide.whaoyue.com:4433/
curl -I https://guide.whaoyue.com:4433/app-api/gis/sdk/maps/1/manifest
curl -I https://guide.whaoyue.com:4433/h5-sdk
curl -I https://guide.whaoyue.com:4433/static/sgs-map-sdk/index.global.js
curl -I https://guide.whaoyue.com:4433/minio/museum-assets/sgs-map/glb/1/V20260624104338_8f086/models/L1_draco.glb
curl -I https://guide.whaoyue.com:4433/minio/museum-assets/tts-audio/audio/2026/07/17/63058096489b4f4aa516fb07fe4a1144.mp3
curl -I https://guide.whaoyue.com:4433/static/icons/halls/biology.jpg
curl -I https://guide.whaoyue.com:4433/static/exhibit-placeholder.jpg
```
查看日志:
```bash
tail -f /dmdata/nginx/logs/guide-http-access.log
tail -f /dmdata/nginx/logs/guide-https-access.log
tail -f /dmdata/nginx/logs/guide-https-error.log
```
证书检查:
```bash
/root/.acme.sh/acme.sh --list
openssl x509 -in /dmdata/nginx/ssl/guide.whaoyue.com/fullchain.pem -noout -subject -issuer -dates
```
## 10. 验收记录
2026-06-08 验收结果:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| DNS A 记录 | 通过 | `guide.whaoyue.com -> 1.92.206.90` |
| 80 端口公网连通 | 通过 | `Test-NetConnection guide.whaoyue.com -Port 80 = True` |
| 443 端口公网连通 | 通过 | `Test-NetConnection guide.whaoyue.com -Port 443 = True` |
| HTTP 跳转 | 通过 | `HTTP/1.1 301 Moved Permanently` |
| HTTPS 静态页 | 通过 | `HTTP/1.1 200 OK` |
| Nginx 配置 | 通过 | `nginx: configuration file /etc/nginx/nginx.conf test is successful` |
| SSL 证书 | 通过 | `CN = guide.whaoyue.com`, Lets Encrypt |
| 自动续期 | 通过 | `acme.sh` cron 已存在 |
2026-06-26 SDK 后端与模型资源修复验收:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| SDK manifest 同源接口 | 通过 | `/app-api/gis/sdk/maps/1/manifest -> 200 application/json` |
| H5 SDK 基座 | 通过 | `/h5-sdk -> 200 text/html` |
| SDK 脚本 | 通过 | `/static/sgs-map-sdk/index.global.js -> 200 application/javascript` |
| MinIO `/museum-assets/` 模型 | 通过 | `draco_EXTERIOR.glb_1781708354536.glb -> 200 model/gltf-binary` |
| MinIO `/minio/museum-assets/` 模型 | 通过 | `L1_draco.glb -> 200 model/gltf-binary` |
| 混合内容引用 | 通过 | H5 运行时代码无 `1.92.206.90:3001``1.92.206.90:9000` |
2026-06-30 静态图标、占位图与 TTS 音频修复验收:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| 展厅图标资源 | 通过 | `/static/icons/halls/biology.jpg` 等展厅图标 -> `200 image/jpeg` |
| 展品占位图兼容资源 | 通过 | `/static/exhibit-placeholder.jpg -> 200 image/jpeg` |
| 展厅占位图兼容资源 | 通过 | `/static/hall-placeholder.jpg -> 200 image/jpeg` |
| SVG 兜底图资源 | 通过 | `/static/icons/marker-exhibit.svg -> 200 image/svg+xml` |
| TTS 音频 HTTPS 代理 | 通过 | `/tts-audio/audio/2026/06/30/947228d3e5174f3697f22cceac30075e.mp3 -> 200 audio/mpeg` |
| TTS CORS/Range 响应 | 通过 | 响应包含 `Access-Control-Allow-Origin: *``Accept-Ranges: bytes` |
| 混合内容引用 | 通过 | H5 运行时代码无 `http://1.92.206.90:9000/tts-audio`、无 `http://1.92.206.90:9000` |
2026-07-22 公网端口调整验收:
| 检查项 | 结果 | 证据 |
| --- | --- | --- |
| Docker HTTP 映射 | 通过 | `0.0.0.0:8888 -> 80/tcp` |
| Docker HTTPS 映射 | 通过 | `0.0.0.0:4433 -> 443/tcp` |
| 既有端口保留 | 通过 | `7080/7081 -> 7080/7081` |
| 8888 端口公网连通 | 通过 | `http://guide.whaoyue.com:8888/ -> 301` |
| HTTP 跳转目标 | 通过 | `Location: https://guide.whaoyue.com:4433/` |
| 4433 首页 | 通过 | `https://guide.whaoyue.com:4433/ -> 200` |
| 4433 API 代理 | 通过 | `/app-api/gis/hall/list -> 200 application/json` |
| 域名校验文件 | 通过 | `/gpL0svkeao.txt -> 200 text/plain` |
| 当前语音对象 | 通过 | 播放信息返回的 `/minio/museum-assets/tts-audio/...mp3 -> 200 audio/mpeg` |
| H5 SDK 基座 | 阻塞 | `/h5-sdk -> 502`,不得沿用 2026-06-26 的 200 结论 |
| Nginx 配置 | 通过 | `nginx: configuration file /etc/nginx/nginx.conf test is successful` |
| 证书续期 | 待处理 | 当前 Webroot HTTP-01 不能通过公网 `8888` 完成验证,需恢复 `80` 或迁移 DNS-01 |

View File

@@ -67,8 +67,38 @@ const copyStaticFile = (fileName) => {
console.log(`Copied ${fileName} to ${path.relative(projectRoot, targetFile)}`)
}
const rewriteCopiedGuideDataUrls = () => {
const guideDataDir = path.join(h5Root, 'static', 'guide-data')
const replacements = [
['http://1.92.206.90:9000/museum-assets', '/museum-assets'],
['http://1.92.206.90:9000/tts-audio', '/tts-audio'],
['https://guide.whaoyue.com/museum-assets', '/museum-assets'],
['https://guide.whaoyue.com/tts-audio', '/tts-audio'],
['https://guide.whaoyue.com/app-api', '/app-api'],
['"sourceHost": "1.92.206.90:5237"', '"sourceHost": ""']
]
for (const entry of fs.readdirSync(guideDataDir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
const filePath = path.join(guideDataDir, entry.name)
let text = fs.readFileSync(filePath, 'utf8')
const original = text
for (const [from, to] of replacements) {
text = text.split(from).join(to)
}
if (text !== original) {
fs.writeFileSync(filePath, text, 'utf8')
console.log(`Rewrote guide-data URLs in ${path.relative(projectRoot, filePath)}`)
}
}
}
copyStaticSubtree('nav-assets')
copyStaticSubtree('guide-data')
rewriteCopiedGuideDataUrls()
copyStaticSubtree('guide')
copyStaticSubtree('icons')
// 讲解业务单元封面由 H5 运行时按 /static/explain/... 动态加载。

View File

@@ -0,0 +1,61 @@
const { loadEnv } = require('vite')
const tencentMapKeyPlaceholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__'
const tencentMapKeyPattern = /^[A-Z0-9]{5}(?:-[A-Z0-9]{5}){4,6}$/i
const isUsableTencentMapKey = (value) => (
tencentMapKeyPattern.test(value)
&& value !== tencentMapKeyPlaceholder
)
const resolveH5BuildPolicy = ({
args = [],
projectRoot,
environment = process.env,
loadProductionEnv = (root) => loadEnv('production', root, '')
}) => {
const flags = new Set(args)
const requireTencentMapKey = flags.has('--require-tencent-map-key')
const allowPlaceholder = flags.has('--allow-placeholder')
if (requireTencentMapKey && allowPlaceholder) {
throw new Error('H5 build cannot require a Tencent map key and allow its placeholder at the same time.')
}
if (!requireTencentMapKey) {
return {
allowPlaceholder,
requireTencentMapKey,
tencentMapKey: ''
}
}
const productionEnv = loadProductionEnv(projectRoot)
const tencentMapKey = (
environment.VITE_TENCENT_MAP_KEY
|| productionEnv.VITE_TENCENT_MAP_KEY
|| ''
).trim()
if (!isUsableTencentMapKey(tencentMapKey)) {
throw new Error([
'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.',
'Inject it through CI or an ignored .env.production.local file.'
].join(' '))
}
return {
allowPlaceholder,
requireTencentMapKey,
tencentMapKey
}
}
const isTextBuildFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml|map)$/i.test(filePath)
module.exports = {
isTextBuildFile,
isUsableTencentMapKey,
resolveH5BuildPolicy,
tencentMapKeyPlaceholder
}

View File

@@ -1,29 +1,36 @@
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
require('./copy-h5-nav-assets.cjs')
const {
isTextBuildFile,
resolveH5BuildPolicy,
tencentMapKeyPlaceholder
} = require('./finalize-h5-build-policy.cjs')
const projectRoot = path.resolve(__dirname, '..')
const h5Root = path.join(projectRoot, 'dist', 'build', 'h5')
const placeholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__'
const placeholder = tencentMapKeyPlaceholder
const badPatterns = [
'http://1.92.206.90:9000',
'1.92.206.90',
'guide.whaoyue.com',
placeholder
]
const args = new Set(process.argv.slice(2))
const requireTencentMapKey = args.has('--require-tencent-map-key')
const allowPlaceholder = args.has('--allow-placeholder')
const tencentMapKey = (process.env.VITE_TENCENT_MAP_KEY || '').trim()
const {
allowPlaceholder,
requireTencentMapKey,
tencentMapKey
} = resolveH5BuildPolicy({
args: process.argv.slice(2),
projectRoot
})
require('./copy-h5-nav-assets.cjs')
if (!fs.existsSync(h5Root)) {
throw new Error(`H5 build output not found: ${h5Root}`)
}
const isTextFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml)$/i.test(filePath)
const walkFiles = (dir, files = []) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const filePath = path.join(dir, entry.name)
@@ -42,16 +49,21 @@ const walkFiles = (dir, files = []) => {
}
const allFiles = walkFiles(h5Root)
const textFiles = allFiles.filter(isTextFile)
const textFiles = allFiles.filter(isTextBuildFile)
const placeholderFiles = textFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(placeholder))
const hasUsableTencentMapKey = tencentMapKey.length > 0 && tencentMapKey !== placeholder
const hasUsableTencentMapKey = tencentMapKey.length > 0
const missingTencentMapKeyMessage = [
'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.',
'Inject it through CI or an ignored .env.production.local file.'
].join(' ')
if (requireTencentMapKey && !hasUsableTencentMapKey) {
throw new Error(missingTencentMapKeyMessage)
}
if (placeholderFiles.length > 0) {
if (!hasUsableTencentMapKey) {
const message = [
'H5 build output still contains Tencent map key placeholder.',
'Please set VITE_TENCENT_MAP_KEY before production build.'
].join(' ')
const message = `H5 build output still contains Tencent map key placeholder. ${missingTencentMapKeyMessage}`
if (requireTencentMapKey && !allowPlaceholder) {
throw new Error(message)
@@ -108,8 +120,8 @@ for (const file of textFiles) {
}
if (badMatches.length > 0) {
if (allowPlaceholder && badMatches.every((item) => item.pattern === placeholder)) {
console.warn('[finalize-h5-build] Tencent map key placeholder is allowed for this build.')
if (allowPlaceholder) {
console.warn('[finalize-h5-build] Test H5 build allows non-production source hosts and the Tencent map key placeholder.')
} else {
console.error(JSON.stringify(badMatches.slice(0, 20), null, 2))
throw new Error('H5 build output contains forbidden test host, old domain, or placeholder.')

View File

@@ -43,18 +43,20 @@ const h5JavaScriptCode = h5JavaScriptFiles
.map((filePath) => readText(filePath))
.join('\n')
for (const required of ['第三方导航', 'third-party-providers']) {
assertContains(pageChunkCode, required, 'H5 首页 chunk')
for (const required of [
'第三方',
'打开地图',
'jweixin',
'miniProgram',
'/pages/open-location/index',
'baidumap://',
'iosamap://',
'androidamap://'
]) {
assertContains(h5JavaScriptCode, required, 'H5 全量 JS')
}
for (const forbidden of [
'打开地图导航',
'native-location',
'jweixin',
'wx.miniProgram',
'宿主地图',
'/pages/open-location/index'
]) {
for (const forbidden of ['native-location']) {
assertNotContains(h5JavaScriptCode, forbidden, 'H5 全量 JS')
}

View File

@@ -223,7 +223,7 @@ const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const showInternalHeader = computed(() => props.forceInternalHeader || !shouldUseHostNavigation.value)
const headerTitle = computed(() => {
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
return '展厅讲解'
return '免费讲解'
})
const loadingTitle = computed(() => {
if (props.stage === 'stop') return '正在加载讲解对象'

View File

@@ -583,17 +583,21 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
})
}
const handleFloorChange = (floor: { id: string; label: string }) => {
const requestFloorSwitch = (
floor: { id: string; label: string },
options: { force?: boolean } = {}
) => {
const floorId = floor.id
if (!floorId || loadingFloorId.value) return
if (!floorId) return Promise.resolve()
if (loadingFloorId.value === floorId) return Promise.resolve()
if (
floorId === renderedFloorId.value
!options.force && floorId === renderedFloorId.value
&& activeFloorId.value === floorId
&& props.indoorView === 'floor'
&& props.layerMode !== 'multi'
) {
emit('floorChange', floorId)
return
return Promise.resolve()
}
requestedFloorId.value = floorId
@@ -607,7 +611,7 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
floorId,
floorLabel: floor.label
})
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
return Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
})
@@ -623,6 +627,10 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
})
}
const handleFloorChange = (floor: { id: string; label: string }) => {
void requestFloorSwitch(floor)
}
const handleLayerModeChange = (mode: LayerDisplayMode) => {
// 手动切换展示层数时使用统一的短保护期。
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
@@ -789,7 +797,11 @@ defineExpose({
indoorRendererRef.value?.clearRoute?.()
},
// 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。
switchFloor: (floorId: string) => indoorRendererRef.value?.switchFloor?.(floorId),
switchFloor: (floorId: string) => {
const floor = findFloorItemById(floorId)
if (!floor) return Promise.resolve()
return requestFloorSwitch(floor, { force: true })
},
showOverview: handleShowOverview,
resetToViewBaseline: (options: {
view: 'overview' | 'floor'

View File

@@ -248,6 +248,7 @@ import type {
PoiCategoryResultState,
PoiSearchContext
} from '@/domain/poiSearch'
import { nextHomeSearchResultVersion } from './homeSearchResultVersion'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const props = withDefaults(defineProps<{
@@ -397,7 +398,8 @@ const emitResultsState = () => {
emit('results-change', {
...context,
visiblePoiIds: active ? context.visiblePoiIds : [],
active
active,
requestId: props.variant === 'home' ? nextHomeSearchResultVersion() : undefined
})
}
@@ -411,7 +413,8 @@ const emitPendingHomeSearchResults = (floor?: Pick<MuseumFloor, 'id' | 'label'>)
floorLabel: floor?.label || context.floorLabel,
visiblePoiIds: [],
active: true,
pending: true
pending: true,
requestId: nextHomeSearchResultVersion()
})
}

View File

@@ -0,0 +1,7 @@
// This version survives a PoiSearchPanel remount, unlike its local request sequence.
let latestHomeSearchResultVersion = 0
export const nextHomeSearchResultVersion = () => {
latestHomeSearchResultVersion += 1
return latestHomeSearchResultVersion
}

View File

@@ -259,7 +259,7 @@ const resume = () => {
})
}
const switchLanguage = async (lang: AudioLanguage) => {
const switchLanguage = async (lang: AudioLanguage, localAudio?: AudioItem | null) => {
if (pendingLanguage.value) {
return false
}
@@ -291,6 +291,35 @@ const switchLanguage = async (lang: AudioLanguage) => {
}
try {
if (localAudio?.audioUrl) {
currentSource.value = nextSource
return await play(localAudio, {
source: nextSource,
retryOnError: retryOnError || undefined,
displayMode: preservedMode
})
}
if (localAudio) {
stopAudioElement()
currentSource.value = nextSource
currentAudio.value = {
...localAudio,
audioUrl: '',
duration: 0,
language: lang
}
visible.value = true
displayMode.value = preservedMode
playing.value = false
loading.value = false
currentTime.value = 0
duration.value = 0
error.value = '当前语言暂无语音讲解'
showToast(error.value)
return false
}
const playInfo = await audioPlayInfoRepository.getPlayInfo({
targetType: source.targetType,
targetId: source.targetId,

View File

@@ -42,11 +42,31 @@ export interface BackendGuideStopInfo {
hasText?: boolean
supportedLanguages?: string[] | null
audioStatus?: string | null
languageVariants?: BackendGuideStopLanguageVariant[] | null
reason?: string | null
linkedExhibitCount?: number | string | null
isSharedStop?: boolean | null
}
export interface BackendGuideStopLanguageVariant {
lang?: string | null
enabled?: boolean | null
playable?: boolean | null
audioStatus?: string | null
playUrl?: string | null
duration?: number | string | null
format?: string | null
audioId?: string | number | null
narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
hasText?: boolean | null
textAvailable?: boolean | null
text?: string | null
textLength?: number | string | null
textHash?: string | null
fallback?: boolean | null
reason?: string | null
}
export interface BackendAudioPlayInfo {
playable?: boolean
targetType?: string | null
@@ -90,6 +110,25 @@ export interface GuideStopLinkedExhibit {
sortOrder?: number
}
export interface GuideStopLanguageVariant {
lang: GuideAudioLanguage
enabled: boolean
playable: boolean
audioStatus: 'READY' | 'MISSING' | string
playUrl?: string
duration?: number
format?: string
audioId?: string
narrationTier?: 'STANDARD' | 'EXTENDED'
hasText: boolean
textAvailable: boolean
text?: string
textLength?: number
textHash?: string
fallback: boolean
reason?: string
}
export interface GuideStopInfo {
available: boolean
targetType: AudioPlayTargetType
@@ -112,6 +151,7 @@ export interface GuideStopInfo {
hasAudio: boolean
hasText: boolean
supportedLanguages: GuideAudioLanguage[]
languageVariants: Record<GuideAudioLanguage, GuideStopLanguageVariant>
audioStatus: 'READY' | 'MISSING' | string
reason?: string
linkedExhibitCount?: number
@@ -177,16 +217,57 @@ export const normalizeGuideAudioLanguage = (
return 'zh-CN'
}
const isSupportedGuideAudioLanguage = (value: string | null | undefined) => (
['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(value?.trim().toLowerCase() || '')
)
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
Array.from(new Set((languages || [])
.map((language) => {
const normalized = language?.trim().toLowerCase()
if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) {
return null
}
return normalizeGuideAudioLanguage(language)
})
.filter(Boolean))) as GuideAudioLanguage[]
.filter(isSupportedGuideAudioLanguage)
.map(normalizeGuideAudioLanguage))) as GuideAudioLanguage[]
)
const normalizeLanguageVariants = (
variants: BackendGuideStopLanguageVariant[] | null | undefined
): Record<GuideAudioLanguage, GuideStopLanguageVariant> => {
const normalizedVariants = {} as Record<GuideAudioLanguage, GuideStopLanguageVariant>
;(variants || []).forEach((variant) => {
if (!isSupportedGuideAudioLanguage(variant.lang)) return
const lang = normalizeGuideAudioLanguage(variant.lang)
const playUrl = normalizeSameOriginPublicUrl(variant.playUrl) || undefined
const playable = variant.playable === true && Boolean(playUrl)
const text = variant.text?.trim() || undefined
const textAvailable = variant.textAvailable === true || Boolean(text)
normalizedVariants[lang] = {
lang,
enabled: variant.enabled === true,
playable,
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
playUrl,
duration: normalizeNumber(variant.duration),
format: variant.format?.trim() || undefined,
audioId: stringifyId(variant.audioId) || undefined,
narrationTier: normalizeNarrationTier(variant.narrationTier),
hasText: variant.hasText === true || textAvailable,
textAvailable,
text,
textLength: normalizeNumber(variant.textLength),
textHash: variant.textHash?.trim() || undefined,
fallback: variant.fallback === true,
reason: variant.reason?.trim() || undefined
}
})
return normalizedVariants
}
const supportedLanguagesFromVariants = (variants: Record<GuideAudioLanguage, GuideStopLanguageVariant>) => (
(Object.values(variants) as GuideStopLanguageVariant[])
.filter((variant) => variant.enabled && (variant.playable || variant.textAvailable))
.map((variant) => variant.lang)
)
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
@@ -255,6 +336,8 @@ export const toGuideStopInfo = (
const coverImageUrl = canUseStopImages
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
: undefined
const languageVariants = normalizeLanguageVariants(source.languageVariants)
const supportedLanguages = supportedLanguagesFromVariants(languageVariants)
return {
available: source.available === true,
@@ -277,7 +360,10 @@ export const toGuideStopInfo = (
playTargetId,
hasAudio: source.hasAudio === true,
hasText: source.hasText === true,
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
supportedLanguages: supportedLanguages.length
? supportedLanguages
: normalizeSupportedLanguages(source.supportedLanguages),
languageVariants,
audioStatus: source.audioStatus || 'MISSING',
reason: source.reason || undefined,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),

View File

@@ -10,6 +10,7 @@ import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isVisitorRestrictedPlaceName,
normalizePoiSemanticValue
} from '@/domain/poiCategories'
@@ -193,6 +194,16 @@ const isPoiAccessible = (poi: StaticNavPoiPayload) => (
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
)
const resolveStaticPoiVisitorVisible = (
poi: StaticNavPoiPayload,
semanticType: string
) => (
typeof poi.visitorVisible === 'boolean'
? poi.visitorVisible
: semanticType !== 'service_space'
&& ![poi.name, poi.sourceObjectName].some(isVisitorRestrictedPlaceName)
)
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
const categoryFallbackIconType = poi.categories?.[0]?.iconType
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
@@ -214,6 +225,7 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness,
visitorVisible: resolveStaticPoiVisitorVisible(poi, iconType),
accessible: isPoiAccessible(poi),
kind,
hallName: kind === 'hall' ? poi.name : undefined

View File

@@ -19,6 +19,7 @@ import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isVisitorRestrictedPlaceName,
isPoiSearchCategorySupported,
normalizePoiSemanticValue
} from '@/domain/poiCategories'
@@ -146,6 +147,7 @@ const spaceCategoryBySgsType: Record<string, MuseumCategory> = {
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
exhibition_hall: hallCategory,
theater: spaceCategoryBySgsType.theater,
service_space: spaceCategoryBySgsType.service_space,
commercial: spaceCategoryBySgsType.commercial,
restaurant: spaceCategoryBySgsType.restaurant,
cafe: spaceCategoryBySgsType.cafe,
@@ -414,6 +416,25 @@ const categoryForBusinessType = (businessType?: string | null): MuseumCategory =
}
}
const resolveSgsPoiVisitorVisible = (
source: {
visitorVisible?: boolean | null
name?: string | null
sourceNodeName?: string | null
anchorNodeName?: string | null
},
category: MuseumCategory
) => (
typeof source.visitorVisible === 'boolean'
? source.visitorVisible
: category.id !== 'space_service'
&& ![
source.name,
source.sourceNodeName,
source.anchorNodeName
].some(isVisitorRestrictedPlaceName)
)
interface SgsHallPoiBuildOptions {
fallbackY?: number
}
@@ -661,6 +682,7 @@ export const toMuseumPoiFromSgs = (
sourceObjectName: poi.anchorNodeName || undefined,
sourceConfidence: 'backend-sgs-sdk',
navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(poi, category),
accessible: category.accessible === true,
kind,
hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
@@ -825,6 +847,7 @@ export const toMuseumSpacePointFromSgs = (
sourceObjectName: space.sourceNodeName || undefined,
sourceConfidence: spacePosition.sourceConfidence,
navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(space, category),
accessible: false,
kind: 'space',
hallId: category.id === hallCategory.id ? spaceId : undefined,
@@ -896,6 +919,7 @@ const createSpaceFallbackHallPoi = (
sourceObjectName: space.sourceNodeName || undefined,
sourceConfidence: spacePosition.sourceConfidence,
navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(space, category),
accessible: false,
kind: 'hall',
hallId,
@@ -983,6 +1007,7 @@ export const toMuseumHallPoisFromSgs = (
? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence
: sgsHallEntranceConfidence,
navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(matchedSpace, category),
accessible: false,
kind: 'hall',
hallId,

View File

@@ -65,6 +65,7 @@ export interface SgsPoiPayload {
y?: number | null
z?: number | null
status?: string | null
visitorVisible?: boolean | null
anchorNodeName?: string | null
description?: string | null
iconUrl?: string | null
@@ -87,6 +88,7 @@ export interface SgsSpacePayload {
center?: SgsPositionPayload | null
sourceNodeName?: string | null
status?: string | null
visitorVisible?: boolean | null
colorHex?: string | null
}

View File

@@ -27,6 +27,7 @@ export interface StaticNavPoiPayload {
sourceObjectName?: string
navigationReadiness?: string
sourceConfidence?: string
visitorVisible?: boolean | null
}
export interface StaticNavManifestFloorModelPayload {

View File

@@ -96,6 +96,8 @@ export interface MuseumPoi {
sourceObjectName?: string
sourceConfidence?: string
navigationReadiness?: string
/** Explicit visitor-search eligibility supplied by the source or adapter policy. */
visitorVisible?: boolean
accessible: boolean
kind?: MuseumPoiKind
hallId?: string
@@ -180,10 +182,21 @@ export interface MuseumExhibit {
audioVariants?: Record<string, {
title?: string
text?: string
textAvailable?: boolean
textLength?: number
textHash?: string
audioUrl?: string
audioDuration?: number
format?: string
audioId?: string
narrationTier?: 'STANDARD' | 'EXTENDED'
hasText?: boolean
enabled?: boolean
playable?: boolean
available?: boolean
audioStatus?: string
fallback?: boolean
reason?: string
}>
imageStatus?: string
imageSource?: string

View File

@@ -183,6 +183,7 @@ export type PoiCategorySource = Pick<
| 'sourcePlaceId'
| 'sourceSpaceId'
| 'sourceObjectName'
| 'visitorVisible'
>
const normalizeValue = (value?: string | null) => (value || '')
@@ -192,6 +193,13 @@ const normalizeValue = (value?: string | null) => (value || '')
.replace(/[\s-]+/g, '_')
.replace(/^_+|_+$/g, '')
const visitorRestrictedPlaceNamePattern = /(?:贵宾|vip|员工|职工|后勤|办公|行政|库房|仓库|机房|设备间|配电|弱电|强电|保洁|值班|消防控制|监控室|staff|employee|back[_ -]?of[_ -]?house|maintenance)/i
/** Legacy sources without an explicit flag must not expose staff-only places. */
export const isVisitorRestrictedPlaceName = (value?: string | null) => (
visitorRestrictedPlaceNamePattern.test(normalizeValue(value))
)
const poiSemanticAliases: Readonly<Record<string, string>> = {
exhibition: 'exhibition_hall',
exhibition_hall: 'exhibition_hall',
@@ -397,7 +405,7 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
)
const hiddenVisitorPrimaryTypes = new Set([
const hiddenVisitorTypes = new Set([
'entrance_exit',
'hall_entrance',
'entrance_anchor',
@@ -406,7 +414,7 @@ const hiddenVisitorPrimaryTypes = new Set([
'operation_experience'
])
const primaryTypeValues = (poi: PoiCategorySource) => [
const visitorTypeValues = (poi: PoiCategorySource) => [
poi.primaryCategory.id,
poi.primaryCategory.label,
poi.primaryCategory.iconType || ''
@@ -414,10 +422,18 @@ const primaryTypeValues = (poi: PoiCategorySource) => [
.map(normalizePoiSemanticValue)
.filter(Boolean)
const isHiddenVisitorType = (value: string) => {
const normalizedValue = normalizePoiSemanticValue(value)
const sourceWrappedValue = normalizedValue.replace(/^(?:space|poi|facility|business)_/, '')
return hiddenVisitorTypes.has(normalizedValue)
|| hiddenVisitorTypes.has(sourceWrappedValue)
}
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
if (poi.visitorVisible === false) return false
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value))
return !visitorTypeValues(poi).some(isHiddenVisitorType)
}
/** Default floor browse only contains canonical halls and valid destination spaces. */
@@ -516,8 +532,6 @@ export const getPoiDataIssues = (poi: MuseumPoi): PoiDataIssue[] => {
} else if (!hasFinitePosition(poi.positionGltf)) {
issues.push({ code: 'invalid-position', ...issueBase })
}
if (!isPoiSearchCategorySupported(poi)) issues.push({ code: 'unsupported-category', ...issueBase })
return issues
}
@@ -548,6 +562,6 @@ export const warnPoiCollectionIssues = (source: string, pois: MuseumPoi[]) => {
const inspection = inspectPoiCollection(pois)
if (!inspection.issues.length && !inspection.duplicateIds.length) return
// 开发期集中输出数据契约问题,避免在页面组件中散落源数据校验。
console.warn(`[POI 数据校验] ${source}`, inspection)
// This is an aggregate development diagnostic, not a visitor-facing failure.
console.debug(`[POI 数据诊断] ${source}`, inspection)
}

View File

@@ -28,6 +28,8 @@ export interface PoiSearchSelection {
export interface PoiCategoryResultState extends PoiSearchContext {
active: boolean
pending?: boolean
/** Monotonic home-result event version used to reject stale cross-component state. */
requestId?: number
}
export type GuidePoiSearchMode = 'default' | 'category' | 'keyword'

View File

@@ -25,7 +25,7 @@
{
"path": "pages/explain/list",
"style": {
"navigationBarTitleText": "全部讲解",
"navigationBarTitleText": "免费讲解",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom"
}

View File

@@ -160,7 +160,8 @@ import {
type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels'
import type {
AudioPlayTargetType
AudioPlayTargetType,
MuseumExhibit
} from '@/domain/museum'
import type {
AudioLanguage
@@ -193,6 +194,7 @@ const defaultDetail: ExplainDetailPageViewModel = {
}
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
const detailSource = ref<MuseumExhibit | null>(null)
const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle')
const detailStateMessage = ref('请检查链接后重试。')
const globalAudioPlayer = useGlobalAudioPlayer()
@@ -201,7 +203,6 @@ const resolvedHallId = ref('')
const retryingAudio = ref(false)
const languageSwitchLoading = ref(false)
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
let languageSwitchSequence = 0
const detailEntryRequest = ref<{
exhibitId: string
targetType?: AudioPlayTargetType
@@ -217,16 +218,6 @@ const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
'yue-HK': '当前语言暂无讲解词。',
'en-US': 'English narration text is not available yet.'
})
const detailTextLoadingByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'yue-HK': false,
'en-US': false
})
const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'yue-HK': false,
'en-US': false
})
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
const isLocationActionVisible = false
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
@@ -416,63 +407,22 @@ const applyDetailText = (lang: AudioLanguage, text?: string) => {
...detailTextByLanguage.value,
[lang]: normalized || fallbackTextForLanguage(lang)
}
detailTextLoadedByLanguage.value = {
...detailTextLoadedByLanguage.value,
[lang]: true
}
}
const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({
id: viewModel.id,
name: viewModel.title,
hallId: viewModel.hallId,
hallName: viewModel.hallName,
floorId: viewModel.floorId,
floorLabel: viewModel.floorLabel,
image: viewModel.coverImages[0],
description: viewModel.summary,
guideText: viewModel.summary,
audioLanguage: viewModel.audio.language,
audioHasText: viewModel.audio.hasText,
playTargetType: viewModel.audio.playTargetType,
playTargetId: viewModel.audio.playTargetId
})
const loadFullDetailTextForLanguage = async (
request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage,
preferredViewModel?: ExplainDetailPageViewModel
) => {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: true
const hydrateDetailTexts = (source: MuseumExhibit) => {
const nextTexts: Record<AudioLanguage, string> = {
'zh-CN': fallbackTextForLanguage('zh-CN'),
'yue-HK': fallbackTextForLanguage('yue-HK'),
'en-US': fallbackTextForLanguage('en-US')
}
try {
const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({
...request,
lang
}))
;(['zh-CN', 'yue-HK', 'en-US'] as AudioLanguage[]).forEach((lang) => {
const variant = source.audioVariants?.[lang]
const text = variant?.text || (source.audioLanguage === lang ? source.guideText : undefined)
nextTexts[lang] = text?.trim() || fallbackTextForLanguage(lang)
})
if (viewModel.audio.hasText) {
const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel))
if (selection.available) {
const nextViewModel = toExplainDetailPageViewModel(selection.exhibit)
applyDetailText(lang, nextViewModel.body || nextViewModel.summary)
return
}
}
applyDetailText(lang, viewModel.body || viewModel.summary)
} catch (error) {
console.warn('讲解正文加载失败:', lang, error)
applyDetailText(lang)
} finally {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: false
}
}
detailTextByLanguage.value = nextTexts
}
const loadExplainDetail = async (
@@ -485,6 +435,8 @@ const loadExplainDetail = async (
lang
})
detailSource.value = exhibitData
hydrateDetailTexts(exhibitData)
exhibit.value = toExplainDetailPageViewModel(exhibitData)
const resolvedLanguage = resolveSupportedDetailLanguage(lang)
selectedAudioLanguage.value = resolvedLanguage
@@ -494,12 +446,13 @@ const loadExplainDetail = async (
}
if (resolvedLanguage !== lang) {
const localizedExhibit = explainUseCase.selectExplainDetailLanguage(exhibitData, resolvedLanguage)
detailSource.value = localizedExhibit
exhibit.value = toExplainDetailPageViewModel(localizedExhibit)
selectedAudioLanguage.value = resolvedLanguage
replaceDetailRouteLanguage(resolvedLanguage)
await loadExplainDetail(request, resolvedLanguage)
return
}
void loadFullDetailTextForLanguage(request, resolvedLanguage, exhibit.value)
detailState.value = 'ready'
}
@@ -618,9 +571,7 @@ const splitDetailTextIntoParagraphs = (text: string): string[] => {
const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value))
const currentDetailTextLoading = computed(() => (
languageSwitchLoading.value || detailTextLoadingByLanguage.value[selectedAudioLanguage.value]
))
const currentDetailTextLoading = computed(() => languageSwitchLoading.value)
const detailLoadingMessage = computed(() => (
languageSwitchLoading.value
? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}`
@@ -648,62 +599,40 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?:
|| (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang))
) return
const source = detailSource.value
if (!source) return
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
const request = detailEntryRequest.value
const requestSequence = ++languageSwitchSequence
const localizedExhibit = explainUseCase.selectExplainDetailLanguage(source, lang)
const localizedViewModel = toExplainDetailPageViewModel(localizedExhibit)
selectedAudioLanguage.value = lang
detailSource.value = localizedExhibit
exhibit.value = localizedViewModel
applyDetailText(lang, localizedExhibit.audioText || localizedExhibit.guideText || localizedViewModel.body)
replaceDetailRouteLanguage(lang)
if (localizedExhibit.hallId) {
resolvedHallId.value = localizedExhibit.hallId
}
if (!shouldSyncAudio || globalAudioPlayer.currentSource.value?.lang === lang) return
languageSwitchLoading.value = true
const audioSwitch = shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang
? globalAudioPlayer.switchLanguage(lang)
: Promise.resolve(true)
try {
if (!request) {
applyDetailText(lang)
await audioSwitch
return
}
const exhibitData = await explainUseCase.enterExplainDetail({
...request,
lang
})
if (requestSequence !== languageSwitchSequence) return
const nextViewModel = toExplainDetailPageViewModel(exhibitData)
exhibit.value = nextViewModel
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
void loadFullDetailTextForLanguage(request, lang, nextViewModel)
await audioSwitch
} catch (error) {
if (requestSequence !== languageSwitchSequence) return
console.warn('讲解语言切换失败:', lang, error)
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
status: 'unavailable',
url: undefined,
duration: undefined,
const nextAudio = localizedViewModel.audio.url
? {
id: `stop-${localizedViewModel.audio.playTargetType || 'ITEM'}-${localizedViewModel.audio.playTargetId || localizedViewModel.id}-${lang}`,
name: localizedViewModel.title,
audioUrl: localizedViewModel.audio.url,
image: heroImage.value,
duration: localizedViewModel.audio.duration,
language: lang,
unavailableReason: '讲解服务暂不可用,请稍后重试'
supportedLanguages: supportedDetailLanguages.value
}
}
applyDetailText(lang)
uni.showToast({
title: '讲解语言切换失败,请稍后重试',
icon: 'none'
})
: null
await globalAudioPlayer.switchLanguage(lang, nextAudio)
} finally {
if (requestSequence === languageSwitchSequence) {
languageSwitchLoading.value = false
}
languageSwitchLoading.value = false
}
}

View File

@@ -65,6 +65,21 @@ const syncPageTitle = (title: string) => {
if (typeof document !== 'undefined') document.title = title
}
const decodeRouteParam = (value: string) => {
let decoded = value
// H5 hash routes embedded in web-view can add multiple URL-encoding layers.
for (let index = 0; index < 8; index += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) break
decoded = next
} catch {
break
}
}
return decoded
}
const loadPage = async (pageNo: number, replace: boolean) => {
const hallId = selectedExplainHallId.value
if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return false
@@ -115,8 +130,8 @@ const loadNextPage = () => void loadPage(nextPageNo.value, false)
onLoad((options: Record<string, string | string[] | undefined> = {}) => {
const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value
selectedExplainHallId.value = first(options.hallId) || ''
selectedExplainHallName.value = first(options.hallName) || '讲解对象'
syncPageTitle('讲解对象')
selectedExplainHallName.value = decodeRouteParam(first(options.hallName) || '讲解对象')
syncPageTitle(selectedExplainHallName.value)
if (!selectedExplainHallId.value) {
explainError.value = '缺少展厅参数,请返回展厅列表后重试'
return

View File

@@ -10,7 +10,7 @@
<ExplainHallSelect
:halls="explainHallItems"
stage="hall"
force-internal-header
:force-internal-header="!shouldUseHostNavigation"
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"

View File

@@ -373,6 +373,8 @@ const requestedFloorId = ref('')
const requestedFloorLabel = ref('')
const loadingFloorId = ref('')
const renderedFloorId = ref('')
// Only GuideMapShell's floor-change confirms that the Three.js floor is usable.
const committedFloorId = ref('')
const failedFloorId = ref('')
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
const selectedGuidePoiDetailTarget = ref<PoiDetailTarget | null>(null)
@@ -432,6 +434,8 @@ const homeSearchExpanded = ref(false)
const homeCategoryModeActive = ref(false)
const searchVisiblePoiIds = ref<string[] | null>(null)
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
const homeSearchMapState = ref<PoiCategoryResultState | null>(null)
let latestHomeSearchMapRequestId = 0
type PoiSearchOverlayHistoryState = {
museumGuideOverlay?: 'poi-search'
[key: string]: unknown
@@ -853,6 +857,9 @@ const handleTabChange = (tabId: GuideTopTab) => {
}
}
if (tabId !== currentTab.value) {
invalidateHomeSearchFloorCommit()
}
currentTab.value = tabId
syncTopTabToUrl(tabId)
loadTopTabData(tabId)
@@ -875,6 +882,7 @@ const syncTopTabFromHash = () => {
currentTab.value = tab
loadTopTabData(tab)
if (tabChanged) {
invalidateHomeSearchFloorCommit()
showRoutePlanner.value = false
isSimulatingRoute.value = false
isPoiCardCollapsed.value = false
@@ -1000,11 +1008,19 @@ const handleFloorRequest = ({ floorId, floorLabel }: { floorId: string; floorLab
requestedFloorLabel.value = floorLabel
loadingFloorId.value = floorId
failedFloorId.value = ''
if (homeSearchMapState.value?.active) {
searchVisiblePoiIds.value = []
}
}
const handleFloorChange = (floorId: string) => {
activeGuideFloor.value = floorId
renderedFloorId.value = floorId
committedFloorId.value = floorId
if (requestedFloorId.value === floorId) {
requestedFloorId.value = ''
requestedFloorLabel.value = ''
}
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
@@ -1014,6 +1030,7 @@ const handleFloorChange = (floorId: string) => {
indoorView.value = 'floor'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
syncHomeSearchMarkersForCommittedFloor(floorId)
showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指平移、双指可缩放`, 3200)
console.log('楼层渲染完成:', floorId)
}
@@ -1023,6 +1040,13 @@ const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; flo
loadingFloorId.value = ''
}
failedFloorId.value = floorId
if (requestedFloorId.value === floorId) {
requestedFloorId.value = ''
requestedFloorLabel.value = ''
}
if (homeSearchMapState.value?.floorId === floorId) {
searchVisiblePoiIds.value = []
}
showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600)
console.warn('楼层渲染失败:', floorId)
}
@@ -1030,6 +1054,8 @@ const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; flo
const handleIndoorViewChange = (view: GuideIndoorView) => {
indoorView.value = view
if (view !== 'floor') {
committedFloorId.value = ''
syncHomeSearchMarkersForCommittedFloor()
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
}
@@ -1045,6 +1071,8 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
console.log('自动切换视图:', event)
indoorView.value = event.to
if (event.to !== 'floor') {
committedFloorId.value = ''
syncHomeSearchMarkersForCommittedFloor()
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
}
@@ -1053,6 +1081,11 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
const readyFloorId = event.floorId || activeGuideFloor.value
indoorView.value = event.view
if (event.view !== 'floor') {
committedFloorId.value = ''
syncHomeSearchMarkersForCommittedFloor()
}
if (event.view === 'overview' && readyFloorId) {
activeGuideFloor.value = readyFloorId
renderedFloorId.value = readyFloorId
@@ -1092,6 +1125,7 @@ const handleGuidePoiClick = (poi: GuideRenderPoi) => {
isSimulatingRoute.value = false
activeGuideFloor.value = poi.floorId
renderedFloorId.value = poi.floorId
committedFloorId.value = poi.floorId
showIndoorHint(`${poi.name} · ${getGuideFloorLabel(poi.floorId)}`, 3000)
}
@@ -1519,6 +1553,7 @@ const resetGuideModelToFloorBaseline = async ({
if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return
if (resetResult === 'applied') {
renderedFloorId.value = activeGuideFloor.value
committedFloorId.value = activeGuideFloor.value
guideModelState.completeViewBaselineReset(resetRequest)
console.info('馆内三维模型已恢复楼层视觉基线:', { rendererReason })
} else if (resetResult === 'not-ready' || resetResult === undefined) {
@@ -1589,12 +1624,20 @@ const clearHomeSearchMapState = () => {
homeCategoryModeActive.value = false
searchVisiblePoiIds.value = null
searchTargetFocusRequest.value = null
homeSearchMapState.value = null
}
const invalidateHomeSearchFloorCommit = () => {
clearHomeSearchMapState()
committedFloorId.value = ''
indoorView.value = 'overview'
}
const handleHomeCategoryModeChange = (active: boolean) => {
homeCategoryModeActive.value = active
if (!active) {
searchVisiblePoiIds.value = null
homeSearchMapState.value = null
return
}
@@ -1604,19 +1647,72 @@ const handleHomeCategoryModeChange = (active: boolean) => {
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
is3DMode.value = true
indoorView.value = 'floor'
searchVisiblePoiIds.value = []
}
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
if (!state.active) {
searchVisiblePoiIds.value = null
const isHomeSearchFloorCommitted = (floorId: string) => (
Boolean(floorId)
&& committedFloorId.value === floorId
&& indoorView.value === 'floor'
)
const requestHomeSearchFloor = (floorId: string, floorLabel = '') => {
if (!floorId || requestedFloorId.value === floorId || loadingFloorId.value === floorId) return
requestedFloorId.value = floorId
requestedFloorLabel.value = floorLabel || getGuideFloorLabel(floorId)
loadingFloorId.value = floorId
failedFloorId.value = ''
void Promise.resolve(guideMapShellRef.value?.switchFloor?.(floorId))
.catch((error) => {
console.error('快捷入口楼层切换失败:', error)
})
}
const syncHomeSearchMarkersForCommittedFloor = (floorId = committedFloorId.value) => {
const state = homeSearchMapState.value
if (
!state?.active
|| state.pending
|| state.floorId !== floorId
|| indoorView.value !== 'floor'
) {
searchVisiblePoiIds.value = state?.active ? [] : null
return
}
searchVisiblePoiIds.value = [...state.visiblePoiIds]
if (state.floorId && !state.pending) {
activeGuideFloor.value = state.floorId
}
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
if (
typeof state.requestId === 'number'
&& state.requestId < latestHomeSearchMapRequestId
) return
if (typeof state.requestId === 'number') {
latestHomeSearchMapRequestId = state.requestId
}
if (!state.active) {
clearHomeSearchMapState()
return
}
homeSearchMapState.value = {
...state,
visiblePoiIds: [...state.visiblePoiIds]
}
if (state.pending) {
searchVisiblePoiIds.value = []
}
if (!isHomeSearchFloorCommitted(state.floorId)) {
searchVisiblePoiIds.value = []
requestHomeSearchFloor(state.floorId, state.floorLabel)
return
}
syncHomeSearchMarkersForCommittedFloor(state.floorId)
}
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
@@ -1672,6 +1768,7 @@ const handleExplainHallClick = async (hallId: string) => {
}
const handleExplainBack = () => {
invalidateHomeSearchFloorCommit()
currentTab.value = 'guide'
syncTopTabToUrl('guide')
loadTopTabData('guide')

View File

@@ -72,6 +72,7 @@ const reasonMessageMap: Record<string, string> = {
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
NO_TEXT: '当前语言暂无讲解词',
LANGUAGE_DISABLED: '该语言暂未开放',
UNSUPPORTED_LANGUAGE: '不支持该语言',
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',

View File

@@ -22,10 +22,7 @@ import {
} from '@/domain/guideFloor'
import {
buildSgsFloorAliases,
createSgsHallPoiDiagnostics,
formatSgsFloorLabel,
toMuseumHallPoisFromSgs,
toMuseumPoiFromSgs
formatSgsFloorLabel
} from '@/data/adapters/sgsSdkGuideAdapter'
import {
defaultSgsSdkApiProvider,
@@ -47,6 +44,9 @@ import {
import type {
GuideRepository
} from '@/repositories/GuideRepository'
import {
canonicalizeVisitorSearchPois
} from '@/repositories/GuideRepository'
import {
guideRepository
} from '@/repositories/createGuideRepository'
@@ -84,37 +84,6 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
})
})
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => {
// A facility can be located inside the same spatial area as other facilities.
// That relationship must not collapse distinct visitor markers (for example,
// a restroom and an elevator in one service zone). Space identity is only a
// canonical-place key for halls, spaces, and business-place representations.
const canDedupeBySpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| poi.primaryCategory === 'business_poi'
const stableKeys = [
poi.id ? `id:${poi.id}` : '',
canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : ''
].filter(Boolean)
if (stableKeys.length) return stableKeys
return poi.sourceObjectName ? [`object:${poi.floorId}:${poi.sourceObjectName}`] : []
}
const dedupeRenderPoisById = (pois: GuideRenderPoi[]) => {
const seen = new Set<string>()
return pois.filter((poi) => {
const keys = getRenderPoiDedupeKeys(poi)
if (!keys.length || keys.some((key) => seen.has(key))) return false
keys.forEach((key) => seen.add(key))
return true
})
}
const countByValue = <T>(
items: T[],
selector: (item: T) => string | number | null | undefined
@@ -124,17 +93,6 @@ const countByValue = <T>(
return counts
}, {})
const countDroppedRenderPoiCategories = (
sourcePois: GuideRenderPoi[],
keptPois: GuideRenderPoi[]
) => {
const keptIds = new Set(keptPois.map((poi) => poi.id))
return countByValue(
sourcePois.filter((poi) => !keptIds.has(poi.id)),
(poi) => poi.primaryCategory
)
}
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
const resolveSgsAssetUrl = (url?: string | null) => {
@@ -234,20 +192,6 @@ const summarizeYValues = (pois: GuideRenderPoi[]) => {
}
}
const getMedianPoiY = (pois: GuideRenderPoi[]) => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) return undefined
const middle = Math.floor(values.length / 2)
return values.length % 2
? values[middle]
: (values[middle - 1] + values[middle]) / 2
}
const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => (
[
String(floor.floorId),
@@ -442,7 +386,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
const resolvedFloorId = String(matchedFloor.floorId)
const loadFloorData = async <T>(
endpoint: 'pois' | 'spaces' | 'navigablePlaces' | 'guidePois' | 'guideSpacePoints',
endpoint: 'guideSearch',
loader: () => Promise<T[]>
) => {
try {
@@ -457,88 +401,36 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
}
}
const [pois, spaces, navigablePlaces, guidePois, guideSpacePoints] = await Promise.all([
loadFloorData('pois', () => this.provider.getFloorPois(resolvedFloorId)),
loadFloorData('spaces', () => this.provider.getFloorSpaces(resolvedFloorId)),
loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId)),
loadFloorData('guidePois', () => this.guide.listPois()),
loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints())
])
const ordinaryPois = pois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const repositoryBusinessPois = guidePois
.filter((poi) => (
poi.floorId === resolvedFloorId
&& poi.primaryCategory.id === 'business_poi'
&& isVisitorSearchPoi(poi)
))
const visitorSearchPois = await loadFloorData(
'guideSearch',
() => this.guide.searchPois('', resolvedFloorId)
)
const canonicalPois = canonicalizeVisitorSearchPois(visitorSearchPois)
const renderPois = canonicalPois
.filter((poi) => poi.floorId === resolvedFloorId)
.map(toGuideRenderPoi)
const repositorySpacePois = guideSpacePoints
.filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi))
.map(toGuideRenderPoi)
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
fallbackY: floorPoiMedianY
})
const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois)
if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) {
warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', {
floorId: resolvedFloorId,
...hallDiagnostics
})
}
const hallPois = museumHallPois
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const adaptedPois = dedupeRenderPoisById([
...hallPois,
...ordinaryPois,
...repositorySpacePois,
...repositoryBusinessPois
])
const floorMatchedPois = adaptedPois.filter((poi) => poi.floorId === resolvedFloorId)
const renderPois = floorMatchedPois
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
logSgsGuideModelDiagnostics('floor POI category diagnostics', {
floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode,
rawPoiCount: pois.length,
rawRepositoryBusinessPoiCount: repositoryBusinessPois.length,
rawRepositorySpacePoiCount: repositorySpacePois.length,
rawPoiTypeCounts: countByValue(pois, (poi) => poi.type),
rawPoiGroupCounts: countByValue(pois, (poi) => poi.poiGroup),
adaptedPoiCount: adaptedPois.length,
adaptedCategoryCounts: countByValue(adaptedPois, (poi) => poi.primaryCategory),
adaptedPoiCategoryCount: adaptedPois.filter((poi) => poi.primaryCategory === 'poi').length,
canonicalPoiCount: canonicalPois.length,
canonicalCategoryCounts: countByValue(canonicalPois, (poi) => poi.primaryCategory.id),
canonicalPoiCategoryCount: canonicalPois.filter((poi) => poi.primaryCategory.id === 'poi').length,
renderPoiCount: renderPois.length,
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length,
repositoryDroppedCategoryCounts: countDroppedRenderPoiCategories(adaptedPois, renderPois)
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length
})
warnSgsGuideModelDiagnostics('floor render POI diagnostics', {
logSgsGuideModelDiagnostics('floor render POI diagnostics', {
floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode,
poiCount: renderPois.length,
hallPoiCount: renderHallPois.length,
hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length,
poiY: summarizeYValues(renderPois),
hallPoiY: summarizeYValues(renderHallPois),
fallbackHallY: floorPoiMedianY ?? null
hallPoiY: summarizeYValues(renderHallPois)
})
if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderHallPois.length) {
warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', {
floorId: resolvedFloorId,
...hallDiagnostics,
renderPoiCount: renderPois.length
})
}
return renderPois
}
}

View File

@@ -159,6 +159,17 @@ const isVisitorSearchResult = (poi: MuseumPoi) => (
&& hasRenderablePosition(poi)
)
// Explicitly hidden source records must still participate in canonical-place
// merging so a private space cannot leak through an unflagged POI duplicate.
const isCanonicalSearchCandidate = (poi: MuseumPoi) => (
isPoiOnIndoorNavigableFloor(poi)
&& hasRenderablePosition(poi)
&& isVisitorSearchPoi({
...poi,
visitorVisible: true
})
)
const normalizePlaceName = (value: string) => value
.trim()
.normalize('NFKC')
@@ -188,6 +199,11 @@ const canonicalMergeKeys = (poi: MuseumPoi) => {
keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`)
}
const sourcePlaceId = poi.sourcePlaceId?.trim()
if (sourcePlaceId) {
keys.push(`place:${poi.floorId}:${categoryId || 'other'}:${sourcePlaceId}`)
}
const normalizedName = normalizePlaceName(poi.name)
const location = positionKey(poi)
// A space, regular POI, and business POI can describe the same visitor
@@ -246,7 +262,10 @@ const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
spaceId: primary.spaceId || secondary.spaceId,
sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId,
sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId,
entrances: primary.entrances || secondary.entrances
entrances: primary.entrances || secondary.entrances,
visitorVisible: primary.visitorVisible === false || secondary.visitorVisible === false
? false
: primary.visitorVisible ?? secondary.visitorVisible
}
}
@@ -254,11 +273,11 @@ const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
* Search queries return a single map-renderable representation of each real
* visitor place. Source collections remain intact for the renderer itself.
*/
const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
export const canonicalizeVisitorSearchPois = (pois: MuseumPoi[]) => {
const canonicalByKey = new Map<string, MuseumPoi>()
pois
.filter(isVisitorSearchResult)
.filter(isCanonicalSearchCandidate)
.forEach((poi) => {
const keys = canonicalMergeKeys(poi)
if (!keys.length) keys.push(`poi:${poi.id}`)
@@ -282,10 +301,11 @@ const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
})
return dedupePoisById(Array.from(canonicalByKey.values()))
.filter(isVisitorSearchResult)
}
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => (
mergeCanonicalSearchPois([
canonicalizeVisitorSearchPois([
...pois,
...spacePoints
])
@@ -426,7 +446,7 @@ export class StaticGuideRepository implements GuideRepository {
this.listPois(),
this.listSpacePoints()
])
this.visitorSearchPoolCache = mergeCanonicalSearchPois([
this.visitorSearchPoolCache = canonicalizeVisitorSearchPois([
...pois,
...spacePoints
])
@@ -470,8 +490,8 @@ export class StaticGuideRepository implements GuideRepository {
}
async getPoiById(id: string) {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id)
const visitorPois = (await this.listPois()).filter(isVisitorSearchPoi)
return visitorPois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(visitorPois, id)
}
async searchPois(keyword = '', floorId?: string) {
@@ -735,7 +755,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
sourceFailures.push(error)
}
const searchPool = mergeCanonicalSearchPois([
const searchPool = canonicalizeVisitorSearchPois([
...pois,
...spacePoints
])
@@ -796,6 +816,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
])
const direct = directPois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.find((poi) => poi.id === normalizedId || getPoiSourceLookupIdentity(normalizedId) === poi.spaceId || getPoiSourceLookupIdentity(normalizedId) === poi.sourceSpaceId)
if (direct) return direct
} catch (error) {

View File

@@ -183,21 +183,29 @@ export class ExplainUseCase {
}
}
const playable = (variant.playable ?? variant.available) === true && Boolean(variant.audioUrl)
const textAvailable = variant.textAvailable ?? (variant.hasText === true || Boolean(variant.text))
const guideText = variant.text
|| (language === 'yue-HK' ? mandarinText : undefined)
|| `${this.languageLabel(language)}讲解词暂未配置。`
return {
...exhibit,
guideTitle: variant.title || exhibit.guideTitle,
guideText: language === 'yue-HK'
? mandarinText || '当前讲解词暂未配置。'
: variant.text || `${this.languageLabel(language)}讲解词暂未配置。`,
audioUrl: variant.audioUrl,
guideText,
audioUrl: playable ? variant.audioUrl : undefined,
audioDuration: variant.audioDuration,
audioLanguage: language,
audioHasText: language === 'yue-HK' ? Boolean(mandarinText) : variant.hasText === true,
audioAvailable: variant.available === true && Boolean(variant.audioUrl),
audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING',
audioUnavailableReason: variant.available && variant.audioUrl
audioHasText: textAvailable,
audioText: variant.text || undefined,
audioTextLength: variant.textLength,
audioTextHash: variant.textHash,
audioNarrationTier: variant.narrationTier,
audioAvailable: playable,
audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
audioUnavailableReason: playable
? undefined
: `当前讲解暂无${this.languageLabel(language)}音频`,
: audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'),
supportedLanguages
}
}
@@ -213,7 +221,27 @@ export class ExplainUseCase {
: undefined
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
const audioAvailable = stopInfo.audioStatus === 'READY'
const audioVariants = Object.fromEntries(Object.values(stopInfo.languageVariants).map((variant) => [variant.lang, {
title: stopInfo.title,
text: variant.text,
textAvailable: variant.textAvailable,
textLength: variant.textLength,
textHash: variant.textHash,
audioUrl: variant.playUrl,
audioDuration: variant.duration,
format: variant.format,
audioId: variant.audioId,
narrationTier: variant.narrationTier,
hasText: variant.hasText,
enabled: variant.enabled,
playable: variant.playable,
available: variant.playable,
audioStatus: variant.audioStatus,
fallback: variant.fallback,
reason: variant.reason
}]))
const currentVariant = audioVariants[stopInfo.lang]
const audioAvailable = currentVariant?.playable === true && Boolean(currentVariant.audioUrl)
return {
...(fallback || {}),
@@ -235,21 +263,22 @@ export class ExplainUseCase {
size: fallback?.size,
tags: fallback?.tags,
guideTitle: stopInfo.title || fallback?.guideTitle,
guideText: stopInfo.description || fallback?.guideText || fallback?.description,
audioUrl: undefined,
audioDuration: undefined,
guideText: currentVariant?.text || stopInfo.description || fallback?.guideText || fallback?.description,
audioUrl: audioAvailable ? currentVariant?.audioUrl : undefined,
audioDuration: currentVariant?.audioDuration,
audioLanguage: stopInfo.lang,
audioHasText: stopInfo.hasText,
audioText: undefined,
audioTextLength: undefined,
audioTextHash: undefined,
audioNarrationTier: undefined,
audioHasText: currentVariant?.textAvailable || stopInfo.hasText,
audioText: currentVariant?.text,
audioTextLength: currentVariant?.textLength,
audioTextHash: currentVariant?.textHash,
audioNarrationTier: currentVariant?.narrationTier,
audioUnavailableReason: audioAvailable
? undefined
: audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
: audioReasonToText(currentVariant?.reason || stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)),
audioAvailable,
audioStatus: stopInfo.audioStatus,
audioStatus: currentVariant?.audioStatus || stopInfo.audioStatus,
supportedLanguages: stopInfo.supportedLanguages,
audioVariants,
imageStatus: stopInfo.imageStatus,
imageSource: stopInfo.imageSource,
galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [],
@@ -434,6 +463,10 @@ export class ExplainUseCase {
throw stopInfoResult.error || new Error('讲解详情加载失败')
}
selectExplainDetailLanguage(exhibit: MuseumExhibit, language: AudioLanguage) {
return this.applyLanguageVariant(exhibit, language)
}
async loadExplainDetailText(exhibit: MuseumExhibit): Promise<ExplainTextSelection> {
const targetType = exhibit.playTargetType || 'ITEM'
const targetId = exhibit.playTargetId || exhibit.id

View File

@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
onHideHandler: null as null | (() => void),
onUnloadHandler: null as null | (() => void),
enterExplainDetail: vi.fn(),
selectExplainDetailLanguage: vi.fn(),
loadExplainDetailText: vi.fn(),
selectAudioForExplainDetail: vi.fn(),
play: vi.fn(),
@@ -43,6 +44,7 @@ vi.mock('@dcloudio/uni-app', () => ({
vi.mock('@/usecases/explainUseCase', () => ({
explainUseCase: {
enterExplainDetail: mocks.enterExplainDetail,
selectExplainDetailLanguage: mocks.selectExplainDetailLanguage,
loadExplainDetailText: mocks.loadExplainDetailText,
selectAudioForExplainDetail: mocks.selectAudioForExplainDetail
}
@@ -110,6 +112,10 @@ describe('讲解详情音频优先布局', () => {
audioState.currentTime.value = 0
audioState.duration.value = 0
mocks.enterExplainDetail.mockResolvedValue(exhibit)
mocks.selectExplainDetailLanguage.mockImplementation((source, lang) => ({
...source,
audioLanguage: lang
}))
mocks.loadExplainDetailText.mockResolvedValue({ available: false })
mocks.selectAudioForExplainDetail.mockResolvedValue({
playable: true,
@@ -191,7 +197,7 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.find('.detail-language-bar').exists()).toBe(false)
})
it('深链语言不受支持时回退至中文并规范化地址', async () => {
it('深链语言不受支持时本地回退至中文并规范化地址', async () => {
mocks.enterExplainDetail.mockImplementation(async (request: { lang: string }) => ({
...exhibit,
audioLanguage: request.lang,
@@ -205,8 +211,9 @@ describe('讲解详情音频优先布局', () => {
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(1, expect.objectContaining({ lang: 'en-US' }))
expect(mocks.enterExplainDetail).toHaveBeenNthCalledWith(2, expect.objectContaining({ lang: 'zh-CN' }))
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
expect(mocks.enterExplainDetail).toHaveBeenCalledWith(expect.objectContaining({ lang: 'en-US' }))
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'zh-CN')
expect(wrapper.get('.language-option').classes()).toContain('active')
expect(wrapper.get('.language-option-text').text()).toBe('中文')
expect(replaceState).toHaveBeenCalledWith(null, '', expect.stringContaining('lang=zh-CN'))
@@ -244,6 +251,34 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined()
})
it('切换语言时使用初次详情响应中的变体,不重复请求详情或正文', async () => {
const localizedExhibit = {
...exhibit,
guideText: '粤语通道使用普通话文案。',
audioUrl: '/assets/astrolabe-yue.mp3',
audioDuration: 83,
audioLanguage: 'yue-HK',
audioStatus: 'READY',
audioHasText: true,
supportedLanguages: ['zh-CN', 'yue-HK', 'en-US']
}
mocks.selectExplainDetailLanguage.mockReturnValue(localizedExhibit)
const wrapper = mount(ExhibitDetail, {
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
})
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
await flushPromises()
await wrapper.findAll('.language-option').find((node) => node.text() === '粤语')?.trigger('tap')
await flushPromises()
expect(mocks.enterExplainDetail).toHaveBeenCalledTimes(1)
expect(mocks.loadExplainDetailText).not.toHaveBeenCalled()
expect(mocks.selectExplainDetailLanguage).toHaveBeenCalledWith(expect.any(Object), 'yue-HK')
expect(wrapper.get('.section-text').text()).toContain('粤语通道使用普通话文案')
expect(wrapper.get('.detail-audio-subtitle').text()).toContain('粤语')
})
it('无音频时禁用播放按钮并显示原因', async () => {
mocks.enterExplainDetail.mockResolvedValue({
...exhibit,

View File

@@ -98,18 +98,21 @@ describe('讲解对象列表返回', () => {
wrapper.unmount()
})
it('keeps the selected hall context when an object payload omits hall fields', async () => {
it('uses the decoded hall name as the page title and detail context', async () => {
const wrapper = mountGuideStopList()
testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '恐龙厅' })
testState.onLoadHandler?.({ hallId: 'hall-1', hallName: '%25E6%25BC%2594%25E5%258C%2596%25E5%258E%2585' })
await flushPromises()
expect(uni.setNavigationBarTitle).toHaveBeenCalledWith({ title: '演化厅' })
expect(document.title).toBe('演化厅')
await wrapper.get('[data-testid="detail-click"]').trigger('click')
const url = vi.mocked(uni.navigateTo).mock.calls[0]?.[0]?.url || ''
const params = new URLSearchParams(url.split('?')[1])
expect(url.split('?')[0]).toBe('/pages/exhibit/detail')
expect(params.get('hallId')).toBe('hall-1')
expect(params.get('hallName')).toBe('恐龙厅')
expect(params.get('hallName')).toBe('演化厅')
expect(params.get('targetType')).toBe('STOP')
expect(params.get('targetId')).toBe('target-1')
wrapper.unmount()

View File

@@ -83,7 +83,7 @@ describe('讲解展厅选择列表', () => {
})
const card = wrapper.get('.hall-overview-card')
expect(wrapper.get('.header-title').text()).toBe('展厅讲解')
expect(wrapper.get('.header-title').text()).toBe('免费讲解')
expect(card.attributes('style')).toContain('--hall-card-color: #d9b06c')
expect(card.text()).toContain('恐龙厅')
expect(card.text()).not.toContain('Dinosaur Hall')

View File

@@ -106,13 +106,13 @@ describe('讲解展厅列表返回', () => {
wrapper.unmount()
})
it('独立页在微信嵌入态强制提供 H5 返回,并保留宿主识别参数', async () => {
it('微信嵌入态由宿主导航提供标题与返回,不渲染重复的 H5 页头', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1'
window.history.replaceState({}, '', window.location.href)
const wrapper = mountExplainList()
expect(wrapper.getComponent(ExplainHallSelectStub).props('forceInternalHeader')).toBe(true)
expect(wrapper.getComponent(ExplainHallSelectStub).props('forceInternalHeader')).toBe(false)
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
expect(uni.navigateBack).not.toHaveBeenCalled()

View File

@@ -25,6 +25,7 @@ const createStopInfo = (overrides: Partial<GuideStopInfo> = {}): GuideStopInfo =
hasAudio: false,
hasText: false,
supportedLanguages: [],
languageVariants: {},
audioStatus: 'MISSING',
...overrides
})
@@ -99,4 +100,60 @@ describe('ExplainUseCase stop image policy', () => {
expect(detail.image).toBe('/guide-stop.jpg')
expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg'])
})
it('uses stop-info language variants locally without requesting play-info or text-info', async () => {
const audio = {
getStopInfo: vi.fn().mockResolvedValue(createStopInfo({
hasAudio: true,
hasText: true,
supportedLanguages: ['zh-CN', 'yue-HK'],
languageVariants: {
'zh-CN': {
lang: 'zh-CN',
enabled: true,
playable: true,
playUrl: '/audio/zh.mp3',
duration: 50,
hasText: true,
textAvailable: true,
text: '普通话讲解词',
fallback: false,
audioStatus: 'READY'
},
'yue-HK': {
lang: 'yue-HK',
enabled: true,
playable: true,
playUrl: '/audio/yue.mp3',
duration: 52,
hasText: true,
textAvailable: true,
text: '粤语通道使用普通话文案',
fallback: false,
audioStatus: 'READY'
}
} as GuideStopInfo['languageVariants']
})),
getPlayInfo: vi.fn(),
getTextInfo: vi.fn()
} as unknown as AudioPlayInfoRepository
const explain = {
getExhibitById: vi.fn(),
listExplainExhibits: vi.fn()
} as unknown as ExplainRepository
const useCase = new ExplainUseCase(explain, audio)
const detail = await useCase.enterExplainDetail({
exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN'
})
const cantonese = useCase.selectExplainDetailLanguage(detail, 'yue-HK')
expect(detail.guideText).toBe('普通话讲解词')
expect(detail.audioUrl).toBe('/audio/zh.mp3')
expect(cantonese.guideText).toBe('粤语通道使用普通话文案')
expect(cantonese.audioUrl).toBe('/audio/yue.mp3')
expect(cantonese.audioDuration).toBe(52)
expect((audio as any).getPlayInfo).not.toHaveBeenCalled()
expect((audio as any).getTextInfo).not.toHaveBeenCalled()
})
})

View File

@@ -35,7 +35,36 @@ describe('全局讲解播放器语言切换', () => {
vi.unstubAllGlobals()
})
it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => {
it('详情页提供语言变体播放地址时直接切换,不请求 play-info', async () => {
await player.play({
id: 'mandarin-audio',
name: '测试讲解',
audioUrl: '/museum-assets/audio/mandarin.mp3',
language: 'zh-CN'
}, {
source: {
targetType: 'STOP',
targetId: '1823450596800289',
lang: 'zh-CN'
}
})
await expect(player.switchLanguage('yue-HK', {
id: 'cantonese-audio',
name: '测试讲解',
audioUrl: '/museum-assets/audio/cantonese.mp3',
language: 'yue-HK'
})).resolves.toBe(true)
expect(repositoryMocks.getPlayInfo).not.toHaveBeenCalled()
expect(player.currentSource.value?.lang).toBe('yue-HK')
expect(player.currentAudio.value).toEqual(expect.objectContaining({
language: 'yue-HK',
audioUrl: '/museum-assets/audio/cantonese.mp3'
}))
})
it('未提供详情语言变体时保留 play-info 刷新回退', async () => {
repositoryMocks.getPlayInfo.mockResolvedValue({
playable: true,
targetType: 'STOP',

View File

@@ -5,7 +5,11 @@ import {
} from '@/domain/poiCategories'
import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider'
import type { StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider'
import { SgsSdkGuideRepository, StaticGuideRepository } from '@/repositories/GuideRepository'
import {
canonicalizeVisitorSearchPois,
SgsSdkGuideRepository,
StaticGuideRepository
} from '@/repositories/GuideRepository'
import {
SgsSdkGuideModelRepository,
StaticGuideModelRepository
@@ -40,7 +44,17 @@ const floorSpaces = [
{ id: 'space-hall', name: '地球展厅', type: 'exhibition_hall', floorId: 'L1', center: { x: 1, y: 12, z: 1 } },
{ id: 'space-cinema', name: '穹幕影院', type: 'theater', floorId: 'L1', center: { x: 2, y: 12, z: 2 } },
{ id: 'space-dining', name: '餐饮区', type: 'restaurant', floorId: 'L1', center: { x: 3, y: 12, z: 3 } },
{ id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } }
{ id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } },
{ id: 'space-private-tea', name: '茶水间', type: 'service_space', floorId: 'L1', center: { x: 5, y: 12, z: 5 } },
{ id: 'space-private-vip', name: '贵宾接待区', type: 'service_space', floorId: 'L1', center: { x: 6, y: 12, z: 6 } },
{
id: 'space-public-rest',
name: '游客休息区',
type: 'service_space',
floorId: 'L1',
center: { x: 7, y: 12, z: 7 },
visitorVisible: true
}
]
const floorPois = [
@@ -120,17 +134,25 @@ describe('GuideRepository visitor search contracts', () => {
const provider = createSgsProvider()
const repository = new SgsSdkGuideRepository(provider)
const [defaultResults, availability] = await Promise.all([
const [defaultResults, availability, privateKeyword, publicKeyword] = await Promise.all([
repository.listDestinationPois('L1'),
repository.getQuickFindCategoryAvailability('L1')
repository.getQuickFindCategoryAvailability('L1'),
repository.searchPois('茶水间', 'L1'),
repository.searchPois('游客休息区', 'L1')
])
expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([
'hall-space-hall',
'hall-space-cinema',
'space-space-dining',
'space-space-shopping'
'space-space-shopping',
'space-space-public-rest'
]))
for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) {
expect(defaultResults.map((poi) => poi.id)).not.toContain(privatePoiId)
}
expect(privateKeyword).toEqual([])
expect(publicKeyword.map((poi) => poi.id)).toEqual(['space-space-public-rest'])
expect(defaultResults.map((poi) => poi.id)).not.toEqual(expect.arrayContaining([
'restroom',
'elevator',
@@ -160,9 +182,13 @@ describe('GuideRepository visitor search contracts', () => {
// survive model marker deduplication.
expect(renderPoiIds.has('restroom')).toBe(true)
expect(renderPoiIds.has('elevator')).toBe(true)
expect(renderPoiIds.has('space-space-public-rest')).toBe(true)
for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) {
expect(renderPoiIds.has(hiddenPoiId)).toBe(false)
}
for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) {
expect(renderPoiIds.has(privatePoiId)).toBe(false)
}
const quickResultIds = (await Promise.all(allQuickCategoryIds.map((categoryId) => (
repository.listQuickFindPois(categoryId, 'L1')
)))).flat().map((poi) => poi.id)
@@ -188,6 +214,115 @@ describe('GuideRepository visitor search contracts', () => {
expect(hiddenKeyword).toEqual([])
})
it('does not resolve private direct IDs outside the visitor search pool', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
queryPois: vi.fn().mockResolvedValue([{
id: 'private-direct-space',
name: '茶水间',
type: 'service_space',
floorId: 'L1',
visitorVisible: false,
position: { x: 30, y: 12, z: 30 }
}])
}))
await expect(repository.getPoiById('private-direct-space')).resolves.toBeNull()
})
it('keeps private and structural space duplicates out of search lists and map markers', async () => {
const provider = createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([
{
id: 'poi-private-dining',
name: '西侧餐饮区',
type: 'restaurant',
floorId: 'L1',
spatialAreaId: 'space-private-dining',
position: { x: 30, y: 12, z: 30 }
},
{
id: 'poi-service-space',
name: '服务空间',
type: 'service_space',
floorId: 'L1',
position: { x: 31, y: 12, z: 31 }
}
]),
getFloorBusinessPois: vi.fn().mockResolvedValue([]),
getFloorSpaces: vi.fn().mockResolvedValue([
{
id: 'space-private-dining',
name: '西侧餐饮区',
type: 'restaurant',
floorId: 'L1',
visitorVisible: false,
center: { x: 30, y: 12, z: 30 }
},
{
id: 'space-private-cinema',
name: '私享影院',
type: 'theater',
floorId: 'L1',
visitorVisible: false,
center: { x: 32, y: 12, z: 32 }
},
{
id: 'space-route-node',
name: '导航区域',
type: 'navigable_place',
floorId: 'L1',
center: { x: 33, y: 12, z: 33 }
}
]),
getNavigablePlaces: vi.fn().mockResolvedValue([])
})
const repository = new SgsSdkGuideRepository(provider)
const modelRepository = new SgsSdkGuideModelRepository(provider, repository)
const [defaultResults, dining, cinema, privateKeyword, serviceKeyword, structuralKeyword, renderPois] = await Promise.all([
repository.listDestinationPois('L1'),
repository.listQuickFindPois('dining', 'L1'),
repository.listQuickFindPois('cinema', 'L1'),
repository.searchPois('西侧餐饮区', 'L1'),
repository.searchPois('服务空间', 'L1'),
repository.searchPois('导航区域', 'L1'),
modelRepository.loadFloorPois('L1')
])
expect(defaultResults).toEqual([])
expect(dining).toEqual([])
expect(cinema).toEqual([])
expect(privateKeyword).toEqual([])
expect(serviceKeyword).toEqual([])
expect(structuralKeyword).toEqual([])
expect(renderPois).toEqual([])
})
it('merges source-place duplicates before returning canonical visitor IDs', () => {
const basePoi = {
floorId: 'L1',
floorLabel: '1F',
primaryCategory: { id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' },
categories: [{ id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' }],
positionGltf: [40, 12, 40] as [number, number, number],
accessible: false,
kind: 'facility' as const,
sourcePlaceId: 'place-cafe'
}
const results = canonicalizeVisitorSearchPois([
{ ...basePoi, id: 'poi-cafe-a', name: '中庭咖啡' },
{
...basePoi,
id: 'poi-cafe-b',
name: '中庭咖啡取餐台',
positionGltf: [41, 12, 41]
}
])
expect(results.map((poi) => poi.id)).toEqual(['poi-cafe-a'])
})
it('deduplicates co-located sources when they do not provide a shared spatial area ID', async () => {
const repository = new SgsSdkGuideRepository(createSgsProvider({
getFloorPois: vi.fn().mockResolvedValue([{
@@ -348,6 +483,24 @@ describe('GuideRepository visitor search contracts', () => {
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(4)
},
{
id: 'static-vip-reception',
name: '贵宾接待区',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(5)
},
{
id: 'static-vip-restroom',
name: '贵宾卫生间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(6)
}
])
} as unknown as StaticNavAssetsProvider
@@ -362,6 +515,10 @@ describe('GuideRepository visitor search contracts', () => {
await expect(repository.searchPois('洗手间', 'L1')).resolves.toMatchObject([
{ id: 'static-restroom' }
])
await expect(repository.searchPois('茶水间', 'L1')).resolves.toEqual([])
await expect(repository.searchPois('贵宾', 'L1')).resolves.toEqual([])
await expect(repository.getPoiById('static-tea-room')).resolves.toBeNull()
await expect(repository.getPoiById('static-vip-restroom')).resolves.toBeNull()
await expect(repository.searchPois('服务', 'L1')).resolves.toEqual([
expect.objectContaining({ id: 'static-service-desk' })
])
@@ -397,6 +554,24 @@ describe('GuideRepository visitor search contracts', () => {
primaryCategoryZh: '讲解点',
iconType: 'guide',
positionGltf: position(2)
},
{
id: 'static-private-tea',
name: '茶水间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(3)
},
{
id: 'static-vip-restroom',
name: '贵宾卫生间',
floorId: 'L1',
primaryCategory: 'basic_service_facility',
primaryCategoryZh: '基础服务设施',
iconType: 'restroom',
positionGltf: position(4)
}
])
} as unknown as StaticNavAssetsProvider

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { toGuideStopInfo } from '@/data/adapters/guideStopInfoAdapter'
describe('guide stop-info adapter', () => {
it('normalizes aggregated language variants and exposes only displayable languages', () => {
const result = toGuideStopInfo({
available: true,
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN',
imageStatus: 'READY',
coverImageUrl: '/museum-assets/cover.webp',
languageVariants: [
{
lang: 'zh',
enabled: true,
playable: true,
audioStatus: 'READY',
playUrl: '/museum-assets/audio/zh.mp3',
duration: '50',
audioId: 12,
hasText: true,
textAvailable: true,
text: '普通话讲解词',
textLength: '6',
textHash: 'zh-hash'
},
{
lang: 'yue-HK',
enabled: true,
playable: false,
audioStatus: 'MISSING',
textAvailable: true,
text: '粤语使用普通话文案',
reason: 'NO_PUBLISHED_AUDIO'
},
{
lang: 'en-US',
enabled: false,
playable: true,
playUrl: '/museum-assets/audio/en.mp3'
}
]
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'yue-HK'])
expect(result.languageVariants['zh-CN']).toMatchObject({
playable: true,
playUrl: '/museum-assets/audio/zh.mp3',
duration: 50,
audioId: '12',
text: '普通话讲解词',
textLength: 6
})
expect(result.languageVariants['yue-HK']).toMatchObject({
playable: false,
textAvailable: true,
reason: 'NO_PUBLISHED_AUDIO'
})
})
it('keeps supportedLanguages as the compatibility fallback when variants are absent', () => {
const result = toGuideStopInfo({
supportedLanguages: ['zh-CN', 'en-US']
}, {
targetType: 'STOP',
targetId: '9001',
lang: 'zh-CN'
})
expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US'])
expect(result.languageVariants).toEqual({})
})
})

View File

@@ -18,6 +18,7 @@ const testState = vi.hoisted(() => ({
searchRestoreCount: 0,
searchCollapseAfterDetailCount: 0,
searchResetCount: 0,
floorSwitchRequests: [] as string[],
onShowHandler: null as null | (() => Promise<void> | void),
halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>,
hallResolutionPromises: [] as Array<Promise<Array<{
@@ -102,7 +103,7 @@ const GuideMapShellStub = defineComponent({
visiblePoiIds: { type: Array, default: null },
targetFocusRequest: { type: Object, default: null }
},
emits: ['floor-change', 'poi-click', 'selection-clear', 'indoor-view-change'],
emits: ['floor-change', 'floor-request', 'floor-switch-failed', 'initial-model-ready', 'poi-click', 'selection-clear', 'indoor-view-change'],
setup() {
onMounted(() => {
testState.mapMountCount += 1
@@ -113,6 +114,10 @@ const GuideMapShellStub = defineComponent({
return {}
},
methods: {
switchFloor(floorId: string) {
testState.floorSwitchRequests.push(floorId)
return Promise.resolve()
},
resetToViewBaseline(options: {
view: 'overview' | 'floor'
floorId?: string
@@ -217,6 +222,7 @@ beforeEach(() => {
testState.searchRestoreCount = 0
testState.searchCollapseAfterDetailCount = 0
testState.searchResetCount = 0
testState.floorSwitchRequests = []
testState.onShowHandler = null
testState.halls = []
testState.hallResolutionPromises = []
@@ -355,7 +361,7 @@ describe('首页搜索与地图闭环', () => {
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')
})
it('向搜索面板传入当前楼层,并将分类结果 ID 精确传给地图', async () => {
it('等待渲染器提交目标楼层后,才将快捷分类结果 ID 传给地图', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
@@ -370,6 +376,13 @@ describe('首页搜索与地图闭环', () => {
})
await wrapper.vm.$nextTick()
expect(testState.floorSwitchRequests).toEqual(['L2'])
expect(map.props('activeFloor')).toBe('L1')
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L2')
expect(map.props('indoorView')).toBe('floor')
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
@@ -388,6 +401,78 @@ describe('首页搜索与地图闭环', () => {
expect(map.props('visiblePoiIds')).toEqual([])
})
it('三维模型重新以总览就绪后,不将旧单层状态误作楼层提交', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
map.vm.$emit('floor-change', 'L1')
await wrapper.vm.$nextTick()
expect(map.props('indoorView')).toBe('floor')
map.vm.$emit('initial-model-ready', { view: 'overview', floorId: 'L1' })
await wrapper.vm.$nextTick()
expect(map.props('indoorView')).toBe('overview')
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', {
...categoryContext,
floorId: 'L1',
floorLabel: '1F',
active: true,
requestId: 20
})
await wrapper.vm.$nextTick()
expect(testState.floorSwitchRequests).toEqual(['L1'])
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L1')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('忽略过期搜索结果,不允许旧楼层覆盖当前待提交地图状态', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', { ...categoryContext, active: true, requestId: 8 })
search.vm.$emit('results-change', {
...categoryContext,
floorId: 'L1',
floorLabel: '1F',
visiblePoiIds: ['stale-l1'],
active: true,
requestId: 7
})
await wrapper.vm.$nextTick()
expect(testState.floorSwitchRequests).toEqual(['L2'])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('楼层切换失败时清空快捷分类标记,重试提交后恢复与列表一致的 ID', async () => {
const wrapper = await mountIndex()
const search = wrapper.getComponent(PoiSearchPanelStub)
const map = wrapper.getComponent(GuideMapShellStub)
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', { ...categoryContext, active: true, requestId: 12 })
await wrapper.vm.$nextTick()
map.vm.$emit('floor-switch-failed', { floorId: 'L2', floorLabel: '2F' })
await wrapper.vm.$nextTick()
expect(map.props('activeFloor')).toBe('L1')
expect(map.props('visiblePoiIds')).toEqual([])
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds)
})
it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => {
const originalFloors = [...floors]
floors.splice(0, floors.length,
@@ -417,6 +502,8 @@ describe('首页搜索与地图闭环', () => {
...categoryContext,
active: true
})
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
search.vm.$emit('result-tap', poi, categoryContext)
await flushPromises()
@@ -486,6 +573,8 @@ describe('首页搜索与地图闭环', () => {
search.vm.$emit('category-mode-change', true)
search.vm.$emit('results-change', { ...categoryContext, active: true })
map.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
search.vm.$emit('result-tap', poi, categoryContext)
await flushPromises()

View File

@@ -149,6 +149,7 @@ const lastResultState = (wrapper: ReturnType<typeof mount>) => (
categoryId: string
keyword: string
pending?: boolean
requestId?: number
}
)
@@ -323,6 +324,36 @@ describe('PoiSearchPanel 搜索状态契约', () => {
wrapper.unmount()
})
it('首页分类结果在组件重挂载后仍发送递增的跨组件版本号', async () => {
const firstWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await firstWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
const firstRequestId = lastResultState(firstWrapper).requestId
firstWrapper.unmount()
const secondWrapper = mount(PoiSearchPanel, {
props: {
variant: 'home',
currentFloorId: 'floor-1',
currentFloorLabel: '1F'
}
})
await flushMountedSearch()
await secondWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap')
await flushMountedSearch()
expect(firstRequestId).toEqual(expect.any(Number))
expect(lastResultState(secondWrapper).requestId).toBeGreaterThan(firstRequestId as number)
secondWrapper.unmount()
})
it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => {
const initialState = createSearchState()
const deferredCategory = createDeferred<GuidePoiSearchViewState>()

View File

@@ -0,0 +1,86 @@
import { createRequire } from 'node:module'
import { describe, expect, it, vi } from 'vitest'
const require = createRequire(import.meta.url)
const {
isTextBuildFile,
resolveH5BuildPolicy,
tencentMapKeyPlaceholder
} = require('../../scripts/finalize-h5-build-policy.cjs') as {
isTextBuildFile: (filePath: string) => boolean
resolveH5BuildPolicy: (options: {
args?: string[]
projectRoot: string
environment?: Record<string, string | undefined>
loadProductionEnv?: (root: string) => Record<string, string | undefined>
}) => {
allowPlaceholder: boolean
requireTencentMapKey: boolean
tencentMapKey: string
}
tencentMapKeyPlaceholder: string
}
const projectRoot = 'C:/museum-guide'
const validTencentMapKey = 'ABCDE-FGHIJ-KLMNO-PQRST-UVWXY-12345'
describe('finalize H5 build policy', () => {
it('keeps test builds isolated from production map credentials', () => {
const loadProductionEnv = vi.fn(() => ({
VITE_TENCENT_MAP_KEY: validTencentMapKey
}))
expect(resolveH5BuildPolicy({
args: ['--allow-placeholder'],
projectRoot,
environment: { VITE_TENCENT_MAP_KEY: validTencentMapKey },
loadProductionEnv
})).toEqual({
allowPlaceholder: true,
requireTencentMapKey: false,
tencentMapKey: ''
})
expect(loadProductionEnv).not.toHaveBeenCalled()
})
it('loads a well-formed production key only for the production gate', () => {
const loadProductionEnv = vi.fn(() => ({
VITE_TENCENT_MAP_KEY: validTencentMapKey
}))
expect(resolveH5BuildPolicy({
args: ['--require-tencent-map-key'],
projectRoot,
environment: {},
loadProductionEnv
})).toEqual({
allowPlaceholder: false,
requireTencentMapKey: true,
tencentMapKey: validTencentMapKey
})
expect(loadProductionEnv).toHaveBeenCalledWith(projectRoot)
})
it('rejects conflicting flags, placeholders, and malformed production keys', () => {
expect(() => resolveH5BuildPolicy({
args: ['--allow-placeholder', '--require-tencent-map-key'],
projectRoot,
environment: {}
})).toThrow('cannot require a Tencent map key')
for (const key of ['', tencentMapKeyPlaceholder, 'fake-key']) {
expect(() => resolveH5BuildPolicy({
args: ['--require-tencent-map-key'],
projectRoot,
environment: { VITE_TENCENT_MAP_KEY: key },
loadProductionEnv: () => ({})
})).toThrow('well-formed non-placeholder')
}
})
it('scans source maps as text build artifacts', () => {
expect(isTextBuildFile('assets/index.js.map')).toBe(true)
expect(isTextBuildFile('assets/index.js')).toBe(true)
expect(isTextBuildFile('assets/model.glb')).toBe(false)
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { toMuseumPoi } from '@/data/adapters/navAssetsAdapter'
import { resolvePoiCategory } from '@/domain/poiCategories'
import { isVisitorSearchPoi, resolvePoiCategory } from '@/domain/poiCategories'
import type { StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider'
const createStaticPoi = (name: string, iconType = 'elevator'): StaticNavPoiPayload => ({
@@ -52,10 +52,23 @@ describe('static nav POI adapter', () => {
const serviceDesk = toMuseumPoi(createStaticPoi('服务台', 'restroom'))
const locker = toMuseumPoi(createStaticPoi('存包处', 'restroom'))
const teaRoom = toMuseumPoi(createStaticPoi('茶水间', 'restroom'))
const vipReception = toMuseumPoi(createStaticPoi('贵宾接待区', 'restroom'))
const vipRestroom = toMuseumPoi(createStaticPoi('贵宾卫生间', 'restroom'))
expect(resolvePoiCategory(serviceDesk)?.id).toBe('service-center')
expect(resolvePoiCategory(locker)?.id).toBe('service-center')
expect(resolvePoiCategory(teaRoom)).toBeNull()
expect(teaRoom.visitorVisible).toBe(false)
expect(vipReception.visitorVisible).toBe(false)
expect(vipRestroom.visitorVisible).toBe(false)
expect(toMuseumPoi({
...createStaticPoi('茶水间', 'restroom'),
visitorVisible: true
}).visitorVisible).toBe(true)
expect(toMuseumPoi({
...createStaticPoi('贵宾卫生间', 'restroom'),
visitorVisible: true
}).visitorVisible).toBe(true)
})
it('splits generic touring fallback records into their visitor-facing semantic kinds', () => {
@@ -73,4 +86,19 @@ describe('static nav POI adapter', () => {
})
expect(resolvePoiCategory(ticketOffice)?.id).toBe('ticket-office')
})
it('keeps a hall visible when its secondary category describes an entrance', () => {
const hall = toMuseumPoi(createTouringPoi('地球展厅'))
hall.kind = 'hall'
hall.categories = [
hall.primaryCategory,
{
id: 'exhibition_hall_entrance',
label: '展厅出入口',
iconType: 'hall_entrance'
}
]
expect(isVisitorSearchPoi(hall)).toBe(true)
})
})