chore: solidify guide P1 snapshot
This commit is contained in:
211
.agents/skills/shenzhen-natural-museum-dev/SKILL.md
Normal file
211
.agents/skills/shenzhen-natural-museum-dev/SKILL.md
Normal file
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: shenzhen-natural-museum-dev
|
||||
description: Shenzhen Natural Museum frontend-miniapp H5 development standards. Use when working in this project on navigation, guide pages, indoor 3D/Three.js/GLB, Tencent Map/outdoor map logic, static nav assets, Guide Data Access Layer/导览数据访问层, data provider/adapter architecture, route_graph/nav_data readiness, 3D guide data audits, browser-simulated user-flow/user-acceptance audits, legacy demo data cleanup, mobile H5 compatibility, browser-based user flow testing, dead-end/return/state-loss/cancel-reset audits, quality gates, or architecture reviews for the museum guide H5 app. Treat mini-program/mp-weixin as out of scope unless the user explicitly asks for it.
|
||||
---
|
||||
|
||||
# Shenzhen Natural Museum Dev
|
||||
|
||||
Use this skill for this repository's product work. The project may live under historical folder names, but the target product is the Shenzhen Natural Museum guide miniapp.
|
||||
|
||||
## Project Ground Truth
|
||||
|
||||
- Main workspace: `E:\MyWork\深圳国际艺术馆\museum-guide\museum-guide-v4.0\frontend-miniapp`.
|
||||
- Default runtime target: H5. Do not consider mini-program/mp-weixin constraints unless the user explicitly asks for them.
|
||||
- Current clean nav asset package: `static/nav-assets/app_nav_assets_v2_clean_20260609_075339`.
|
||||
- Current guide data service: `src/services/navAssets.ts`.
|
||||
- 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 and POI/location preview. Do not present it as certified real indoor navigation until `route_graph` and `nav_data` are available and verified.
|
||||
|
||||
## Non-Negotiable Rules
|
||||
|
||||
- Do not reintroduce business dependencies from the guide module to `src/assets/data/*.json`.
|
||||
- Keep guide data loading centralized through `src/services/navAssets.ts` or a deliberate successor service with the same clear boundary.
|
||||
- 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.
|
||||
- Treat POI coordinates from the clean package as display or preview candidates, not certified navigation anchors.
|
||||
- Keep `NAV_ROUTE_GRAPH_READY` false until real graph/nav data has been added, validated, and wired with tests or smoke verification.
|
||||
- Do not claim "开始馆内导航" or route planning is real unless the graph data exists. Prefer "位置预览", "查看位置", or an explicitly disabled state.
|
||||
- Three.js/WebGL is H5-only for current project work. Do not add mini-program fallback or mp-weixin compatibility work unless specifically requested.
|
||||
- Preserve mobile overlays: top tabs/menu/search/cards/floor controls must remain visible and clickable above the 3D canvas.
|
||||
- Avoid unrelated refactors, broad file deletion, or data cleanup without explicit confirmation. Contain and mark legacy first; delete only when asked.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Inspect before changing:
|
||||
- Use `rg`/`rg --files` to find guide, map, route, asset, and data references.
|
||||
- Check routing/page entry points before editing UI behavior.
|
||||
- Read the local implementation instead of assuming the uni-app/Vue structure.
|
||||
|
||||
2. Scope changes tightly:
|
||||
- For guide work, start around `src/pages/guide`, `src/components/guide`, `src/services/navAssets.ts`, and related route/search/facility pages.
|
||||
- Do not touch hall/exhibit/explain demo modules unless the task is explicitly about legacy cleanup or cross-module data consistency.
|
||||
|
||||
3. Protect the H5 boundary:
|
||||
- Default to H5 behavior, H5 styling, and H5 validation.
|
||||
- Use conditional compilation only when existing code requires it or when the user explicitly asks about mini-program support.
|
||||
- Do not spend time preserving or testing mp-weixin behavior unless the task names mini-program/mp-weixin.
|
||||
|
||||
4. Verify after changes:
|
||||
- Run the smallest meaningful checks, then expand based on risk.
|
||||
- For frontend/UI guide changes, prefer at least `pnpm type-check`, `pnpm lint`, and `pnpm build:h5`.
|
||||
- Run `pnpm build:mp-weixin` only when the user explicitly requests mini-program validation.
|
||||
- Mention before running build commands if the task is read-only, because builds write `dist`.
|
||||
|
||||
## Guide, Map, And 3D Standards
|
||||
|
||||
- Indoor 3D should load GLB/GLTF resources from the clean package under `static/nav-assets/...` while preserving texture/bin relationships.
|
||||
- Always provide loading and error fallback states for model loading.
|
||||
- Keep canvas sizing constrained by the guide layout. It must not cover fixed navigation, cards, buttons, or floor controls.
|
||||
- Avoid automatic page jumps when opening the indoor 3D view or clicking "start" actions. Explicit user intent should route, preview, or show a disabled explanation.
|
||||
- Watch GLB weight and runtime memory. The clean package previously contained multiple GLBs totaling about 24 MB; load only the primary model needed for the current view unless there is a product reason to load more.
|
||||
- Prefer existing Three.js/GLTF patterns in the project. Add dependencies only when the repository lacks the needed loader/runtime.
|
||||
|
||||
## 数据源标准
|
||||
|
||||
- Use the clean manifest as the single guide data entry point.
|
||||
- If route/facility/search pages need POI data, consume normalized records from `src/services/navAssets.ts`.
|
||||
- Do not duplicate parsed nav data inside page components.
|
||||
- Use a Guide Data Access Layer/导览数据访问层 for all guide data. Pages and UI components must not bind directly to static JSON files or future backend API response shapes.
|
||||
- Treat `src/services/navAssets.ts` as the current 导览数据访问层 boundary. If it is replaced, the successor must keep the same boundary: pages consume normalized guide domain models, not raw static-package or API records.
|
||||
- Connect every data source through a Provider/Adapter architecture:
|
||||
- Static data packages use a Static Data Provider/静态数据源提供器.
|
||||
- Future backend APIs or database-backed services use an API Data Provider/接口数据源提供器.
|
||||
- Adapters/数据适配器 convert source-specific records into unified guide domain models such as floors, POIs, categories, model assets, connector endpoints, route previews, and future route graphs.
|
||||
- Keep current static-package fields and future API fields behind adapters. Do not let route/search/facility/detail pages depend on file paths, response field quirks, database IDs, or temporary migration aliases.
|
||||
- Use contract-first data evolution. Static packages and future APIs should expose compatible `schemaVersion`/dataset version metadata and preserve a stable domain model for `Floor`, `Poi`, `GuideAsset`, `ConnectorEndpoint`, `RoutePreview`, and future `RouteGraph`.
|
||||
- Keep phase boundaries explicit: phase 1 supports 3D display, POI search/filtering, and location preview; phase 2 route navigation requires `route_graph`, `nav_data`, verified anchors, and graph smoke tests.
|
||||
- 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;
|
||||
- unrelated historical demo modules;
|
||||
- deletion candidates that need user approval.
|
||||
|
||||
## 三维导览数据审核模块
|
||||
|
||||
Use this module when the user asks for data review, nav data readiness, POI audit, route graph audit, GLB/3D guide data validation, or professional 3D guide industry review.
|
||||
|
||||
Audit the data as a navigation product, not just as JSON that parses:
|
||||
|
||||
1. Inventory the authoritative sources:
|
||||
- Identify clean package files under `static/nav-assets/...`, the manifest, GLB/GLTF assets, POI index, floor definitions, connector data, `route_graph`, `nav_data`, and code entry points that load them.
|
||||
- Confirm guide runtime pages consume data through `src/services/navAssets.ts` or an approved successor 导览数据访问层.
|
||||
- Treat 导览数据访问层 + 数据源提供器/数据适配器 as a required audit item. Flag any page, component, store, or utility that directly parses static-package files, directly depends on backend API response shapes, or duplicates source-specific transformation logic.
|
||||
- Check that static data packages and future API providers produce the same normalized guide domain models, or document the compatibility gap.
|
||||
- Mark `src/assets/data` as legacy/demo unless the task is explicitly about migrating it.
|
||||
|
||||
2. Check professional 3D guide data readiness:
|
||||
- Coordinate system: every POI, floor, entrance, connector, and preview marker must declare or inherit the same coordinate space as the rendered GLB/GLTF.
|
||||
- Floor semantics: floor IDs, labels, order, elevation, and visible floor controls must match the model and UI.
|
||||
- POI quality: each POI needs stable `id`, display name, category, floor, source confidence, display coordinate, and enough context for search/detail/preview.
|
||||
- Topology: real navigation requires verified nodes, edges, weights, floor transitions, one-way/blocked paths, accessible routes, and vertical connectors.
|
||||
- Anchors: POI coordinates from detection or semantic extraction are preview candidates until human- or test-verified against accessible entrances and walkable surfaces.
|
||||
- Asset integrity: GLB/GLTF/bin/textures must preserve relative paths, load on H5, and have size/performance risk noted for mobile.
|
||||
- Indoor/outdoor separation: Tencent/outdoor entrance data must not be treated as indoor route graph data.
|
||||
|
||||
3. Gate route readiness:
|
||||
- Keep `NAV_ROUTE_GRAPH_READY` false until `route_graph` and `nav_data` exist, are loaded, and pass smoke tests.
|
||||
- Do not approve route planning if graph nodes are unreachable, connectors are missing, floors are misaligned, or POI anchors are not mapped to walkable graph nodes.
|
||||
- If only preview data exists, report the capability as "位置预览" and list missing data needed for real navigation.
|
||||
- Verify that phase 2 route data can be added through the 导览数据访问层 without rewriting page-level route/search/facility logic.
|
||||
|
||||
4. Report data findings with evidence:
|
||||
- Separate `Verified data issue`, `Runtime integration issue`, and `Source-only risk`.
|
||||
- Include priority, affected asset/file, object IDs, observed mismatch, expected 3D guide standard, data-access-layer/provider/adapter impact, user impact, and suggested verification.
|
||||
- Save requested data-audit reports under `docs/QA/` using names like `3d-guide-data-audit-YYYY-MM-DD.md`.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Use these checks as the project baseline:
|
||||
|
||||
```powershell
|
||||
pnpm type-check
|
||||
pnpm lint
|
||||
pnpm build:h5
|
||||
```
|
||||
|
||||
- Treat lint warnings as debt even if the command exits successfully.
|
||||
- Add or run H5 smoke checks for: top menu visibility, indoor 3D scene, "开始馆内导航"/preview action, search result click, and route/detail page.
|
||||
- For mobile risk, inspect small viewport behavior: overlays above canvas, no text/button overlap, no horizontal overflow, and no accidental full-screen canvas capture.
|
||||
- If the user explicitly asks for mini-program/mp-weixin, add `pnpm build:mp-weixin` as an extra check for that task only.
|
||||
|
||||
## Browser User Flow Closure Testing
|
||||
|
||||
Use this module when the user asks to test the Shenzhen Natural Museum guide app through browser automation, find user logic loops that do not close, audit dead ends, verify return behavior, or test multi-step guide/search/detail/route flows.
|
||||
|
||||
1. Prepare the target:
|
||||
- If the user provides a URL, use it directly.
|
||||
- If the user says to use the actual project or a temporary service, inspect `package.json` and start the smallest H5 dev server command that matches the repo; report the local preview URL.
|
||||
- Treat browser simulation as the evidence baseline for user testing. If a runnable H5 URL or dev server is available, use the Browser in-app automation plugin with DOM snapshots, real clicks/taps, viewport changes, browser back, refresh, and deep-link checks.
|
||||
- Use screenshots when visual layering, mobile fit, 3D canvas coverage, or map overlay behavior matters.
|
||||
- If browser verification is impossible, label findings as `source-only risk`; do not present them as user-tested or browser-verified.
|
||||
- Keep the test scoped to H5 unless the user explicitly asks for mp-weixin.
|
||||
|
||||
2. Simulate real user tasks, not just route URLs:
|
||||
- Home guide: outdoor map, indoor 3D 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: choose start, view position, search from detail, return from route.
|
||||
- Route detail: location preview, outdoor reference switch, target-location action, mode/floor/tool controls, browser reload.
|
||||
- Explain: tab switch, search drawer, hot/history keyword, result click, exhibit detail, audio/play/navigation actions.
|
||||
- Hall/exhibit detail: direct-open and in-flow-open behavior, visible back path, bottom action behavior.
|
||||
|
||||
3. Check these closure risks on every flow:
|
||||
- Dead end after click: page stays stuck, white screen, missing content, or action only logs/toasts without a useful next step.
|
||||
- Interrupted task: button text promises navigation/search/audio/selection but there is no continuation, completion, or recovery instruction.
|
||||
- State loss: route query, selected tab, 2D/3D mode, filter, search keyword, target, preview mode, or route step disappears after navigation, refresh, share/deep link, or browser back.
|
||||
- Missing return control: custom-navigation pages must provide a visible in-page back/cancel/close path, not only rely on browser/system back.
|
||||
- Multi-step trap: start/target/location/route flows must have cancel, reset, retry, and completion paths.
|
||||
|
||||
4. Validate against current product truth:
|
||||
- Treat current capability as indoor 3D display plus POI/location preview until verified `route_graph` and `nav_data` exist.
|
||||
- Flag any UI that claims real route planning, start navigation, arrival, or certified guidance while `NAV_ROUTE_GRAPH_READY` is false.
|
||||
- Flag old mock data when it changes the user's selected object, e.g. a natural museum search result opens an unrelated historical demo detail.
|
||||
- Keep Tencent/outdoor map issues separate from indoor Three.js/GLB issues.
|
||||
|
||||
5. Report with evidence:
|
||||
- Separate browser-verified findings from source-only risks.
|
||||
- Mark a finding as browser-verified only when it was reproduced through actual browser steps. Source inspection can support evidence but cannot replace the browser user test.
|
||||
- For each finding include: priority, user path, observed behavior, expected closure, impact, and file/line evidence when available.
|
||||
- Mention positive paths that work, such as back buttons, close buttons, or state preservation.
|
||||
- If saving a report is requested, place it under `docs/QA/` using a date-based name such as `user-flow-closure-audit-YYYY-MM-DD.md`.
|
||||
|
||||
## 3D Guide User Audit Module
|
||||
|
||||
Use this module when the user asks for user review, usability audit, user acceptance testing, guided-path testing, or professional 3D guide user-flow review.
|
||||
|
||||
Audit whether a real visitor can complete the museum task, not only whether clicks fire:
|
||||
|
||||
1. Define visitor tasks before testing:
|
||||
- Find a facility or exhibit from the home guide.
|
||||
- Understand indoor vs outdoor context.
|
||||
- Preview a target location in 3D.
|
||||
- Recover from route unavailable, model load failure, search miss, location denial, or wrong target.
|
||||
- Switch between "导览" and "讲解" without losing necessary context.
|
||||
|
||||
2. Apply 3D guide UX standards:
|
||||
- Orientation: the user always knows current floor, map mode, target, and whether the view is indoor 3D or outdoor map.
|
||||
- Control visibility: top tabs/menu/search/cards/floor controls remain above canvas, tappable, and readable on mobile.
|
||||
- Flow closure: every start/target/search/preview route has visible back, cancel, reset, retry, and completion or disabled states.
|
||||
- Trust and truthfulness: labels must not promise real route planning, arrival guidance, turn-by-turn navigation, or positioning accuracy unless data and runtime support it.
|
||||
- Spatial feedback: selecting a POI should highlight or frame the location, not only show a toast.
|
||||
- Failure recovery: WebGL/model/network/location failures need fallback text, retry, and a route back to search or home.
|
||||
- Accessibility and comfort: avoid tiny touch targets, hidden controls, excessive motion, unreadable overlays, and ambiguous color-only state.
|
||||
|
||||
3. Test with realistic H5 paths:
|
||||
- Mobile viewport first; include a small-screen pass for overlay collisions and horizontal overflow.
|
||||
- Use real clicks/taps through Browser automation for user-audit conclusions whenever the H5 app can run. Do not substitute route-only checks, source reading, or static screenshots for simulated visitor behavior.
|
||||
- Screenshots are required when checking 3D/map layering, canvas coverage, mobile fit, or visual feedback after a POI/action click.
|
||||
- Test refresh/deep link/browser back for search result, facility detail, route preview, top tab switching, and unavailable route states.
|
||||
|
||||
4. Report user findings with evidence:
|
||||
- Separate browser-verified findings from source-only risks.
|
||||
- If a report could not run browser simulation, state that limitation at the top and avoid calling it a user test report.
|
||||
- Include priority, task, path, observed behavior, expected visitor outcome, impact, and file/line evidence when available.
|
||||
- Mention positive paths that work, especially preserved state, visible back/close controls, and useful fallbacks.
|
||||
- Save requested user-audit reports under `docs/QA/` using names like `3d-guide-user-audit-YYYY-MM-DD.md` or `user-flow-closure-audit-YYYY-MM-DD.md`.
|
||||
|
||||
## Known Risk Radar
|
||||
|
||||
- `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.
|
||||
- Historical demo data remains outside the guide module in areas such as hall, exhibit, and explain pages. Do not mix it back into the guide flow.
|
||||
- Tencent Map SDK behavior and indoor Three.js behavior are separate operational risks; test both after changes that touch shared guide shell UI.
|
||||
- Static asset size and GLB decode time can affect mobile performance. Prefer lazy loading, dispose Three.js resources on unmount, and avoid loading unused models.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Shenzhen Natural Museum Dev"
|
||||
short_description: "H5 standards for guide, 导览数据访问层, 3D data audits, and user audits."
|
||||
default_prompt: "Use the Shenzhen Natural Museum frontend-miniapp H5 standards for guide, Three.js/GLB, Tencent Map, nav assets, 导览数据访问层 with data provider/adapter architecture, 三维导览数据审核, browser-simulated user-flow audits, legacy demo data, and H5 validation. Ignore mini-program/mp-weixin unless explicitly requested."
|
||||
@@ -115,6 +115,10 @@ pnpm build:h5
|
||||
pnpm build:mp-weixin
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
H5 线上部署、Nginx 配置、模型资源校验和回滚步骤见 [docs/H5_DEPLOYMENT_GUIDE.md](docs/H5_DEPLOYMENT_GUIDE.md)。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 地图导览(楼层切换、POI 标记)
|
||||
|
||||
204
docs/H5_DEPLOYMENT_GUIDE.md
Normal file
204
docs/H5_DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# H5 部署说明
|
||||
|
||||
最后更新:2026-06-10
|
||||
|
||||
## 目标环境
|
||||
|
||||
- 域名:https://guide.whaoyue.com/
|
||||
- SSH 连接别名:`自然博物馆-测试服务器`
|
||||
- 服务器 IP:`1.92.206.90`
|
||||
- 站点宿主目录:`/dmdata/nginx/html/guide`
|
||||
- Nginx 配置:`/dmdata/nginx/conf.d/guide.whaoyue.com.conf`
|
||||
- Nginx 容器:`nginx-server`
|
||||
- H5 构建目录:`dist/build/h5`
|
||||
|
||||
说明:Nginx 容器内站点根目录为 `/usr/share/nginx/html/guide`,宿主机对应目录为 `/dmdata/nginx/html/guide`。
|
||||
|
||||
## 本地构建
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
pnpm type-check
|
||||
pnpm build:h5
|
||||
```
|
||||
|
||||
`pnpm build:h5` 会先执行 `uni build -p h5`,再执行 `scripts/copy-h5-nav-assets.cjs`,把 `static/nav-assets` 复制到 `dist/build/h5/static/nav-assets`。
|
||||
|
||||
构建后重点检查:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem -LiteralPath 'dist\build\h5\static\nav-assets\app_nav_assets_v2_clean_20260609_075339' -Recurse -File |
|
||||
Measure-Object Length -Sum |
|
||||
Select-Object Count,Sum
|
||||
```
|
||||
|
||||
当前导航模型资源包应包含 `25` 个文件,文件总字节数约 `26811587`。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
生成部署包:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path '.tmp' | Out-Null
|
||||
$archive = '.tmp\museum-guide-h5.tar.gz'
|
||||
if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
|
||||
tar -C 'dist\build\h5' -czf $archive .
|
||||
```
|
||||
|
||||
上传到服务器:
|
||||
|
||||
```powershell
|
||||
scp -o BatchMode=yes '.tmp\museum-guide-h5.tar.gz' '自然博物馆-测试服务器:/tmp/museum-guide-h5.tar.gz'
|
||||
```
|
||||
|
||||
替换线上站点:
|
||||
|
||||
```powershell
|
||||
ssh -o BatchMode=yes '自然博物馆-测试服务器' 'set -e
|
||||
SITE=/dmdata/nginx/html/guide
|
||||
ARCHIVE=/tmp/museum-guide-h5.tar.gz
|
||||
TS=$(date +%Y%m%d%H%M%S)
|
||||
BACKUP_DIR=/dmdata/nginx/html/_backups
|
||||
[ "$SITE" = "/dmdata/nginx/html/guide" ]
|
||||
test -f "$ARCHIVE"
|
||||
mkdir -p "$SITE" "$BACKUP_DIR"
|
||||
tar -C "$SITE" -czf "$BACKUP_DIR/guide-before-deploy-$TS.tar.gz" .
|
||||
find "$SITE" -mindepth 1 -maxdepth 1 ! -name ".well-known" -exec rm -rf -- {} +
|
||||
tar -C "$SITE" -xzf "$ARCHIVE"
|
||||
mkdir -p "$SITE/.well-known/acme-challenge"
|
||||
chown -R root:root "$SITE"
|
||||
docker exec nginx-server nginx -t
|
||||
docker exec nginx-server nginx -s reload
|
||||
printf "backup=%s\n" "$BACKUP_DIR/guide-before-deploy-$TS.tar.gz"
|
||||
printf "files=%s\n" "$(find "$SITE" -type f | wc -l)"
|
||||
'
|
||||
```
|
||||
|
||||
## Nginx 关键配置
|
||||
|
||||
`/static/nav-assets/` 必须独立配置,避免模型、manifest、楼层数据缺失时落入 SPA 的 `/index.html` 回退。
|
||||
|
||||
```nginx
|
||||
location ^~ /static/nav-assets/ {
|
||||
types {
|
||||
application/json json;
|
||||
model/gltf-binary glb;
|
||||
model/gltf+json gltf;
|
||||
text/csv csv;
|
||||
text/markdown md;
|
||||
}
|
||||
default_type application/octet-stream;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
每次修改配置后执行:
|
||||
|
||||
```powershell
|
||||
ssh -o BatchMode=yes '自然博物馆-测试服务器' 'docker exec nginx-server nginx -t && docker exec nginx-server nginx -s reload'
|
||||
```
|
||||
|
||||
## 部署后验证
|
||||
|
||||
公网资源响应:
|
||||
|
||||
```powershell
|
||||
curl.exe -I --max-time 30 https://guide.whaoyue.com/static/nav-assets/app_nav_assets_v2_clean_20260609_075339/app_nav_manifest.json
|
||||
curl.exe -I --max-time 30 https://guide.whaoyue.com/static/nav-assets/app_nav_assets_v2_clean_20260609_075339/models_by_floor/L1.glb
|
||||
curl.exe -I --max-time 30 https://guide.whaoyue.com/static/nav-assets/app_nav_assets_v2_clean_20260609_075339/models_by_floor/MISSING.glb
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- `app_nav_manifest.json`:`200`,`Content-Type: application/json`
|
||||
- `models_by_floor/L1.glb`:`200`,`Content-Type: model/gltf-binary`
|
||||
- 缺失 GLB:`404`,不能返回 `index.html`
|
||||
|
||||
### 域名根目录校验文件
|
||||
|
||||
如第三方平台要求在域名根目录放置 TXT 校验文件,可上传到站点宿主目录:
|
||||
|
||||
```powershell
|
||||
scp -o BatchMode=yes 'E:\MyWork\深圳自然馆\服务器信息\对接文档\gpL0svkeao.txt' '自然博物馆-测试服务器:/dmdata/nginx/html/guide/gpL0svkeao.txt'
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```powershell
|
||||
curl.exe -L --max-time 30 https://guide.whaoyue.com/gpL0svkeao.txt
|
||||
```
|
||||
|
||||
当前 `gpL0svkeao.txt` 预期返回:
|
||||
|
||||
```text
|
||||
b772216640a14171ba5655085c8523be
|
||||
```
|
||||
|
||||
页面验证:
|
||||
|
||||
- 打开 https://guide.whaoyue.com/#/
|
||||
- 切换到 `室内3D`
|
||||
- 首次进入应加载当前楼层模型,而不是先加载全馆 overview 模型
|
||||
- 不应出现 `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 模型请求返回 HTML
|
||||
|
||||
现象:
|
||||
|
||||
```text
|
||||
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
|
||||
```
|
||||
|
||||
原因通常是 H5 产物缺少 `static/nav-assets`,或 Nginx 未对 `/static/nav-assets/` 使用 `try_files $uri =404`。
|
||||
|
||||
处理:
|
||||
|
||||
1. 确认 `pnpm build:h5` 已执行资源复制脚本。
|
||||
2. 确认服务器存在 `/dmdata/nginx/html/guide/static/nav-assets/app_nav_assets_v2_clean_20260609_075339`。
|
||||
3. 确认 Nginx `/static/nav-assets/` 配置生效。
|
||||
4. 强刷浏览器,避免继续使用旧 JS。
|
||||
|
||||
### 室内 3D 首屏加载过慢
|
||||
|
||||
导览页已配置为 `initial-view="floor"`,进入室内 3D 时优先加载当前楼层模型。不要在导览壳层中默认加载全馆 overview 模型,否则低性能设备可能长时间停留在 100% 或页面无响应。
|
||||
|
||||
### 浏览器缓存旧包
|
||||
|
||||
如果线上已经替换但页面仍异常,使用带参数的地址强制刷新:
|
||||
|
||||
```text
|
||||
https://guide.whaoyue.com/?v=YYYYMMDDHHmm#/
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
部署脚本会在 `/dmdata/nginx/html/_backups` 下生成备份包。回滚示例:
|
||||
|
||||
```powershell
|
||||
ssh -o BatchMode=yes '自然博物馆-测试服务器' 'set -e
|
||||
SITE=/dmdata/nginx/html/guide
|
||||
BACKUP=/dmdata/nginx/html/_backups/guide-before-deploy-YYYYMMDDHHmmss.tar.gz
|
||||
[ "$SITE" = "/dmdata/nginx/html/guide" ]
|
||||
test -f "$BACKUP"
|
||||
find "$SITE" -mindepth 1 -maxdepth 1 ! -name ".well-known" -exec rm -rf -- {} +
|
||||
tar -C "$SITE" -xzf "$BACKUP"
|
||||
docker exec nginx-server nginx -t
|
||||
docker exec nginx-server nginx -s reload
|
||||
'
|
||||
```
|
||||
|
||||
## 最近部署记录
|
||||
|
||||
- 2026-06-10:补齐 H5 模型资源打包,新增 `/static/nav-assets/` Nginx 规则,修复模型资源请求返回 HTML 的问题。
|
||||
- 2026-06-10:室内 3D 默认加载当前楼层模型,减少首屏模型解析压力。线上备份:`/dmdata/nginx/html/_backups/guide-before-floor-first-20260610033959.tar.gz`。
|
||||
- 2026-06-10:上传域名根目录校验文件 `gpL0svkeao.txt`,公网验证地址为 `https://guide.whaoyue.com/gpL0svkeao.txt`。
|
||||
153
docs/QA/user-flow-closure-audit-2026-06-10.md
Normal file
153
docs/QA/user-flow-closure-audit-2026-06-10.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 用户逻辑闭环浏览器测试报告
|
||||
|
||||
日期:2026-06-10
|
||||
项目:深圳自然博物馆智能导览应用 - frontend-miniapp
|
||||
接续会话:`019eab78-f40b-72b1-9751-f8d4c4ff8fba`
|
||||
测试地址:`http://192.168.0.112:5180/#/`
|
||||
运行目标:H5
|
||||
测试方式:浏览器自动化点击、DOM 状态观察、当前源码佐证
|
||||
测试范围:导览首页、全局顶部菜单、搜索页、设施详情页、路线详情页、返回/取消/重置/状态保留
|
||||
|
||||
## 结论
|
||||
|
||||
本次测试确认:导览首页到搜索、搜索结果到设施详情/路线预览、路线页室外预览返回室内预览等主路径已经可以渲染,浏览器或系统返回也能在若干场景中恢复上一页;搜索筛选状态从设施详情返回后仍能保留,是一个正向结果。
|
||||
|
||||
但用户逻辑闭环仍未完成。当前问题不是页面打不开,而是用户在进入二级页面后缺少稳定的页面级返回、取消、重置和继续路径;部分关键按钮只 `toast` 或 `console.log`,导致任务停在中间态。尤其是搜索页不能重新输入关键词、设施页不能真正选择起点、路线页不能继续到“目标位置确认/导航完成”,这些都属于闭环断点。
|
||||
|
||||
需要特别说明:当前产品能力仍应表述为“室内 3D 展示 + POI/位置预览”。源码中的提示也明确指出正式路线数据尚未接入,不能把现有路径宣传为真实室内路线规划或实时导航。
|
||||
|
||||
## 当前已验证正向路径
|
||||
|
||||
| 路径 | 浏览器验证结果 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 首页导览 -> 点击搜索框 -> 搜索页 | 成功进入 `/pages/search/index` | 搜索页默认关键词为“卫生间”。 |
|
||||
| 搜索页 -> 结果卡片“查看” -> 路线页 | 最终可渲染路线详情页 | 首次进入时出现过短暂空白,后续编译完成后恢复。 |
|
||||
| 路线页 -> 查看室外地图 -> 返回预览 | 可切换到 `outdoor-preview`,并通过按钮回到 `preview` | `handleViewOutdoorMap` 与 `handleReturnToPreview` 有明确状态切换。 |
|
||||
| 搜索页 -> 设施卡片 -> 设施详情 -> 浏览器返回 | 可回到搜索页 | 测试中搜索筛选状态保留,例如“无障碍”过滤未丢失。 |
|
||||
| 首页室内 3D 主 CTA | 已从“开始馆内导航”调整为“选择目标地点”并跳搜索页 | 避免直接进入未完成的路线规划态。 |
|
||||
|
||||
## 浏览器验证问题
|
||||
|
||||
### P1:自定义导航页面缺少页面级返回/取消闭环
|
||||
|
||||
用户路径:搜索页 -> 设施详情页 / 路线详情页
|
||||
观察结果:页面使用自定义导航后,顶部统一展示“导览 / 讲解”切换,但详情和路线页没有清晰的页面级返回、关闭或取消入口;测试中主要依赖浏览器或系统返回恢复。
|
||||
期望闭环:二级页应在顶部或内容区提供明确的“返回上一页/取消本次选择/回到搜索结果”路径,并保留搜索关键词、筛选、目标对象和预览状态。
|
||||
影响:用户可以进入详情或路线页,但如果没有浏览器返回习惯,容易认为流程已卡死;在 H5 壳或嵌入式容器里,这个风险更高。
|
||||
源码证据:
|
||||
|
||||
- `src/pages.json:11` 至 `src/pages.json:47`:搜索、设施详情、路线详情等页面均配置 `navigationStyle: "custom"`。
|
||||
- `src/components/navigation/GuidePageFrame.vue:3` 至 `src/components/navigation/GuidePageFrame.vue:9`:页面框架只注入 `GuideTopTabs` 和内容 slot,没有返回/关闭能力。
|
||||
- `src/components/navigation/GuideTopTabs.vue:3` 至 `src/components/navigation/GuideTopTabs.vue:12`:顶部控件只渲染全局 tab。
|
||||
- `src/utils/guideTopTabs.ts:14` 至 `src/utils/guideTopTabs.ts:17`:tab 切换使用 `uni.reLaunch` 回首页 tab,不是页面返回。
|
||||
|
||||
建议:保留全局 `GuideTopTabs`,但在 `GuidePageFrame` 增加可选页面级 back/cancel 插槽或 props。搜索、设施详情、路线详情应显式提供返回上一页;路线/起点选择等多步骤页还应提供“取消本次导航/重置目标”。
|
||||
|
||||
### P1:搜索结果页不能重新输入或重置搜索
|
||||
|
||||
用户路径:首页导览 -> 搜索框 -> 搜索页 -> 点击搜索页顶部搜索框
|
||||
观察结果:点击搜索页顶部搜索框后只保持当前页,未打开输入框、搜索弹层或清空/重搜流程。
|
||||
期望闭环:用户应能在搜索结果页继续编辑关键词、清空关键词、提交新搜索,并能保留或重置筛选条件。
|
||||
影响:搜索是导览任务的主要入口。如果用户第一次默认进入“卫生间”结果后想改搜展厅、入口或设施,目前只能绕路返回首页再进,任务链路被打断。
|
||||
源码证据:
|
||||
|
||||
- `src/pages/search/index.vue:94`:默认 `searchKeyword` 为“卫生间”。
|
||||
- `src/pages/search/index.vue:153` 至 `src/pages/search/index.vue:157`:路由参数可覆盖关键词并加载结果。
|
||||
- `src/pages/search/index.vue:160` 至 `src/pages/search/index.vue:162`:`handleSearchTap` 仅 `console.log('保持搜索结果页')`。
|
||||
- `src/pages/search/index.vue:168` 至 `src/pages/search/index.vue:177`:结果卡片和“查看”可以继续跳详情/路线,但搜索本身没有再输入闭环。
|
||||
|
||||
建议:搜索页顶部搜索框应进入真实输入态。最小修复可以是打开一个输入面板,支持确认搜索、清空关键词和取消;更完整的方案是把首页搜索和搜索页搜索抽成同一套搜索组件。
|
||||
|
||||
### P1:路线页“查看目标位置”没有形成下一步
|
||||
|
||||
用户路径:搜索页 -> 结果“查看” -> 路线详情页 -> 点击“查看目标位置”
|
||||
观察结果:按钮只显示“正式路线数据尚未接入,可先查看馆内三维位置”的提示,没有切换到目标位置高亮、POI 卡片、3D 定位或可恢复状态。
|
||||
期望闭环:在真实路线数据未接入前,按钮文案和行为应稳定落到“位置预览”:例如高亮目标 POI、展示楼层/区域/说明、提供返回搜索和查看室外参考入口。
|
||||
影响:用户已经选择目标,但点击后没有获得更多可操作信息,会误以为目标定位失败或导航不可用。
|
||||
源码证据:
|
||||
|
||||
- `src/services/navAssets.ts:3`:路线不可用提示为“正式路线数据尚未接入,可先查看馆内三维位置”。
|
||||
- `src/pages/route/detail.vue:333` 至 `src/pages/route/detail.vue:338`:`handleShowTargetLocation` 仅 `uni.showToast`。
|
||||
- `src/pages/route/detail.vue:329` 至 `src/pages/route/detail.vue:341`:室外预览有状态切换和返回,但目标位置按钮没有同等状态落点。
|
||||
- `src/pages/route/detail.vue:84`:室外预览文案说明真实馆内路线需等待 `route_graph/nav_data` 接入。
|
||||
|
||||
建议:短期把该动作改为“高亮目标位置/查看位置详情”,并切换到明确的 preview 子状态;中期接入 POI 坐标后,在 3D 视图中定位目标点;正式导航能力上线前,不使用“开始导航/路线规划已完成”等表述。
|
||||
|
||||
### P1:设施详情“选择起点”没有起点选择流程
|
||||
|
||||
用户路径:搜索页 -> 设施详情 -> 点击“选择起点”
|
||||
观察结果:按钮只显示“请选择当前位置”,没有进入定位、手动选点、取消、确认或重置流程。
|
||||
期望闭环:起点选择应至少包含“使用当前位置 / 手动选择 / 取消”的状态,并把起点写入路线页参数或共享状态;失败时应说明原因和恢复路径。
|
||||
影响:用户无法完成“从哪里到目标设施”的前置步骤。即使之后点击查看位置/路线页,也只是目标预览,不是起点到终点的闭环。
|
||||
源码证据:
|
||||
|
||||
- `src/pages/facility/detail.vue:118` 至 `src/pages/facility/detail.vue:123`:`handleChooseStart` 仅 `uni.showToast`。
|
||||
- `src/pages/facility/detail.vue:125` 至 `src/pages/facility/detail.vue:129`:主按钮直接进入路线 `state=preview`,没有携带起点。
|
||||
- `src/pages/route/detail.vue:348` 至 `src/pages/route/detail.vue:352`:路线页手动选择也只是 `toast`。
|
||||
|
||||
建议:先把“选择起点”改造成受控步骤:进入 `selecting-start` 状态,显示取消、确认和手动位置候选;没有定位能力时明确禁用真实起点选择,并提供“只查看目标位置”。
|
||||
|
||||
### P2:首次进入路线页可能出现短暂空白,应有加载/降级态
|
||||
|
||||
用户路径:搜索页 -> 第一个结果“查看” -> 路线详情页
|
||||
观察结果:浏览器测试中首次进入路线页有过短暂空白,之后页面完成渲染。该现象在开发服务中可能由 Vite 首次编译或异步资源加载造成。
|
||||
期望闭环:即使在首次加载、资源未就绪或 3D 资源较慢时,也应显示稳定的页面骨架、加载中、失败重试和返回路径。
|
||||
影响:移动端网络或 WebGL 资源加载慢时,用户可能在白屏阶段退出;如果没有错误恢复,路线页会被认为不可用。
|
||||
源码证据:
|
||||
|
||||
- `src/pages/route/detail.vue:2` 至 `src/pages/route/detail.vue:96`:路线页主要内容挂在 `GuidePageFrame` 内,当前浏览器现象需要后续用生产构建验证加载态是否足够。
|
||||
- `src/components/navigation/GuidePageFrame.vue:35` 至 `src/components/navigation/GuidePageFrame.vue:60`:框架是满屏布局,内容未就绪时需要页面自身提供可见占位。
|
||||
|
||||
建议:路线页增加首屏 loading skeleton 和错误 fallback;对 3D/静态资源加载失败要给“重试 / 返回搜索 / 仅查看文字位置”的降级路径。此项需要用 `pnpm build:h5` 后的 H5 预览再验证一次。
|
||||
|
||||
### P2:全局顶部 tab 切换会重启首页,可能丢失当前二级流程
|
||||
|
||||
用户路径:设施详情或路线页 -> 点击顶部“讲解/导览”
|
||||
观察结果:顶部 tab 是全局切换,调用 `uni.reLaunch` 进入首页指定 tab。它适合全局入口切换,但不保留当前搜索、设施或路线流程。
|
||||
期望闭环:全局顶部菜单可以保持全局跳转,但二级流程页应同步提供独立返回/取消;如果用户正在多步骤任务中,切走前可考虑确认或保存上下文。
|
||||
影响:用户误触顶部 tab 后,当前目标、筛选和路线预览上下文可能被抛弃。
|
||||
源码证据:
|
||||
|
||||
- `src/utils/guideTopTabs.ts:12` 至 `src/utils/guideTopTabs.ts:17`:所有 tab 切换都 `reLaunch` 到首页。
|
||||
- `src/components/navigation/GuidePageFrame.vue:3` 至 `src/components/navigation/GuidePageFrame.vue:6`:所有使用该框架的页面都会显示顶部 tab。
|
||||
|
||||
建议:将顶部 tab 定义为全局主导航;把“返回/取消/继续当前任务”定义为页面导航。二者不要混用。对路线、起点选择、搜索结果这类有上下文的页面,增加页面内保护和恢复。
|
||||
|
||||
## 源码侧风险补充
|
||||
|
||||
| 风险 | 当前证据 | 处理建议 |
|
||||
| --- | --- | --- |
|
||||
| 路线能力仍是预览态 | `src/services/navAssets.ts:3` 保留正式路线未接入提示;`src/pages/route/detail.vue:84` 明确等待 `route_graph/nav_data` | 所有 UI 文案统一为“位置预览/查看位置”,避免“真实导航”承诺。 |
|
||||
| 搜索默认词会影响用户意图 | `src/pages/search/index.vue:94` 默认“卫生间” | 默认结果可以保留,但必须允许立即编辑、清空和重新搜索。 |
|
||||
| 起点未写入路线参数 | `src/pages/facility/detail.vue:125` 至 `src/pages/facility/detail.vue:129` 只传 `facilityId/target/state` | 增加 start 参数或共享状态,并定义取消/重置。 |
|
||||
| 顶部菜单不是返回按钮 | `src/utils/guideTopTabs.ts:14` 至 `src/utils/guideTopTabs.ts:17` 使用 `reLaunch` | 继续作为全局菜单使用,同时给页面加 back/cancel。 |
|
||||
|
||||
## 建议修复顺序
|
||||
|
||||
1. P1:给 `GuidePageFrame` 增加页面级返回/取消能力,并在搜索、设施详情、路线详情启用。
|
||||
2. P1:补齐搜索页顶部搜索框的输入、清空、取消、重新提交。
|
||||
3. P1:把设施详情“选择起点”改为明确的起点选择步骤;未接定位时使用禁用态或预览态文案。
|
||||
4. P1:把路线页“查看目标位置”落到真实 preview 子状态,不再只是 toast。
|
||||
5. P2:给路线页和 3D/位置预览增加加载、失败、重试、返回搜索的降级 UI。
|
||||
6. P2:为全局顶部 tab 切换和二级流程上下文定义保存或确认策略。
|
||||
|
||||
## 回归测试用例
|
||||
|
||||
| 用例 | 操作 | 预期 |
|
||||
| --- | --- | --- |
|
||||
| 搜索页重搜 | 首页 -> 搜索 -> 点击搜索页搜索框 -> 输入新关键词 -> 确认 | 结果按新关键词刷新,可清空,可取消回原结果。 |
|
||||
| 搜索筛选保留 | 搜索页选择“无障碍” -> 打开设施详情 -> 返回 | 回到搜索页后筛选和关键词保持。 |
|
||||
| 设施起点取消 | 设施详情 -> 选择起点 -> 取消 | 返回设施详情,不丢失目标设施。 |
|
||||
| 设施起点确认 | 设施详情 -> 选择起点 -> 确认 -> 查看位置 | 路线页能读取目标和起点;未接真实路线时明确展示预览态。 |
|
||||
| 路线目标预览 | 搜索结果“查看” -> 路线页 -> 查看目标位置 | 页面出现目标位置高亮或目标卡片,不只 toast。 |
|
||||
| 路线室外返回 | 路线页 -> 查看室外地图 -> 返回预览/返回室内 3D | 状态回到室内预览,目标对象不丢失。 |
|
||||
| 二级页返回 | 搜索 -> 设施详情/路线页 -> 点击页面返回 | 返回上一页,搜索关键词和筛选保留。 |
|
||||
| 顶部 tab 误触 | 路线页 -> 点击“讲解” -> 再回导览 | 行为符合产品定义;若会丢失路线上下文,应有明确提示或可恢复路径。 |
|
||||
| 首次加载路线 | 清缓存或首次打开 -> 搜索结果“查看” -> 路线页 | 不出现无反馈白屏;至少显示 loading、失败重试和返回。 |
|
||||
|
||||
## 本次测试边界
|
||||
|
||||
- 本报告接续旧会话的浏览器自动化结果,没有在当前会话重新启动 H5 服务或重复跑全量点击。
|
||||
- 本报告只覆盖 H5 用户逻辑闭环,不覆盖 mp-weixin。
|
||||
- 真实室内路线规划、定位、到达判定不在当前已验证能力内;需等待 `route_graph/nav_data` 接入并完成独立验证。
|
||||
- 当前文档只新增测试报告,不修改业务实现。
|
||||
132
docs/SHENZHEN_NATURAL_MUSEUM_DEV_SKILL_USAGE.md
Normal file
132
docs/SHENZHEN_NATURAL_MUSEUM_DEV_SKILL_USAGE.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# shenzhen-natural-museum-dev Skill 使用说明
|
||||
|
||||
本文档说明项目专用 Codex skill `shenzhen-natural-museum-dev` 的使用方式、触发场景和维护规则。该 skill 用于固化深圳自然博物馆 `frontend-miniapp` 项目的导览、三维模型、腾讯地图、静态资源、历史 demo 数据和 H5 质量标准。
|
||||
|
||||
## Skill 位置
|
||||
|
||||
- Skill 主文件:`.agents/skills/shenzhen-natural-museum-dev/SKILL.md`
|
||||
- Skill 展示元数据:`.agents/skills/shenzhen-natural-museum-dev/agents/openai.yaml`
|
||||
|
||||
说明文档放在 `docs/` 下,而不是放进 skill 目录。skill 目录应保持精简,只保留 AI 执行任务所需的必要文件。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
处理以下任务时应使用该 skill:
|
||||
|
||||
- 导览模块页面、组件、交互或数据流调整
|
||||
- 室内三维导览、Three.js、GLB/GLTF 模型加载、WebGL 性能问题
|
||||
- 腾讯地图、室外导览、地图 SDK 或地图标记逻辑调整
|
||||
- `static/nav-assets/app_nav_assets_v2_clean_20260609_075339` 资源包相关工作
|
||||
- `src/services/navAssets.ts` 导览数据服务相关工作
|
||||
- `src/assets/data` 历史 demo 数据清理、隔离或迁移
|
||||
- `route_graph`、`nav_data`、POI 坐标、路线规划能力判断
|
||||
- H5 适配、移动端遮挡、构建和质量门禁
|
||||
- 导览模块专项架构审计或上线风险审查
|
||||
|
||||
默认只关注 H5 模式。除非任务明确提到“小程序”“mp-weixin”或“小程序构建”,否则不要把小程序兼容作为默认目标。
|
||||
|
||||
## 如何触发
|
||||
|
||||
在 Codex 任务中可以显式写明:
|
||||
|
||||
```text
|
||||
请使用 shenzhen-natural-museum-dev skill,检查导览模块的 Three.js 模型加载问题。
|
||||
```
|
||||
|
||||
也可以在任务描述里包含明确上下文,Codex 应能自动匹配:
|
||||
|
||||
```text
|
||||
帮我修复室内三维导览切换后顶部菜单被遮挡的问题。
|
||||
```
|
||||
|
||||
```text
|
||||
请检查腾讯地图和室内 3D 是否有耦合风险。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我清理导览模块里依赖 src/assets/data 的旧 demo 数据。
|
||||
```
|
||||
|
||||
## Skill 固化的核心规则
|
||||
|
||||
- 导览业务数据必须优先走 `src/services/navAssets.ts`。
|
||||
- 当前干净导览资源包为 `static/nav-assets/app_nav_assets_v2_clean_20260609_075339`。
|
||||
- `src/assets/data` 是历史 demo 数据区域,不应再作为当前导览模块的数据源。
|
||||
- 在 `route_graph` 和 `nav_data` 未准备并验证前,不应声明“真实馆内导航”或启用真实路线规划。
|
||||
- POI 坐标只能作为展示或位置预览候选,不能当作已认证导航锚点。
|
||||
- Three.js / GLB 室内三维逻辑应与腾讯地图 / 室外导览逻辑隔离。
|
||||
- Three.js/WebGL 当前按 H5 能力处理;不要默认增加小程序 fallback 或 mp-weixin 兼容工作。
|
||||
- 移动端顶部菜单、搜索、卡片、按钮和楼层控件不能被 3D canvas 遮挡。
|
||||
- 不做无关重构,不直接删除历史数据;先盘点引用、隔离影响,再按确认范围清理。
|
||||
|
||||
## 推荐任务写法
|
||||
|
||||
导览功能开发:
|
||||
|
||||
```text
|
||||
请使用 shenzhen-natural-museum-dev skill,基于当前 clean nav assets 修改导览搜索结果跳转逻辑,不能依赖 src/assets/data 旧数据。
|
||||
```
|
||||
|
||||
三维模型问题:
|
||||
|
||||
```text
|
||||
请使用 shenzhen-natural-museum-dev skill,排查室内 3D 模型加载失败,并保证 H5 有加载中和失败兜底。
|
||||
```
|
||||
|
||||
腾讯地图问题:
|
||||
|
||||
```text
|
||||
请使用 shenzhen-natural-museum-dev skill,检查腾讯地图逻辑是否被室内导览改动影响,只读审计并给出证据文件。
|
||||
```
|
||||
|
||||
历史数据清理:
|
||||
|
||||
```text
|
||||
请使用 shenzhen-natural-museum-dev skill,先只读盘点 src/assets/data 旧 demo 数据在项目中的引用,不要删除文件。
|
||||
```
|
||||
|
||||
架构审计:
|
||||
|
||||
```text
|
||||
请使用 shenzhen-natural-museum-dev skill,对导览、腾讯地图、Three.js、静态资源和 H5 构建做一次只读架构审计。
|
||||
```
|
||||
|
||||
## 验证要求
|
||||
|
||||
涉及代码修改时,优先根据风险运行以下检查:
|
||||
|
||||
```powershell
|
||||
pnpm type-check
|
||||
pnpm lint
|
||||
pnpm build:h5
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `pnpm build:*` 会写入 `dist`,如果任务是只读审计,应先说明风险再执行。
|
||||
- `pnpm lint` 即使命令成功,警告也应视为技术债。
|
||||
- 导览 UI 改动后,应额外做 H5 冒烟检查:顶部菜单、室内 3D、开始导航/位置预览、搜索结果、路线详情页。
|
||||
- 只有用户明确要求小程序/mp-weixin 时,才额外运行 `pnpm build:mp-weixin` 或处理小程序兼容。
|
||||
|
||||
## 维护方式
|
||||
|
||||
更新项目架构标准时,优先修改:
|
||||
|
||||
```text
|
||||
.agents/skills/shenzhen-natural-museum-dev/SKILL.md
|
||||
```
|
||||
|
||||
修改后运行 skill 校验:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONUTF8='1'
|
||||
python C:\Users\Administrator\.codex\skills\.system\skill-creator\scripts\quick_validate.py .agents\skills\shenzhen-natural-museum-dev
|
||||
```
|
||||
|
||||
如果展示名称、默认提示词或简短说明需要调整,再同步更新:
|
||||
|
||||
```text
|
||||
.agents/skills/shenzhen-natural-museum-dev/agents/openai.yaml
|
||||
```
|
||||
|
||||
不要在 skill 目录下新增 README、CHANGELOG 或临时说明文件;团队说明文档统一放在 `docs/` 下。
|
||||
9
docs/pdca/actions.md
Normal file
9
docs/pdca/actions.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# PDCA 行动
|
||||
|
||||
| id | action | owner | due | status | source |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| A-001 | 对 guide.whaoyue.com 执行 fresh browser/真实设备室内 3D 冒烟测试 | TBD | 2026-06-11 | todo | C-008 / R-002 |
|
||||
| A-002 | 将用户流程闭环审计拆成可执行修复任务并排期 | TBD | 2026-06-11 | todo | C-007 / R-001 |
|
||||
| A-003 | 确认下一轮发布验收标准和负责人 | TBD | 2026-06-11 | todo | M-005 |
|
||||
| A-004 | 监控模型资源缓存与 404 行为,确认无 `<!DOCTYPE` JSON 解析报错复发 | TBD | 2026-06-11 | todo | R-003 |
|
||||
| A-005 | 梳理当前工作区未提交改动,明确本轮交付边界 | TBD | 2026-06-11 | todo | R-004 |
|
||||
14
docs/pdca/checks.md
Normal file
14
docs/pdca/checks.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# PDCA 检查
|
||||
|
||||
| id | check | result | date | evidence | notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| C-001 | H5 构建 | pass | 2026-06-10 | `pnpm build:h5` 通过,复制 `static/nav-assets` | Sass legacy API warning 不阻塞 |
|
||||
| C-002 | 类型检查 | pass | 2026-06-10 | `pnpm type-check` 通过 | |
|
||||
| C-003 | Nginx 配置检查 | pass | 2026-06-10 | `docker exec nginx-server nginx -t` 通过 | 存在 http2 deprecated warning,不影响加载 |
|
||||
| C-004 | manifest 公网响应 | pass | 2026-06-10 | `curl -I https://guide.whaoyue.com/static/nav-assets/app_nav_assets_v2_clean_20260609_075339/app_nav_manifest.json` 返回 200 JSON | |
|
||||
| C-005 | 楼层 GLB 公网响应 | pass | 2026-06-10 | `curl -I .../models_by_floor/L1.glb` 返回 200 `model/gltf-binary` | |
|
||||
| C-006 | 缺失模型资源处理 | pass | 2026-06-10 | `curl -I .../models_by_floor/MISSING.glb` 返回 404 | 已避免 SPA fallback 返回 `index.html` |
|
||||
| C-007 | H5 用户流程闭环审计 | fail | 2026-06-10 | `docs/QA/user-flow-closure-audit-2026-06-10.md` | 搜索、路线、设施和详情页仍需闭环修复 |
|
||||
| C-008 | in-app browser fresh 3D 复测 | pending | 2026-06-10 | | 浏览器插件初始化异常,需后续复测 |
|
||||
| C-009 | PDCA 报告与面板生成 | pass | 2026-06-10 | `docs/pdca/reports/2026-06-10.md`;`docs/pdca/dashboard.html`;`docs/pdca/dashboard.png` | |
|
||||
| C-010 | 根目录 TXT 校验文件公网访问 | pass | 2026-06-10 | `curl -L https://guide.whaoyue.com/gpL0svkeao.txt` 返回 `b772216640a14171ba5655085c8523be` | 文件位于 `/dmdata/nginx/html/guide/gpL0svkeao.txt` |
|
||||
1595
docs/pdca/dashboard.html
Normal file
1595
docs/pdca/dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/pdca/dashboard.png
Normal file
BIN
docs/pdca/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
56
docs/pdca/dashboard.svg
Normal file
56
docs/pdca/dashboard.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
8
docs/pdca/decisions.md
Normal file
8
docs/pdca/decisions.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# PDCA 决策
|
||||
|
||||
| id | date | decision | status | owner | needed_by | recommendation | rationale |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| D-001 | 2026-06-10 | 当前线上部署目标使用 H5 模式 | accepted | Codex | | 使用 `pnpm build:h5` | 用户明确要求 H5 模式打包部署 |
|
||||
| D-002 | 2026-06-10 | 导览模型资源通过 `/static/nav-assets/` 提供,并由 Nginx 对缺失资源返回 404 | accepted | Codex | | 保持专用 location | 避免资源缺失时返回 `index.html` 导致 JSON/GLB 解析错误 |
|
||||
| D-003 | 2026-06-10 | 室内 3D 首屏默认加载当前楼层模型 | accepted | Codex | | 保持 `initial-view="floor"` | 降低首屏模型解析压力,导览页已有楼层切换上下文 |
|
||||
| D-004 | 2026-06-10 | 是否以用户流程闭环作为下一轮发布准入 | needed | TBD | 2026-06-11 | 建议纳入准入 | 当前 QA 审计显示关键任务闭环仍未完成 |
|
||||
9
docs/pdca/milestones.md
Normal file
9
docs/pdca/milestones.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# PDCA 里程碑
|
||||
|
||||
| id | milestone | status | owner | due | evidence | notes |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| M-001 | H5 测试服务器可访问 | done | Codex | 2026-06-10 | https://guide.whaoyue.com/ 已部署;Nginx reload 成功 | Now |
|
||||
| M-002 | 室内 3D 模型资源请求修复 | done | Codex | 2026-06-10 | manifest 200 JSON;L1.glb 200 GLB;缺失 GLB 404 | Now |
|
||||
| M-003 | 部署说明与回滚流程沉淀 | done | Codex | 2026-06-10 | `docs/H5_DEPLOYMENT_GUIDE.md` | Now |
|
||||
| M-004 | H5 关键用户流程闭环 | todo | TBD | TBD | `docs/QA/user-flow-closure-audit-2026-06-10.md` | Next |
|
||||
| M-005 | 发布验收与交付确认 | todo | TBD | TBD | | Later |
|
||||
34
docs/pdca/plan.md
Normal file
34
docs/pdca/plan.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# PDCA 计划
|
||||
|
||||
项目:深圳自然博物馆 H5 智能导览前端
|
||||
|
||||
当前目标:完成 `guide.whaoyue.com` H5 部署闭环,确保室内 3D 模型资源可稳定加载,并把上线验证、风险、后续用户流程闭环工作纳入可追踪进度。
|
||||
|
||||
## 范围
|
||||
|
||||
- H5 模式构建与部署。
|
||||
- Nginx、SSL、静态资源和模型资源服务配置。
|
||||
- 室内 3D 模型加载链路,包括 manifest、楼层 GLB、POI 数据和缺失资源处理。
|
||||
- 部署说明、验证记录、风险和后续行动管理。
|
||||
|
||||
## 暂不覆盖
|
||||
|
||||
- 微信小程序 `mp-weixin` 发布。
|
||||
- 真实内容数据最终验收。
|
||||
- 全量用户流程闭环修复,这部分当前作为后续行动和风险跟踪。
|
||||
|
||||
## 成功指标
|
||||
|
||||
- `pnpm build:h5` 成功,且 H5 产物包含 `static/nav-assets`。
|
||||
- `pnpm type-check` 成功。
|
||||
- Nginx 配置测试成功并 reload。
|
||||
- 线上 manifest 返回 `application/json`。
|
||||
- 线上 GLB 返回 `model/gltf-binary`。
|
||||
- 缺失模型资源返回 404,不返回 `index.html`。
|
||||
- 室内 3D 默认加载当前楼层模型,降低首屏解析压力。
|
||||
- 部署文档和 PDCA 进度可审计。
|
||||
|
||||
## 当前判断
|
||||
|
||||
- 部署与模型请求修复已完成。
|
||||
- 项目整体仍有用户流程闭环风险,需在下一轮集中处理搜索、详情、路线、设施选择、返回/取消/重试等关键链路。
|
||||
83
docs/pdca/reports/2026-06-10.md
Normal file
83
docs/pdca/reports/2026-06-10.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# PDCA 项目进度报告 - 2026-06-10
|
||||
|
||||
## 健康状态
|
||||
|
||||
- 状态:红色
|
||||
- 原因:2 个高影响风险仍开放;1 个决策待确认
|
||||
- 项目:`E:\MyWork\深圳国际艺术馆\museum-guide\museum-guide-v4.0\frontend-miniapp`
|
||||
- Git:master@a90f63c,变更文件数:22
|
||||
|
||||
## 专业状态维度
|
||||
|
||||
| 维度 | 状态 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 进度 | 黄色 | 开放工作仍有截止日期待确认 |
|
||||
| 范围 | 黄色 | 目标或里程碑仍待确认 |
|
||||
| 风险 | 红色 | 高影响风险仍开放 |
|
||||
| 证据 | 红色 | 存在未通过的验收检查 |
|
||||
| 决策 | 黄色 | 需要干系人确认决策 |
|
||||
|
||||
## 计划
|
||||
|
||||
- 查看 `docs/pdca/plan.md` 获取目标、里程碑、范围与成功指标。
|
||||
- 如果目标、里程碑、负责人或截止日期仍待确认,项目基线尚未完整。
|
||||
|
||||
### 里程碑
|
||||
|
||||
- M-001 | H5 测试服务器可访问 | 已完成 | Codex | 2026-06-10
|
||||
- M-002 | 室内 3D 模型资源请求修复 | 已完成 | Codex | 2026-06-10
|
||||
- M-003 | 部署说明与回滚流程沉淀 | 已完成 | Codex | 2026-06-10
|
||||
- M-004 | H5 关键用户流程闭环 | 待办 | 待确认 | 待确认
|
||||
- M-005 | 发布验收与交付确认 | 待办 | 待确认 | 待确认
|
||||
|
||||
## 执行
|
||||
|
||||
- 任务统计:已完成: 7, 待办: 2
|
||||
- 完成率口径:仅统计 `status=done` 且 `evidence` 非空的任务。
|
||||
- 已完成并有证据:7/9
|
||||
- 标记完成但缺少证据:0
|
||||
- T-001 | 配置 H5 模型资源打包 | 已完成 | Codex | 2026-06-10
|
||||
- T-002 | 修复 Nginx 模型静态资源规则 | 已完成 | Codex | 2026-06-10
|
||||
- T-003 | 部署 H5 到 guide.whaoyue.com | 已完成 | Codex | 2026-06-10
|
||||
- T-004 | 优化室内 3D 首屏加载策略 | 已完成 | Codex | 2026-06-10
|
||||
- T-005 | 更新 H5 部署文档 | 已完成 | Codex | 2026-06-10
|
||||
- T-006 | 生成并维护 PDCA 项目进度 | 已完成 | Codex | 2026-06-10
|
||||
- T-007 | 浏览器 fresh load 复测室内 3D | 待办 | 待确认 | 2026-06-11
|
||||
- T-008 | 修复 H5 用户流程闭环 P0/P1 问题 | 待办 | 待确认 | 待确认
|
||||
- T-009 | 上传域名根目录校验文件 | 已完成 | Codex | 2026-06-10
|
||||
|
||||
## 检查
|
||||
|
||||
### 阻塞
|
||||
|
||||
- 暂无阻塞记录。
|
||||
|
||||
### 逾期任务
|
||||
|
||||
- 未发现逾期任务。
|
||||
|
||||
### 逾期里程碑
|
||||
|
||||
- 未发现逾期里程碑。
|
||||
|
||||
### 缺少证据
|
||||
|
||||
- 完成/复核状态任务均有证据或暂无此类任务。
|
||||
|
||||
### 高影响开放风险
|
||||
|
||||
- R-001 | H5 用户流程闭环仍有断点,影响真实到馆任务完成 | 开放 | 按 `docs/QA/user-flow-closure-audit-2026-06-10.md` 拆分搜索、详情、路线、设施选择、返回/取消/重试任务 | 待确认
|
||||
- R-003 | 模型静态资源缓存 30 天,错误资源上线后恢复慢 | 缓解中 | 模型包路径带版本号;缺失资源返回 404;部署时使用 URL 参数强刷前端包 | Codex
|
||||
|
||||
## 行动
|
||||
|
||||
- A-001 | 对 guide.whaoyue.com 执行 fresh browser/真实设备室内 3D 冒烟测试 | 待确认 | 2026-06-11 | C-008 / R-002
|
||||
- A-002 | 将用户流程闭环审计拆成可执行修复任务并排期 | 待确认 | 2026-06-11 | C-007 / R-001
|
||||
- A-003 | 确认下一轮发布验收标准和负责人 | 待确认 | 2026-06-11 | M-005
|
||||
- A-004 | 监控模型资源缓存与 404 行为,确认无 `<!DOCTYPE` JSON 解析报错复发 | 待确认 | 2026-06-11 | R-003
|
||||
- A-005 | 梳理当前工作区未提交改动,明确本轮交付边界 | 待确认 | 2026-06-11 | R-004
|
||||
|
||||
### 决策待确认
|
||||
|
||||
- D-004 | 是否以用户流程闭环作为下一轮发布准入 | 待确认 | 2026-06-11 | 建议纳入准入
|
||||
|
||||
8
docs/pdca/risks.md
Normal file
8
docs/pdca/risks.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# PDCA 风险
|
||||
|
||||
| id | risk | probability | impact | status | mitigation | owner |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| R-001 | H5 用户流程闭环仍有断点,影响真实到馆任务完成 | high | high | open | 按 `docs/QA/user-flow-closure-audit-2026-06-10.md` 拆分搜索、详情、路线、设施选择、返回/取消/重试任务 | TBD |
|
||||
| R-002 | fresh browser 复测未完成,可能漏掉真实设备上的 3D 渲染问题 | medium | medium | watch | 使用浏览器强刷、移动端视口和真实设备补测室内 3D;保留 HTTP 资源验证作为最低证据 | TBD |
|
||||
| R-003 | 模型静态资源缓存 30 天,错误资源上线后恢复慢 | medium | high | mitigating | 模型包路径带版本号;缺失资源返回 404;部署时使用 URL 参数强刷前端包 | Codex |
|
||||
| R-004 | 项目仍有多处未提交/未归属改动,发布边界可能不清 | medium | medium | watch | 发布前整理变更清单,确认哪些进入本轮交付,哪些留作后续 | TBD |
|
||||
13
docs/pdca/tasks.md
Normal file
13
docs/pdca/tasks.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# PDCA 任务
|
||||
|
||||
| id | title | phase | status | owner | due | evidence | blockers |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| T-001 | 配置 H5 模型资源打包 | do | done | Codex | 2026-06-10 | `package.json` build:h5 调用 `scripts/copy-h5-nav-assets.cjs`;`pnpm build:h5` 通过 | |
|
||||
| T-002 | 修复 Nginx 模型静态资源规则 | do | done | Codex | 2026-06-10 | `/dmdata/nginx/conf.d/guide.whaoyue.com.conf` 增加 `/static/nav-assets/`,`docker exec nginx-server nginx -t` 通过 | |
|
||||
| T-003 | 部署 H5 到 guide.whaoyue.com | do | done | Codex | 2026-06-10 | 服务器备份 `/dmdata/nginx/html/_backups/guide-before-floor-first-20260610033959.tar.gz`;线上文件数 48 | |
|
||||
| T-004 | 优化室内 3D 首屏加载策略 | do | done | Codex | 2026-06-10 | `src/components/navigation/GuideMapShell.vue` 传入 `initial-view="floor"` 和当前楼层 ID | |
|
||||
| T-005 | 更新 H5 部署文档 | act | done | Codex | 2026-06-10 | `docs/H5_DEPLOYMENT_GUIDE.md`;`README.md` 已链接部署文档 | |
|
||||
| T-006 | 生成并维护 PDCA 项目进度 | act | done | Codex | 2026-06-10 | `docs/pdca/reports/2026-06-10.md`;`docs/pdca/dashboard.html`;`docs/pdca/dashboard.png` | |
|
||||
| T-007 | 浏览器 fresh load 复测室内 3D | check | todo | TBD | 2026-06-11 | | 当前 in-app browser 调试通道初始化异常,需人工或恢复工具后复测 |
|
||||
| T-008 | 修复 H5 用户流程闭环 P0/P1 问题 | do | todo | TBD | TBD | `docs/QA/user-flow-closure-audit-2026-06-10.md` | 需确认优先级、负责人和截止日期 |
|
||||
| T-009 | 上传域名根目录校验文件 | do | done | Codex | 2026-06-10 | `https://guide.whaoyue.com/gpL0svkeao.txt` 返回 `b772216640a14171ba5655085c8523be` | |
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev:h5": "uni -p h5",
|
||||
"build:h5": "uni build -p h5",
|
||||
"build:h5": "uni build -p h5 && node scripts/copy-h5-nav-assets.cjs",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
|
||||
44
scripts/copy-h5-nav-assets.cjs
Normal file
44
scripts/copy-h5-nav-assets.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const sourceDir = path.join(projectRoot, 'static', 'nav-assets')
|
||||
const h5Root = path.join(projectRoot, 'dist', 'build', 'h5')
|
||||
const targetDir = path.join(h5Root, 'static', 'nav-assets')
|
||||
const faviconSource = path.join(projectRoot, 'public', 'favicon.svg')
|
||||
const faviconTarget = path.join(h5Root, 'favicon.svg')
|
||||
|
||||
if (!fs.existsSync(h5Root)) {
|
||||
throw new Error(`H5 build output not found: ${h5Root}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
throw new Error(`Navigation assets not found: ${sourceDir}`)
|
||||
}
|
||||
|
||||
const copyDirectory = (source, target) => {
|
||||
fs.mkdirSync(target, { recursive: true })
|
||||
|
||||
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
||||
const sourcePath = path.join(source, entry.name)
|
||||
const targetPath = path.join(target, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDirectory(sourcePath, targetPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
fs.copyFileSync(sourcePath, targetPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(targetDir, { recursive: true, force: true })
|
||||
copyDirectory(sourceDir, targetDir)
|
||||
|
||||
if (fs.existsSync(faviconSource)) {
|
||||
fs.copyFileSync(faviconSource, faviconTarget)
|
||||
}
|
||||
|
||||
console.log(`Copied navigation assets to ${path.relative(projectRoot, targetDir)}`)
|
||||
@@ -101,6 +101,24 @@ interface CleanPoi {
|
||||
positionGltf?: [number, number, number]
|
||||
}
|
||||
|
||||
interface TargetPoiFocusRequest {
|
||||
requestId: number | string
|
||||
poiId: string
|
||||
name?: string
|
||||
floorId: string
|
||||
floorLabel?: string
|
||||
primaryCategoryZh?: string
|
||||
positionGltf?: [number, number, number]
|
||||
}
|
||||
|
||||
interface TargetPoiFocusResult {
|
||||
requestId: number | string
|
||||
poiId: string
|
||||
floorId: string
|
||||
status: 'focused' | 'missing' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface FloorPoiData {
|
||||
status: string
|
||||
floorId: string
|
||||
@@ -115,17 +133,20 @@ const props = withDefaults(defineProps<{
|
||||
initialView?: ViewMode
|
||||
showControls?: boolean
|
||||
showPoi?: boolean
|
||||
targetFocus?: TargetPoiFocusRequest | null
|
||||
}>(), {
|
||||
assetBaseUrl: NAV_ASSET_BASE_URL,
|
||||
initialFloorId: 'L1',
|
||||
initialView: 'overview',
|
||||
showControls: true,
|
||||
showPoi: false
|
||||
showPoi: false,
|
||||
targetFocus: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
floorChange: [floorId: string]
|
||||
poiClick: [poi: CleanPoi]
|
||||
targetFocus: [result: TargetPoiFocusResult]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<ContainerRef>(null)
|
||||
@@ -136,6 +157,7 @@ const loadingMessage = ref('正在读取室内导览资源...')
|
||||
const activeView = ref<ViewMode>(props.initialView)
|
||||
const currentFloor = ref(props.initialFloorId)
|
||||
const selectedPOI = ref<CleanPoi | null>(null)
|
||||
const activeFocusPoiId = ref('')
|
||||
const floorIndex = ref<FloorIndexItem[]>([])
|
||||
|
||||
const floors = computed<FloorOption[]>(() => (
|
||||
@@ -146,6 +168,7 @@ const floors = computed<FloorOption[]>(() => (
|
||||
label: formatFloorLabel(floor.floorId)
|
||||
}))
|
||||
))
|
||||
const shouldRenderPoiMarkers = computed(() => props.showPoi || props.showControls || Boolean(props.targetFocus))
|
||||
|
||||
let manifest: CleanNavManifest | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
@@ -158,6 +181,8 @@ let poiGroup: THREE.Group | null = null
|
||||
let animationId = 0
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let isDisposed = false
|
||||
let pendingTargetFocus: TargetPoiFocusRequest | null = null
|
||||
let targetFocusQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
|
||||
@@ -410,7 +435,7 @@ const loadFloor = async (floorId: string) => {
|
||||
scene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
|
||||
if (props.showPoi || props.showControls) {
|
||||
if (shouldRenderPoiMarkers.value) {
|
||||
await loadFloorPOIs(floor)
|
||||
}
|
||||
}
|
||||
@@ -463,20 +488,90 @@ const getPoiColor = (category: string) => {
|
||||
return colorMap[category] || '#1f2329'
|
||||
}
|
||||
|
||||
const getPoiMarkerSize = () => (
|
||||
activeModel
|
||||
? Math.max(new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()).length() * 0.012, 2.4)
|
||||
: 3
|
||||
)
|
||||
|
||||
const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
|
||||
const baseScale = typeof sprite.userData.baseScale === 'number'
|
||||
? sprite.userData.baseScale
|
||||
: sprite.scale.x
|
||||
const scale = focused ? baseScale * 1.55 : baseScale
|
||||
|
||||
sprite.scale.set(scale, scale, scale)
|
||||
sprite.renderOrder = focused ? 20 : 10
|
||||
sprite.material.opacity = focused ? 1 : 0.78
|
||||
}
|
||||
|
||||
const updatePoiMarkerFocus = () => {
|
||||
if (!poiGroup) return
|
||||
|
||||
poiGroup.children.forEach((child) => {
|
||||
if (child instanceof THREE.Sprite) {
|
||||
const poi = child.userData.poi as CleanPoi | undefined
|
||||
setPoiSpriteFocusStyle(child, Boolean(poi && poi.id === activeFocusPoiId.value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const findPoiSprite = (poiId: string) => (
|
||||
poiGroup?.children.find((child): child is THREE.Sprite => (
|
||||
child instanceof THREE.Sprite && (child.userData.poi as CleanPoi | undefined)?.id === poiId
|
||||
))
|
||||
)
|
||||
|
||||
const findLoadedPoi = (poiId: string) => (
|
||||
findPoiSprite(poiId)?.userData.poi as CleanPoi | undefined
|
||||
)
|
||||
|
||||
const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): CleanPoi | null => {
|
||||
if (!request.positionGltf) return null
|
||||
|
||||
return {
|
||||
id: request.poiId,
|
||||
name: request.name || '目标位置',
|
||||
floorId: request.floorId,
|
||||
primaryCategory: 'target_preview',
|
||||
primaryCategoryZh: request.primaryCategoryZh || '位置预览',
|
||||
iconType: 'target_preview',
|
||||
positionGltf: request.positionGltf
|
||||
}
|
||||
}
|
||||
|
||||
const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
|
||||
if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null
|
||||
|
||||
const poi = createPoiFromFocusRequest(request)
|
||||
if (!poi?.positionGltf) return null
|
||||
|
||||
const [x, y, z] = poi.positionGltf
|
||||
const markerSize = getPoiMarkerSize()
|
||||
const sprite = new THREE.Sprite(createPoiMaterial(poi))
|
||||
sprite.position.set(x, y + markerSize * 0.5, z)
|
||||
sprite.userData.baseScale = markerSize
|
||||
sprite.userData.poi = poi
|
||||
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
|
||||
poiGroup.add(sprite)
|
||||
|
||||
return poi
|
||||
}
|
||||
|
||||
const loadFloorPOIs = async (floor: FloorIndexItem) => {
|
||||
if (!poiGroup) return
|
||||
|
||||
const data = await fetchJson<FloorPoiData>(assetUrl(floor.poiDataAsset))
|
||||
const validPois = data.pois.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||
const markerSize = activeModel ? Math.max(new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()).length() * 0.012, 2.4) : 3
|
||||
const markerSize = getPoiMarkerSize()
|
||||
|
||||
validPois.forEach((poi) => {
|
||||
const [x, y, z] = poi.positionGltf!
|
||||
const sprite = new THREE.Sprite(createPoiMaterial(poi))
|
||||
sprite.position.set(x, y + markerSize * 0.5, z)
|
||||
sprite.scale.set(markerSize, markerSize, markerSize)
|
||||
sprite.renderOrder = 10
|
||||
sprite.userData.baseScale = markerSize
|
||||
sprite.userData.poi = poi
|
||||
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
|
||||
poiGroup!.add(sprite)
|
||||
})
|
||||
}
|
||||
@@ -495,10 +590,134 @@ const handlePointerDown = (event: PointerEvent) => {
|
||||
|
||||
if (hits[0]?.object.userData.poi) {
|
||||
selectedPOI.value = hits[0].object.userData.poi as CleanPoi
|
||||
activeFocusPoiId.value = selectedPOI.value.id
|
||||
updatePoiMarkerFocus()
|
||||
emit('poiClick', selectedPOI.value)
|
||||
}
|
||||
}
|
||||
|
||||
const emitTargetFocus = (
|
||||
request: TargetPoiFocusRequest,
|
||||
status: TargetPoiFocusResult['status'],
|
||||
message?: string
|
||||
) => {
|
||||
emit('targetFocus', {
|
||||
requestId: request.requestId,
|
||||
poiId: request.poiId,
|
||||
floorId: request.floorId,
|
||||
status,
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
const clearTargetFocus = () => {
|
||||
pendingTargetFocus = null
|
||||
activeFocusPoiId.value = ''
|
||||
selectedPOI.value = null
|
||||
updatePoiMarkerFocus()
|
||||
}
|
||||
|
||||
const isSceneReadyForTargetFocus = () => (
|
||||
Boolean(scene && camera && controls && loader && floorIndex.value.length)
|
||||
&& !isLoading.value
|
||||
&& !loadError.value
|
||||
)
|
||||
|
||||
const focusCameraOnPoi = (poi: CleanPoi) => {
|
||||
if (!camera || !controls || !poi.positionGltf) return
|
||||
|
||||
const [x, y, z] = poi.positionGltf
|
||||
const target = new THREE.Vector3(x, y, z)
|
||||
const modelSize = activeModel
|
||||
? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
|
||||
: new THREE.Vector3(80, 80, 80)
|
||||
const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1)
|
||||
const distance = Math.min(Math.max(maxDim * 0.22, 28), Math.max(maxDim * 0.55, 80))
|
||||
const direction = new THREE.Vector3(0.72, 0.58, 1).normalize()
|
||||
|
||||
camera.position.copy(target).add(direction.multiplyScalar(distance))
|
||||
camera.near = Math.max(0.1, maxDim / 1200)
|
||||
camera.far = Math.max(10000, maxDim * 20)
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
controls.target.copy(target)
|
||||
controls.minDistance = Math.max(2, maxDim * 0.025)
|
||||
controls.maxDistance = Math.max(20, maxDim * 6)
|
||||
controls.update()
|
||||
}
|
||||
|
||||
const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
||||
if (!request.poiId || !request.floorId) {
|
||||
emitTargetFocus(request, 'missing', '目标位置数据不完整')
|
||||
return false
|
||||
}
|
||||
|
||||
const floor = floorIndex.value.find((item) => item.floorId === request.floorId)
|
||||
if (!floor) {
|
||||
emitTargetFocus(request, 'missing', '目标楼层不在当前三维资源中')
|
||||
return false
|
||||
}
|
||||
|
||||
activeFocusPoiId.value = request.poiId
|
||||
|
||||
try {
|
||||
if (activeView.value !== 'floor' || currentFloor.value !== request.floorId) {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
await loadFloor(request.floorId)
|
||||
isLoading.value = false
|
||||
emit('floorChange', request.floorId)
|
||||
} else if (shouldRenderPoiMarkers.value && poiGroup && poiGroup.children.length === 0) {
|
||||
await loadFloorPOIs(floor)
|
||||
}
|
||||
|
||||
const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request)
|
||||
|
||||
if (!poi?.positionGltf) {
|
||||
selectedPOI.value = null
|
||||
updatePoiMarkerFocus()
|
||||
emitTargetFocus(request, 'missing', '目标暂无三维坐标')
|
||||
return false
|
||||
}
|
||||
|
||||
selectedPOI.value = poi
|
||||
updatePoiMarkerFocus()
|
||||
focusCameraOnPoi(poi)
|
||||
emitTargetFocus(request, 'focused')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('目标 POI 聚焦失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
setProgress(0, error instanceof Error ? error.message : '目标位置聚焦失败')
|
||||
emitTargetFocus(request, 'error', error instanceof Error ? error.message : '目标位置聚焦失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
|
||||
if (!request) {
|
||||
clearTargetFocus()
|
||||
return
|
||||
}
|
||||
|
||||
pendingTargetFocus = request
|
||||
|
||||
if (!isSceneReadyForTargetFocus()) return
|
||||
|
||||
const nextRequest = pendingTargetFocus
|
||||
if (!nextRequest) return
|
||||
|
||||
pendingTargetFocus = null
|
||||
targetFocusQueue = targetFocusQueue
|
||||
.then(() => focusTargetPoi(nextRequest))
|
||||
.finally(() => {
|
||||
if (pendingTargetFocus) {
|
||||
queueTargetFocus(pendingTargetFocus)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const loadCleanPackage = async () => {
|
||||
setProgress(8, '正在读取室内导览资源...')
|
||||
manifest = await fetchJson<CleanNavManifest>(assetUrl('app_nav_manifest.json'))
|
||||
@@ -537,6 +756,7 @@ const init3DScene = async () => {
|
||||
|
||||
setProgress(100, '室内三维模型加载完成')
|
||||
isLoading.value = false
|
||||
queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
|
||||
} catch (error) {
|
||||
console.error('室内 3D clean package 加载失败:', error)
|
||||
loadError.value = true
|
||||
@@ -564,6 +784,7 @@ const showOverview = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
activeFocusPoiId.value = ''
|
||||
await loadOverview()
|
||||
isLoading.value = false
|
||||
} catch (error) {
|
||||
@@ -613,8 +834,13 @@ const disposeScene = () => {
|
||||
defineExpose({
|
||||
switchFloor: handleFloorChange,
|
||||
showOverview,
|
||||
focusTargetPoi: (request: TargetPoiFocusRequest) => {
|
||||
queueTargetFocus(request)
|
||||
},
|
||||
clearNavigation: () => {
|
||||
activeFocusPoiId.value = ''
|
||||
selectedPOI.value = null
|
||||
updatePoiMarkerFocus()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -622,6 +848,13 @@ watch(() => props.assetBaseUrl, () => {
|
||||
init3DScene()
|
||||
})
|
||||
|
||||
watch(() => props.targetFocus, (request) => {
|
||||
queueTargetFocus(request || null)
|
||||
}, {
|
||||
deep: true,
|
||||
immediate: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
init3DScene()
|
||||
})
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
ref="threeMapRef"
|
||||
class="indoor-three-map"
|
||||
:asset-base-url="indoorAssetBaseUrl"
|
||||
:initial-floor-id="activeFloorId"
|
||||
:initial-view="indoorInitialView"
|
||||
:show-controls="false"
|
||||
:show-poi="shouldShowIndoorPois"
|
||||
:target-focus="targetFocusRequest"
|
||||
@target-focus="handleTargetFocus"
|
||||
/>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
@@ -77,6 +82,27 @@
|
||||
|
||||
<slot name="overlay"></slot>
|
||||
|
||||
<view
|
||||
v-if="mapType === 'indoor' && showIndoorViewToggle"
|
||||
class="indoor-view-toggle"
|
||||
:style="indoorViewToggleStyle"
|
||||
>
|
||||
<view
|
||||
class="indoor-view-item"
|
||||
:class="{ active: indoorView === 'overview' }"
|
||||
@tap="handleIndoorViewChange('overview')"
|
||||
>
|
||||
<text class="indoor-view-label">全馆</text>
|
||||
</view>
|
||||
<view
|
||||
class="indoor-view-item"
|
||||
:class="{ active: indoorView === 'floor' }"
|
||||
@tap="handleIndoorViewChange('floor')"
|
||||
>
|
||||
<text class="indoor-view-label">楼层</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showFloor" class="floor-switcher" :style="floorSwitcherStyle">
|
||||
<view
|
||||
v-for="floor in floors"
|
||||
@@ -116,16 +142,40 @@ import {
|
||||
navFloorIdFromLabel
|
||||
} from '@/services/navAssets'
|
||||
|
||||
interface TargetPoiFocusRequest {
|
||||
requestId: number | string
|
||||
poiId: string
|
||||
name?: string
|
||||
floorId: string
|
||||
floorLabel?: string
|
||||
primaryCategoryZh?: string
|
||||
positionGltf?: [number, number, number]
|
||||
}
|
||||
|
||||
interface TargetPoiFocusResult {
|
||||
requestId: number | string
|
||||
poiId: string
|
||||
floorId: string
|
||||
status: 'focused' | 'missing' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
type IndoorViewMode = 'overview' | 'floor'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
searchText?: string
|
||||
activeMode?: '2d' | '3d'
|
||||
activeFloor?: string
|
||||
indoorView?: IndoorViewMode
|
||||
indoorInitialView?: IndoorViewMode
|
||||
searchTop?: string
|
||||
floorTop?: string
|
||||
indoorViewToggleTop?: string
|
||||
toolsTop?: string
|
||||
tools?: string[]
|
||||
showSearch?: boolean
|
||||
showFloor?: boolean
|
||||
showIndoorViewToggle?: boolean
|
||||
modeTop?: string
|
||||
modeLayout?: 'full' | 'status'
|
||||
modeStatus?: string
|
||||
@@ -134,16 +184,21 @@ const props = withDefaults(defineProps<{
|
||||
outdoorVariant?: 'home' | 'entrance'
|
||||
indoorAssetBaseUrl?: string
|
||||
dimmed?: boolean
|
||||
targetFocusRequest?: TargetPoiFocusRequest | null
|
||||
}>(), {
|
||||
searchText: '搜索设施、展厅、入口',
|
||||
activeMode: '3d',
|
||||
activeFloor: '1F',
|
||||
indoorView: 'floor',
|
||||
indoorInitialView: 'floor',
|
||||
searchTop: '16px',
|
||||
floorTop: '164px',
|
||||
indoorViewToggleTop: '154px',
|
||||
toolsTop: '406px',
|
||||
tools: () => [] as string[],
|
||||
showSearch: true,
|
||||
showFloor: true,
|
||||
showIndoorViewToggle: false,
|
||||
modeTop: '60px',
|
||||
modeLayout: 'full',
|
||||
modeStatus: '',
|
||||
@@ -151,7 +206,8 @@ const props = withDefaults(defineProps<{
|
||||
mapType: 'indoor',
|
||||
outdoorVariant: 'home',
|
||||
indoorAssetBaseUrl: NAV_ASSET_BASE_URL,
|
||||
dimmed: false
|
||||
dimmed: false,
|
||||
targetFocusRequest: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -159,11 +215,18 @@ const emit = defineEmits<{
|
||||
modeChange: [mode: '2d' | '3d']
|
||||
floorChange: [floor: string]
|
||||
toolClick: [tool: string]
|
||||
indoorViewChange: [view: IndoorViewMode]
|
||||
targetFocus: [result: TargetPoiFocusResult]
|
||||
}>()
|
||||
|
||||
const threeMapRef = ref<{ switchFloor?: (floorId: string) => Promise<void> | void } | null>(null)
|
||||
const threeMapRef = ref<{
|
||||
switchFloor?: (floorId: string) => Promise<void> | void
|
||||
showOverview?: () => Promise<void> | void
|
||||
} | null>(null)
|
||||
const floors = NAV_FLOOR_OPTIONS.map((floor) => floor.label)
|
||||
|
||||
const activeFloorId = computed(() => navFloorIdFromLabel(props.activeFloor))
|
||||
|
||||
const searchFieldStyle = computed(() => ({
|
||||
top: props.searchTop
|
||||
}))
|
||||
@@ -172,6 +235,10 @@ const floorSwitcherStyle = computed(() => ({
|
||||
top: props.floorTop
|
||||
}))
|
||||
|
||||
const indoorViewToggleStyle = computed(() => ({
|
||||
top: props.indoorViewToggleTop
|
||||
}))
|
||||
|
||||
const modeRowStyle = computed(() => ({
|
||||
top: props.modeTop
|
||||
}))
|
||||
@@ -180,6 +247,8 @@ const toolStackStyle = computed(() => ({
|
||||
top: props.toolsTop
|
||||
}))
|
||||
|
||||
const shouldShowIndoorPois = computed(() => Boolean(props.targetFocusRequest))
|
||||
|
||||
const handleSearchTap = () => {
|
||||
emit('searchTap')
|
||||
}
|
||||
@@ -191,12 +260,27 @@ const handleModeChange = (mode: '2d' | '3d') => {
|
||||
const handleFloorChange = (floor: string) => {
|
||||
const floorId = navFloorIdFromLabel(floor)
|
||||
void threeMapRef.value?.switchFloor?.(floorId)
|
||||
emit('indoorViewChange', 'floor')
|
||||
emit('floorChange', floor)
|
||||
}
|
||||
|
||||
const handleIndoorViewChange = (view: IndoorViewMode) => {
|
||||
if (view === 'overview') {
|
||||
void threeMapRef.value?.showOverview?.()
|
||||
} else {
|
||||
void threeMapRef.value?.switchFloor?.(activeFloorId.value)
|
||||
}
|
||||
|
||||
emit('indoorViewChange', view)
|
||||
}
|
||||
|
||||
const handleToolClick = (tool: string) => {
|
||||
emit('toolClick', tool)
|
||||
}
|
||||
|
||||
const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
||||
emit('targetFocus', result)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -434,6 +518,42 @@ const handleToolClick = (tool: string) => {
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.indoor-view-toggle {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
width: 58px;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||||
z-index: 36;
|
||||
}
|
||||
|
||||
.indoor-view-item {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
color: #59645d;
|
||||
}
|
||||
|
||||
.indoor-view-item.active {
|
||||
background: var(--museum-accent);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 6px 12px rgba(82, 107, 91, 0.18);
|
||||
}
|
||||
|
||||
.indoor-view-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.floor-item {
|
||||
position: relative;
|
||||
height: 36px;
|
||||
|
||||
144
src/components/navigation/GuidePageFrame.vue
Normal file
144
src/components/navigation/GuidePageFrame.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<view class="guide-page-frame" :class="`variant-${variant}`">
|
||||
<GuideTopTabs
|
||||
:active-tab="activeTab"
|
||||
@tab-change="handleTabChange"
|
||||
/>
|
||||
<view
|
||||
v-if="showBack || showCancel"
|
||||
class="guide-page-frame-actions"
|
||||
>
|
||||
<view
|
||||
v-if="showBack"
|
||||
class="frame-action frame-action-back"
|
||||
@tap.stop="handleBackTap"
|
||||
>
|
||||
<text class="frame-action-text">{{ backLabel }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="showCancel"
|
||||
class="frame-action frame-action-cancel"
|
||||
@tap.stop="handleCancelTap"
|
||||
>
|
||||
<text class="frame-action-text">{{ cancelLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="guide-page-frame-body">
|
||||
<slot></slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GuideTopTabs from '@/components/navigation/GuideTopTabs.vue'
|
||||
import type { GuideTopTab } from '@/utils/guideTopTabs'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
activeTab?: GuideTopTab
|
||||
variant?: 'overlay' | 'static'
|
||||
showBack?: boolean
|
||||
showCancel?: boolean
|
||||
backLabel?: string
|
||||
cancelLabel?: string
|
||||
}>(), {
|
||||
activeTab: 'guide',
|
||||
variant: 'overlay',
|
||||
showBack: false,
|
||||
showCancel: false,
|
||||
backLabel: '返回',
|
||||
cancelLabel: '取消'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
tabChange: [tab: GuideTopTab]
|
||||
back: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const handleTabChange = (tab: GuideTopTab) => {
|
||||
emit('tabChange', tab)
|
||||
}
|
||||
|
||||
const handleBackTap = () => {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
const handleCancelTap = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.guide-page-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--museum-bg-map);
|
||||
}
|
||||
|
||||
.guide-page-frame-body {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.variant-overlay .guide-page-frame-body {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.variant-static .guide-page-frame-body {
|
||||
top: 44px;
|
||||
background: var(--museum-bg-light);
|
||||
}
|
||||
|
||||
.guide-page-frame-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
z-index: 2010;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.frame-action {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
min-width: 54px;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.frame-action-back {
|
||||
left: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.frame-action-cancel {
|
||||
right: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.frame-action-text {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.guide-page-frame {
|
||||
max-width: 430px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
90
src/components/navigation/GuideTopTabs.vue
Normal file
90
src/components/navigation/GuideTopTabs.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<view class="guide-top-tabs">
|
||||
<view
|
||||
v-for="tab in GUIDE_TOP_TABS"
|
||||
:key="tab.id"
|
||||
class="guide-top-tab"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@tap="handleTabTap(tab.id)"
|
||||
>
|
||||
<view v-if="activeTab === tab.id" class="guide-top-tab-indicator"></view>
|
||||
<text class="guide-top-tab-label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GUIDE_TOP_TABS,
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
activeTab?: GuideTopTab
|
||||
}>(), {
|
||||
activeTab: 'guide'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
tabChange: [tab: GuideTopTab]
|
||||
}>()
|
||||
|
||||
const handleTabTap = (tab: GuideTopTab) => {
|
||||
emit('tabChange', tab)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.guide-top-tabs {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid #eceee8;
|
||||
box-sizing: border-box;
|
||||
z-index: 2000;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.guide-top-tab {
|
||||
position: relative;
|
||||
width: 75px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.guide-top-tab-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.guide-top-tab.active .guide-top-tab-label {
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.guide-top-tab-indicator {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 26px;
|
||||
width: 39px;
|
||||
height: 6px;
|
||||
background: var(--museum-accent);
|
||||
border-radius: 3px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
212
src/components/navigation/LocationPreview.vue
Normal file
212
src/components/navigation/LocationPreview.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<view class="location-preview">
|
||||
<view class="preview-header">
|
||||
<text class="preview-title">位置预览</text>
|
||||
<text class="preview-summary">{{ summary }}</text>
|
||||
</view>
|
||||
|
||||
<view class="preview-steps">
|
||||
<view
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
class="preview-step"
|
||||
>
|
||||
<view
|
||||
class="step-dot"
|
||||
:class="{ target: index === steps.length - 1 }"
|
||||
></view>
|
||||
<view v-if="index === 0" class="step-connector"></view>
|
||||
<text class="step-text">{{ step.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<view class="action-btn secondary" @tap="emit('viewOutdoorMap')">
|
||||
<text class="action-text">查看室外地图</text>
|
||||
</view>
|
||||
<view class="action-btn primary" @tap="handleShowTargetLocation">
|
||||
<text class="action-text">查看目标位置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface PreviewStep {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PreviewTargetLocation {
|
||||
poiId: string
|
||||
name: string
|
||||
floorId: string
|
||||
floorLabel?: string
|
||||
primaryCategoryZh?: string
|
||||
positionGltf?: [number, number, number]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
summary: string
|
||||
steps: PreviewStep[]
|
||||
targetLocation?: PreviewTargetLocation | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewOutdoorMap: []
|
||||
showTargetLocation: [target: PreviewTargetLocation | null]
|
||||
}>()
|
||||
|
||||
const handleShowTargetLocation = () => {
|
||||
emit('showTargetLocation', props.targetLocation || null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.location-preview {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 34px);
|
||||
height: 222px;
|
||||
padding: 22px 20px 16px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.preview-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #6b7178;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-steps {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.preview-step {
|
||||
position: relative;
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #000000;
|
||||
border-radius: 5px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.step-dot.target {
|
||||
background: var(--museum-accent);
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 18px;
|
||||
width: 2px;
|
||||
height: 38px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#d9d9d9 0,
|
||||
#d9d9d9 5px,
|
||||
transparent 5px,
|
||||
transparent 9px
|
||||
);
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
width: 151px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
width: 168px;
|
||||
background: #000000;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.action-btn.primary .action-text {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.action-row {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn.secondary,
|
||||
.action-btn.primary {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,68 +1,80 @@
|
||||
<template>
|
||||
<view class="detail-page">
|
||||
<scroll-view class="content" scroll-y>
|
||||
<!-- 展品图片 -->
|
||||
<view class="exhibit-hero">
|
||||
<image class="hero-image" :src="exhibit.image" mode="aspectFill" />
|
||||
<view class="audio-control" @tap="handlePlayAudio">
|
||||
<text class="audio-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 展品信息 -->
|
||||
<view class="exhibit-info">
|
||||
<text class="exhibit-title">{{ exhibit.name }}</text>
|
||||
<text v-if="exhibit.artist" class="exhibit-artist">{{ exhibit.artist }}</text>
|
||||
|
||||
<view class="info-grid">
|
||||
<view v-if="exhibit.year" class="info-item">
|
||||
<text class="info-label">创作年代</text>
|
||||
<text class="info-value">{{ exhibit.year }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.material" class="info-item">
|
||||
<text class="info-label">材质</text>
|
||||
<text class="info-value">{{ exhibit.material }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.size" class="info-item">
|
||||
<text class="info-label">尺寸</text>
|
||||
<text class="info-value">{{ exhibit.size }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.hall" class="info-item">
|
||||
<text class="info-label">展厅位置</text>
|
||||
<text class="info-value">{{ exhibit.hall }}</text>
|
||||
<GuidePageFrame
|
||||
:active-tab="activeTopTab"
|
||||
variant="static"
|
||||
@tab-change="handleTopTabChange"
|
||||
>
|
||||
<view class="detail-page">
|
||||
<scroll-view class="content" scroll-y>
|
||||
<!-- 展品图片 -->
|
||||
<view class="exhibit-hero">
|
||||
<image class="hero-image" :src="exhibit.image" mode="aspectFill" />
|
||||
<view class="audio-control" @tap="handlePlayAudio">
|
||||
<text class="audio-icon">{{ isPlaying ? '⏸' : '▶' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="description-section">
|
||||
<text class="section-title">作品介绍</text>
|
||||
<text class="description-text">{{ exhibit.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<!-- 展品信息 -->
|
||||
<view class="exhibit-info">
|
||||
<text class="exhibit-title">{{ exhibit.name }}</text>
|
||||
<text v-if="exhibit.artist" class="exhibit-artist">{{ exhibit.artist }}</text>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="action-bar">
|
||||
<view class="action-btn-group">
|
||||
<view class="action-btn" @tap="handleNavigate">
|
||||
<text class="btn-icon">🗺️</text>
|
||||
<text class="btn-text">导航</text>
|
||||
<view class="info-grid">
|
||||
<view v-if="exhibit.year" class="info-item">
|
||||
<text class="info-label">创作年代</text>
|
||||
<text class="info-value">{{ exhibit.year }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.material" class="info-item">
|
||||
<text class="info-label">材质</text>
|
||||
<text class="info-value">{{ exhibit.material }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.size" class="info-item">
|
||||
<text class="info-label">尺寸</text>
|
||||
<text class="info-value">{{ exhibit.size }}</text>
|
||||
</view>
|
||||
<view v-if="exhibit.hall" class="info-item">
|
||||
<text class="info-label">展厅位置</text>
|
||||
<text class="info-value">{{ exhibit.hall }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="description-section">
|
||||
<text class="section-title">作品介绍</text>
|
||||
<text class="description-text">{{ exhibit.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="action-btn" @tap="handleCollect">
|
||||
<text class="btn-icon">{{ isCollected ? '❤️' : '🤍' }}</text>
|
||||
<text class="btn-text">收藏</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="handleShare">
|
||||
<text class="btn-icon">📤</text>
|
||||
<text class="btn-text">分享</text>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="action-bar">
|
||||
<view class="action-btn-group">
|
||||
<view class="action-btn" @tap="handleNavigate">
|
||||
<text class="btn-icon">🗺️</text>
|
||||
<text class="btn-text">导航</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="handleCollect">
|
||||
<text class="btn-icon">{{ isCollected ? '❤️' : '🤍' }}</text>
|
||||
<text class="btn-text">收藏</text>
|
||||
</view>
|
||||
<view class="action-btn" @tap="handleShare">
|
||||
<text class="btn-icon">📤</text>
|
||||
<text class="btn-text">分享</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</GuidePageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import {
|
||||
isGuideTopTab,
|
||||
navigateToGuideTopTab,
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
|
||||
const exhibit = ref({
|
||||
id: '1',
|
||||
@@ -78,12 +90,18 @@ const exhibit = ref({
|
||||
|
||||
const isPlaying = ref(false)
|
||||
const isCollected = ref(false)
|
||||
const activeTopTab = ref<GuideTopTab>('guide')
|
||||
|
||||
onLoad((options: any) => {
|
||||
if (options.id) {
|
||||
// 根据 ID 加载展品数据
|
||||
console.log('加载展品:', options.id)
|
||||
}
|
||||
|
||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||
if (isGuideTopTab(tab)) {
|
||||
activeTopTab.value = tab
|
||||
}
|
||||
})
|
||||
|
||||
const handlePlayAudio = () => {
|
||||
@@ -108,12 +126,16 @@ const handleShare = () => {
|
||||
uni.showShareMenu()
|
||||
}
|
||||
|
||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
navigateToGuideTopTab(tab)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.detail-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
background-color: var(--museum-bg-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,58 +1,136 @@
|
||||
<template>
|
||||
<GuideMapShell
|
||||
:search-text="facility.name"
|
||||
floor-top="164px"
|
||||
tools-top="406px"
|
||||
:tools="['回正', '2D']"
|
||||
@search-tap="handleSearchTap"
|
||||
@mode-change="handleModeChange"
|
||||
@floor-change="handleFloorChange"
|
||||
@tool-click="handleToolClick"
|
||||
<GuidePageFrame
|
||||
active-tab="guide"
|
||||
variant="overlay"
|
||||
show-back
|
||||
:show-cancel="isStartPanelOpen"
|
||||
@tab-change="handleTopTabChange"
|
||||
@back="handlePageBack"
|
||||
@cancel="handleCancelStartSelection"
|
||||
>
|
||||
<view class="detail-sheet">
|
||||
<view class="detail-title-row">
|
||||
<text class="detail-title">{{ facility.name }}</text>
|
||||
<text class="open-status">{{ facility.status }}</text>
|
||||
</view>
|
||||
<GuideMapShell
|
||||
:search-text="facility.name"
|
||||
search-top="60px"
|
||||
mode-top="104px"
|
||||
floor-top="208px"
|
||||
tools-top="450px"
|
||||
:tools="['回正', '2D']"
|
||||
@search-tap="handleSearchTap"
|
||||
@mode-change="handleModeChange"
|
||||
@floor-change="handleFloorChange"
|
||||
@tool-click="handleToolClick"
|
||||
>
|
||||
<view class="detail-sheet">
|
||||
<view class="detail-title-row">
|
||||
<text class="detail-title">{{ facility.name }}</text>
|
||||
<text class="open-status">{{ facility.status }}</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-lines">
|
||||
<text class="detail-line">所在区域:{{ facility.location }}</text>
|
||||
<text class="detail-line">最近垂直交通:{{ facility.traffic }}</text>
|
||||
</view>
|
||||
<view class="detail-lines">
|
||||
<text class="detail-line">所在区域:{{ facility.location }}</text>
|
||||
<text class="detail-line">最近垂直交通:{{ facility.traffic }}</text>
|
||||
<text v-if="confirmedStart" class="detail-line">预览起点:{{ confirmedStart.label }}</text>
|
||||
</view>
|
||||
|
||||
<view class="tag-row">
|
||||
<view
|
||||
v-for="(tag, index) in facility.tags"
|
||||
:key="tag"
|
||||
class="detail-tag"
|
||||
:class="{ active: index === 0 }"
|
||||
>
|
||||
<text class="detail-tag-text">{{ tag }}</text>
|
||||
<view class="tag-row">
|
||||
<view
|
||||
v-for="(tag, index) in facility.tags"
|
||||
:key="tag"
|
||||
class="detail-tag"
|
||||
:class="{ active: index === 0 }"
|
||||
>
|
||||
<text class="detail-tag-text">{{ tag }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<view class="action-btn secondary" @tap="handleChooseStart">
|
||||
<text class="action-text">{{ startButtonLabel }}</text>
|
||||
</view>
|
||||
<view class="action-btn primary" @tap="handleStartNavigation">
|
||||
<text class="action-text">查看位置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<view class="action-btn secondary" @tap="handleChooseStart">
|
||||
<text class="action-text">选择起点</text>
|
||||
<view
|
||||
v-if="isStartPanelOpen"
|
||||
class="start-panel-mask"
|
||||
@tap="handleCancelStartSelection"
|
||||
></view>
|
||||
<view
|
||||
v-if="isStartPanelOpen"
|
||||
class="start-select-sheet"
|
||||
@tap.stop=""
|
||||
>
|
||||
<text class="start-select-title">选择预览起点</text>
|
||||
<text class="start-select-desc">当前仅用于位置预览,不生成真实馆内路线。</text>
|
||||
|
||||
<view class="start-chip-section">
|
||||
<text class="start-chip-label">楼层</text>
|
||||
<view class="start-chip-row">
|
||||
<view
|
||||
v-for="floor in startFloors"
|
||||
:key="floor"
|
||||
class="start-chip"
|
||||
:class="{ active: floor === selectedStartFloor }"
|
||||
@tap="handleStartFloorChange(floor)"
|
||||
>
|
||||
<text class="start-chip-text">{{ floor }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="action-btn primary" @tap="handleStartNavigation">
|
||||
<text class="action-text">查看位置</text>
|
||||
|
||||
<view class="start-chip-section area">
|
||||
<text class="start-chip-label">区域</text>
|
||||
<view class="start-chip-row">
|
||||
<view
|
||||
v-for="area in startAreas"
|
||||
:key="area"
|
||||
class="start-chip area"
|
||||
:class="{ active: area === selectedStartArea }"
|
||||
@tap="handleStartAreaChange(area)"
|
||||
>
|
||||
<text class="start-chip-text">{{ area }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="start-panel-actions">
|
||||
<view class="start-panel-btn secondary" @tap="handleCancelStartSelection">
|
||||
<text class="start-panel-btn-text">取消</text>
|
||||
</view>
|
||||
<view class="start-panel-btn primary" @tap="handleConfirmStartSelection">
|
||||
<text class="start-panel-btn-text">确认起点</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</GuideMapShell>
|
||||
</GuideMapShell>
|
||||
</GuidePageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||
import {
|
||||
NAV_FLOOR_OPTIONS,
|
||||
NAV_ROUTE_UNAVAILABLE_MESSAGE,
|
||||
formatNavFloorLabel,
|
||||
isPoiAccessible,
|
||||
loadCleanNavPoiById
|
||||
} from '@/services/navAssets'
|
||||
import {
|
||||
navigateToGuideTopTab,
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
|
||||
interface ConfirmedStart {
|
||||
label: string
|
||||
floor: string
|
||||
source: 'facility-detail'
|
||||
}
|
||||
|
||||
const facility = ref({
|
||||
id: '',
|
||||
@@ -62,6 +140,30 @@ const facility = ref({
|
||||
traffic: NAV_ROUTE_UNAVAILABLE_MESSAGE,
|
||||
tags: ['三维位置', 'clean 数据']
|
||||
})
|
||||
const isStartPanelOpen = ref(false)
|
||||
const selectedStartFloor = ref('1F')
|
||||
const selectedStartArea = ref('服务台附近')
|
||||
const confirmedStart = ref<ConfirmedStart | null>(null)
|
||||
const startFloors = NAV_FLOOR_OPTIONS.map((floor) => floor.label)
|
||||
const startAreas = ['主入口', '服务台附近', '停车场入口']
|
||||
|
||||
const pendingStartLabel = computed(() => `${selectedStartFloor.value} ${selectedStartArea.value}`)
|
||||
const startButtonLabel = computed(() => confirmedStart.value ? '更换起点' : '选择起点')
|
||||
|
||||
const resolveConfirmedStartArea = () => {
|
||||
if (!confirmedStart.value) return '服务台附近'
|
||||
|
||||
const area = confirmedStart.value.floor
|
||||
? confirmedStart.value.label.replace(`${confirmedStart.value.floor} `, '').trim()
|
||||
: confirmedStart.value.label.trim()
|
||||
|
||||
return startAreas.includes(area) ? area : '服务台附近'
|
||||
}
|
||||
|
||||
const syncDraftStartSelection = () => {
|
||||
selectedStartFloor.value = confirmedStart.value?.floor || '1F'
|
||||
selectedStartArea.value = resolveConfirmedStartArea()
|
||||
}
|
||||
|
||||
onLoad(async (options: any) => {
|
||||
if (options.id) {
|
||||
@@ -103,15 +205,51 @@ const handleSearchTap = () => {
|
||||
}
|
||||
|
||||
const handleChooseStart = () => {
|
||||
uni.showToast({
|
||||
title: '请选择当前位置',
|
||||
icon: 'none'
|
||||
})
|
||||
syncDraftStartSelection()
|
||||
isStartPanelOpen.value = true
|
||||
}
|
||||
|
||||
const handleStartFloorChange = (floor: string) => {
|
||||
selectedStartFloor.value = floor
|
||||
}
|
||||
|
||||
const handleStartAreaChange = (area: string) => {
|
||||
selectedStartArea.value = area
|
||||
}
|
||||
|
||||
const handleCancelStartSelection = () => {
|
||||
syncDraftStartSelection()
|
||||
isStartPanelOpen.value = false
|
||||
}
|
||||
|
||||
const handleConfirmStartSelection = () => {
|
||||
confirmedStart.value = {
|
||||
label: pendingStartLabel.value,
|
||||
floor: selectedStartFloor.value,
|
||||
source: 'facility-detail'
|
||||
}
|
||||
isStartPanelOpen.value = false
|
||||
}
|
||||
|
||||
const handleStartNavigation = () => {
|
||||
const params: Record<string, string> = {
|
||||
facilityId: facility.value.id,
|
||||
target: facility.value.name,
|
||||
state: 'preview'
|
||||
}
|
||||
|
||||
if (confirmedStart.value) {
|
||||
params.startLabel = confirmedStart.value.label
|
||||
params.startFloor = confirmedStart.value.floor
|
||||
params.startSource = confirmedStart.value.source
|
||||
}
|
||||
|
||||
const query = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${facility.value.id}&target=${encodeURIComponent(facility.value.name)}&state=planning`
|
||||
url: `/pages/route/detail?${query}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,6 +265,21 @@ const handleToolClick = (tool: string) => {
|
||||
console.log('设施详情工具:', tool)
|
||||
}
|
||||
|
||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
navigateToGuideTopTab(tab)
|
||||
}
|
||||
|
||||
const handlePageBack = () => {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -265,4 +418,151 @@ const handleToolClick = (tool: string) => {
|
||||
.action-btn.primary .action-text {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.start-panel-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
z-index: 52;
|
||||
}
|
||||
|
||||
.start-select-sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 34px);
|
||||
height: 276px;
|
||||
padding: 22px 20px 16px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16);
|
||||
z-index: 55;
|
||||
}
|
||||
|
||||
.start-select-title {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.start-select-desc {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #626970;
|
||||
}
|
||||
|
||||
.start-chip-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.start-chip-section.area {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.start-chip-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #60666d;
|
||||
}
|
||||
|
||||
.start-chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.start-chip {
|
||||
height: 28px;
|
||||
min-width: 48px;
|
||||
padding: 0 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #f4f4ef;
|
||||
border: 1px solid #e7e8e2;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.start-chip.area {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.start-chip.active {
|
||||
background: #000000;
|
||||
border-color: #000000;
|
||||
}
|
||||
|
||||
.start-chip-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #60666d;
|
||||
}
|
||||
|
||||
.start-chip.active .start-chip-text {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.start-panel-actions {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.start-panel-btn {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.start-panel-btn.secondary {
|
||||
width: 151px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.start-panel-btn.primary {
|
||||
width: 168px;
|
||||
background: #000000;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.start-panel-btn-text {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.start-panel-btn.primary .start-panel-btn-text {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.start-panel-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.start-panel-btn.secondary,
|
||||
.start-panel-btn.primary {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,58 +1,72 @@
|
||||
<template>
|
||||
<view class="detail-page">
|
||||
<scroll-view class="content" scroll-y>
|
||||
<!-- 展厅封面 -->
|
||||
<view class="hall-hero">
|
||||
<image class="hero-image" :src="hall.image" mode="aspectFill" />
|
||||
<view class="hall-badge">
|
||||
<text class="badge-text">{{ hall.floor }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 展厅信息 -->
|
||||
<view class="hall-info">
|
||||
<text class="hall-title">{{ hall.name }}</text>
|
||||
<text class="hall-description">{{ hall.description }}</text>
|
||||
|
||||
<view class="stats-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ hall.exhibitCount }}</text>
|
||||
<text class="stat-label">展品数量</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ hall.area }}</text>
|
||||
<text class="stat-label">展厅面积</text>
|
||||
<GuidePageFrame
|
||||
:active-tab="activeTopTab"
|
||||
variant="static"
|
||||
@tab-change="handleTopTabChange"
|
||||
>
|
||||
<view class="detail-page">
|
||||
<scroll-view class="content" scroll-y>
|
||||
<!-- 展厅封面 -->
|
||||
<view class="hall-hero">
|
||||
<image class="hero-image" :src="hall.image" mode="aspectFill" />
|
||||
<view class="hall-badge">
|
||||
<text class="badge-text">{{ hall.floor }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 展品列表 -->
|
||||
<view class="exhibits-section">
|
||||
<text class="section-title">展厅展品</text>
|
||||
<view class="exhibits-grid">
|
||||
<ExhibitCard
|
||||
v-for="exhibit in exhibits"
|
||||
:key="exhibit.id"
|
||||
:exhibit="exhibit"
|
||||
@click="handleExhibitClick"
|
||||
/>
|
||||
<!-- 展厅信息 -->
|
||||
<view class="hall-info">
|
||||
<text class="hall-title">{{ hall.name }}</text>
|
||||
<text class="hall-description">{{ hall.description }}</text>
|
||||
|
||||
<view class="stats-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ hall.exhibitCount }}</text>
|
||||
<text class="stat-label">展品数量</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ hall.area }}</text>
|
||||
<text class="stat-label">展厅面积</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 展品列表 -->
|
||||
<view class="exhibits-section">
|
||||
<text class="section-title">展厅展品</text>
|
||||
<view class="exhibits-grid">
|
||||
<ExhibitCard
|
||||
v-for="exhibit in exhibits"
|
||||
:key="exhibit.id"
|
||||
:exhibit="exhibit"
|
||||
@click="handleExhibitClick"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="action-bar">
|
||||
<view class="action-btn primary" @tap="handleNavigate">
|
||||
<text class="btn-text">导航到展厅</text>
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="action-bar">
|
||||
<view class="action-btn primary" @tap="handleNavigate">
|
||||
<text class="btn-text">导航到展厅</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</GuidePageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import ExhibitCard from '@/components/content/ExhibitCard.vue'
|
||||
import {
|
||||
isGuideTopTab,
|
||||
navigateToGuideTopTab,
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
|
||||
const activeTopTab = ref<GuideTopTab>('guide')
|
||||
|
||||
const hall = ref({
|
||||
id: '1',
|
||||
@@ -73,11 +87,16 @@ onLoad((options: any) => {
|
||||
if (options.id) {
|
||||
console.log('加载展厅:', options.id)
|
||||
}
|
||||
|
||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||
if (isGuideTopTab(tab)) {
|
||||
activeTopTab.value = tab
|
||||
}
|
||||
})
|
||||
|
||||
const handleExhibitClick = (exhibit: any) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/exhibit/detail?id=${exhibit.id}`
|
||||
url: `/pages/exhibit/detail?id=${exhibit.id}&tab=${activeTopTab.value}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,12 +104,16 @@ const handleNavigate = () => {
|
||||
console.log('导航到展厅')
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
navigateToGuideTopTab(tab)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.detail-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--museum-bg-light);
|
||||
display: flex;
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view class="content-tabs">
|
||||
<view
|
||||
v-for="tab in contentTabs"
|
||||
:key="tab.id"
|
||||
class="content-tab"
|
||||
:class="{ active: currentTab === tab.id }"
|
||||
@tap="handleTabChange(tab.id)"
|
||||
>
|
||||
<view v-if="currentTab === tab.id" class="content-tab-indicator"></view>
|
||||
<text class="content-tab-label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<GuidePageFrame
|
||||
:active-tab="currentTab"
|
||||
variant="overlay"
|
||||
@tab-change="handleTabChange"
|
||||
>
|
||||
<GuideMapShell
|
||||
v-if="currentTab === 'guide'"
|
||||
:search-text="guideSearchText"
|
||||
@@ -25,10 +16,18 @@
|
||||
:map-type="guideMapType"
|
||||
:outdoor-variant="guideOutdoorVariant"
|
||||
:indoor-asset-base-url="indoorNavAssetBaseUrl"
|
||||
:show-floor="false"
|
||||
indoor-initial-view="overview"
|
||||
:indoor-view="indoorView"
|
||||
:active-floor="activeGuideFloor"
|
||||
indoor-view-toggle-top="154px"
|
||||
floor-top="244px"
|
||||
:show-indoor-view-toggle="is3DMode"
|
||||
:show-floor="is3DMode && indoorView === 'floor'"
|
||||
:tools="[]"
|
||||
@search-tap="handleGuideSearchTap"
|
||||
@mode-change="handleModeChange"
|
||||
@floor-change="handleFloorChange"
|
||||
@indoor-view-change="handleIndoorViewChange"
|
||||
>
|
||||
<template #overlay>
|
||||
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
|
||||
@@ -66,7 +65,7 @@
|
||||
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-bottom-card">
|
||||
<text class="entrance-card-title">主入口</text>
|
||||
<text class="entrance-card-desc">
|
||||
可到达入口后自动切换,也可手动进入室内3D并选择楼层/区域。
|
||||
可参考主入口位置,也可手动进入室内3D并选择楼层/区域。
|
||||
</text>
|
||||
<view class="entrance-actions">
|
||||
<view class="entrance-btn secondary" @tap="handleSwitchEntrance">
|
||||
@@ -102,15 +101,21 @@
|
||||
@pause="handleAudioPause"
|
||||
@ended="handleAudioEnded"
|
||||
/>
|
||||
</view>
|
||||
</GuidePageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||
import ExplainList from '@/components/explain/ExplainList.vue'
|
||||
import FloatingAudioButton from '@/components/audio/FloatingAudioButton.vue'
|
||||
import AudioPlayer, { type AudioItem } from '@/components/audio/AudioPlayer.vue'
|
||||
import {
|
||||
isGuideTopTab,
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
import {
|
||||
NAV_ASSET_BASE_URL,
|
||||
NAV_ROUTE_UNAVAILABLE_MESSAGE
|
||||
@@ -119,20 +124,17 @@ import {
|
||||
// 3D 模式状态
|
||||
const is3DMode = ref(false)
|
||||
const guideOutdoorState = ref<'home' | 'entrance'>('home')
|
||||
const indoorView = ref<'overview' | 'floor'>('overview')
|
||||
const activeGuideFloor = ref('1F')
|
||||
|
||||
// 状态
|
||||
const searchKeyword = ref('')
|
||||
const currentTab = ref('guide')
|
||||
const currentTab = ref<GuideTopTab>('guide')
|
||||
const searchBarFocused = ref(false)
|
||||
const showMarkerDetail = ref(false)
|
||||
const showAreaSelector = ref(false)
|
||||
const selectedAreaId = ref('service')
|
||||
|
||||
const contentTabs = [
|
||||
{ id: 'guide', label: '导览' },
|
||||
{ id: 'explain', label: '讲解' }
|
||||
]
|
||||
|
||||
const indoorNavAssetBaseUrl = NAV_ASSET_BASE_URL
|
||||
|
||||
const shortcutItems = [
|
||||
@@ -171,7 +173,7 @@ const markerDataMap: Record<string, MarkerDetail> = {
|
||||
}
|
||||
|
||||
// Tab 切换处理
|
||||
const handleTabChange = (tabId: string) => {
|
||||
const handleTabChange = (tabId: GuideTopTab) => {
|
||||
currentTab.value = tabId
|
||||
console.log('切换标签:', tabId)
|
||||
|
||||
@@ -181,6 +183,13 @@ const handleTabChange = (tabId: string) => {
|
||||
searchBarFocused.value = false
|
||||
}
|
||||
|
||||
onLoad((options: any = {}) => {
|
||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||
if (isGuideTopTab(tab)) {
|
||||
currentTab.value = tab
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索处理
|
||||
const handleSearchFocus = () => {
|
||||
searchBarFocused.value = true
|
||||
@@ -284,12 +293,19 @@ const handleCollectMarker = (marker: MarkerDetail) => {
|
||||
}
|
||||
|
||||
const handleFloorChange = (floor: string) => {
|
||||
activeGuideFloor.value = floor
|
||||
indoorView.value = 'floor'
|
||||
console.log('切换楼层:', floor)
|
||||
}
|
||||
|
||||
const handleIndoorViewChange = (view: 'overview' | 'floor') => {
|
||||
indoorView.value = view
|
||||
}
|
||||
|
||||
// 进入 3D 室内模式
|
||||
const handleEnter3DMode = () => {
|
||||
console.log('进入 3D 室内模式')
|
||||
indoorView.value = 'overview'
|
||||
is3DMode.value = true
|
||||
guideOutdoorState.value = 'home'
|
||||
}
|
||||
@@ -298,32 +314,37 @@ const handleModeChange = (mode: '2d' | '3d') => {
|
||||
is3DMode.value = mode === '3d'
|
||||
|
||||
if (mode === '2d') {
|
||||
indoorView.value = 'overview'
|
||||
guideOutdoorState.value = 'home'
|
||||
} else {
|
||||
indoorView.value = 'overview'
|
||||
}
|
||||
}
|
||||
|
||||
const guideStatusLabel = computed(() => {
|
||||
if (is3DMode.value) {
|
||||
return '馆内定位'
|
||||
return indoorView.value === 'overview' ? '室内全馆' : '室内楼层'
|
||||
}
|
||||
|
||||
if (guideOutdoorState.value === 'entrance') {
|
||||
return '推荐入口'
|
||||
}
|
||||
|
||||
return '馆外定位'
|
||||
return '室外参考'
|
||||
})
|
||||
|
||||
const guideTaskTitle = computed(() => {
|
||||
if (is3DMode.value) {
|
||||
return '规划馆内路线'
|
||||
return indoorView.value === 'overview' ? '查看完整建筑' : '查看楼层位置'
|
||||
}
|
||||
return '从室外进入馆内'
|
||||
})
|
||||
|
||||
const guideTaskSubtitle = computed(() => {
|
||||
if (is3DMode.value) {
|
||||
return '选择目标设施后,系统会优先推荐最近路径'
|
||||
return indoorView.value === 'overview'
|
||||
? '切换到楼层后,可查看 POI 与三维位置预览'
|
||||
: '选择目标设施后,可查看楼层与三维位置预览'
|
||||
}
|
||||
|
||||
return ''
|
||||
@@ -413,7 +434,7 @@ const handleAudioEnded = (audio: AudioItem) => {
|
||||
const handleExplainExhibitClick = (exhibit: any) => {
|
||||
console.log('点击讲解展品:', exhibit)
|
||||
uni.navigateTo({
|
||||
url: `/pages/exhibit/detail?id=${exhibit.id}`
|
||||
url: `/pages/exhibit/detail?id=${exhibit.id}&tab=explain`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -438,16 +459,6 @@ const handleExplainFeaturedClick = (exhibit: any) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--museum-bg-map);
|
||||
}
|
||||
|
||||
.explain-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -546,59 +557,6 @@ const handleExplainFeaturedClick = (exhibit: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
.content-tabs {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid #eceee8;
|
||||
box-sizing: border-box;
|
||||
z-index: 2000;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.content-tab {
|
||||
position: relative;
|
||||
width: 75px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content-tab-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.content-tab.active .content-tab-label {
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.content-tab-indicator {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 26px;
|
||||
width: 39px;
|
||||
height: 6px;
|
||||
background: var(--museum-accent);
|
||||
border-radius: 3px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-bar-wrapper {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
@@ -809,11 +767,4 @@ const handleExplainFeaturedClick = (exhibit: any) => {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-container {
|
||||
max-width: 430px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 12px 36px rgba(36, 49, 42, 0.16);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,103 @@
|
||||
<template>
|
||||
<GuideMapShell
|
||||
:search-text="searchKeyword || '搜索设施、展厅、入口'"
|
||||
floor-top="164px"
|
||||
@search-tap="handleSearchTap"
|
||||
@mode-change="handleModeChange"
|
||||
@floor-change="handleFloorChange"
|
||||
<GuidePageFrame
|
||||
active-tab="guide"
|
||||
variant="overlay"
|
||||
show-back
|
||||
:show-cancel="isSearchEditing"
|
||||
@tab-change="handleTopTabChange"
|
||||
@back="handlePageBack"
|
||||
@cancel="handleSearchCancel"
|
||||
>
|
||||
<template #overlay>
|
||||
<view class="filter-row">
|
||||
<GuideMapShell
|
||||
:search-text="searchKeyword || '搜索设施、展厅、入口'"
|
||||
search-top="60px"
|
||||
mode-top="104px"
|
||||
floor-top="208px"
|
||||
@search-tap="handleSearchTap"
|
||||
@mode-change="handleModeChange"
|
||||
@floor-change="handleFloorChange"
|
||||
>
|
||||
<template #overlay>
|
||||
<view
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
class="filter-chip"
|
||||
:class="[{ active: currentFilter === filter.id }, `filter-${filter.id}`]"
|
||||
@tap="handleFilterChange(filter.id)"
|
||||
v-if="isSearchEditing"
|
||||
class="search-edit-row"
|
||||
@tap.stop=""
|
||||
>
|
||||
<text class="filter-text">{{ filter.label }}</text>
|
||||
<view class="search-edit-box">
|
||||
<input
|
||||
class="search-edit-input"
|
||||
type="text"
|
||||
confirm-type="search"
|
||||
:focus="searchInputFocused"
|
||||
:placeholder="searchPlaceholder"
|
||||
:value="searchDraftKeyword"
|
||||
@input="handleSearchInput"
|
||||
@confirm="handleSearchConfirm"
|
||||
/>
|
||||
<view
|
||||
v-if="searchDraftKeyword"
|
||||
class="search-clear-btn"
|
||||
@tap.stop="handleSearchClear"
|
||||
>
|
||||
<text class="search-clear-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="search-confirm-btn" @tap="handleSearchConfirm">
|
||||
<text class="search-confirm-text">搜索</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="results-sheet">
|
||||
<view class="sheet-header">
|
||||
<text class="sheet-title">{{ resultTitle }}</text>
|
||||
<text class="sort-label">按距离排序</text>
|
||||
</view>
|
||||
|
||||
<view class="result-list">
|
||||
<view
|
||||
v-for="item in visibleFacilities"
|
||||
:key="item.id"
|
||||
class="result-card"
|
||||
@tap="handleFacilityClick(item)"
|
||||
>
|
||||
<view class="facility-mark">
|
||||
<text class="facility-mark-text">卫</text>
|
||||
</view>
|
||||
<view class="facility-info">
|
||||
<text class="facility-name">{{ item.name }}</text>
|
||||
<text class="facility-meta">{{ item.meta }}</text>
|
||||
</view>
|
||||
<view class="filter-row">
|
||||
<view
|
||||
class="result-action"
|
||||
:class="{ secondary: item.action === '查看' }"
|
||||
@tap.stop="handleResultAction(item)"
|
||||
v-for="filter in filters"
|
||||
:key="filter.id"
|
||||
class="filter-chip"
|
||||
:class="[{ active: currentFilter === filter.id }, `filter-${filter.id}`]"
|
||||
@tap="handleFilterChange(filter.id)"
|
||||
>
|
||||
<text class="result-action-text">{{ item.action }}</text>
|
||||
<text class="filter-text">{{ filter.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="results-sheet">
|
||||
<view class="sheet-header">
|
||||
<text class="sheet-title">{{ resultTitle }}</text>
|
||||
<text class="sort-label">默认展示</text>
|
||||
</view>
|
||||
|
||||
<view class="result-list">
|
||||
<view
|
||||
v-for="item in visibleFacilities"
|
||||
:key="item.id"
|
||||
class="result-card"
|
||||
@tap="handleFacilityClick(item)"
|
||||
>
|
||||
<view class="facility-mark">
|
||||
<text class="facility-mark-text">卫</text>
|
||||
</view>
|
||||
<view class="facility-info">
|
||||
<text class="facility-name">{{ item.name }}</text>
|
||||
<text class="facility-meta">{{ item.meta }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="result-action"
|
||||
:class="{ secondary: item.action === '查看' }"
|
||||
@tap.stop="handleResultAction(item)"
|
||||
>
|
||||
<text class="result-action-text">{{ item.action }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</GuideMapShell>
|
||||
</GuideMapShell>
|
||||
</GuidePageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||
import {
|
||||
formatNavFloorLabel,
|
||||
@@ -63,9 +105,13 @@ import {
|
||||
searchCleanNavPois,
|
||||
type CleanNavPoi
|
||||
} from '@/services/navAssets'
|
||||
import {
|
||||
navigateToGuideTopTab,
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
|
||||
interface FilterItem {
|
||||
id: 'all' | 'accessible' | 'floor' | 'nearest'
|
||||
id: 'all' | 'accessible' | 'floor'
|
||||
label: string
|
||||
}
|
||||
|
||||
@@ -79,14 +125,17 @@ interface FacilityResult {
|
||||
}
|
||||
|
||||
const searchKeyword = ref('卫生间')
|
||||
const searchDraftKeyword = ref('')
|
||||
const currentFilter = ref<FilterItem['id']>('all')
|
||||
const isLoading = ref(false)
|
||||
const isSearchEditing = ref(false)
|
||||
const searchInputFocused = ref(false)
|
||||
const searchPlaceholder = '搜索设施、展厅、入口'
|
||||
|
||||
const filters: FilterItem[] = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'accessible', label: '无障碍' },
|
||||
{ id: 'floor', label: '本楼层' },
|
||||
{ id: 'nearest', label: '最近' }
|
||||
{ id: 'floor', label: '1F 点位' }
|
||||
]
|
||||
|
||||
const facilities = ref<FacilityResult[]>([])
|
||||
@@ -141,11 +190,46 @@ onLoad((options: any) => {
|
||||
if (options.keyword) {
|
||||
searchKeyword.value = decodeURIComponent(options.keyword)
|
||||
}
|
||||
searchDraftKeyword.value = searchKeyword.value
|
||||
void loadFacilityResults()
|
||||
})
|
||||
|
||||
const handleSearchTap = () => {
|
||||
console.log('保持搜索结果页')
|
||||
const handleSearchTap = async () => {
|
||||
searchDraftKeyword.value = searchKeyword.value
|
||||
isSearchEditing.value = true
|
||||
searchInputFocused.value = false
|
||||
await nextTick()
|
||||
searchInputFocused.value = true
|
||||
}
|
||||
|
||||
const handleSearchInput = (event: any) => {
|
||||
searchDraftKeyword.value = event.detail.value
|
||||
}
|
||||
|
||||
const handleSearchClear = async () => {
|
||||
searchDraftKeyword.value = ''
|
||||
searchInputFocused.value = false
|
||||
await nextTick()
|
||||
searchInputFocused.value = true
|
||||
}
|
||||
|
||||
const handleSearchCancel = () => {
|
||||
searchDraftKeyword.value = searchKeyword.value
|
||||
isSearchEditing.value = false
|
||||
searchInputFocused.value = false
|
||||
}
|
||||
|
||||
const handleSearchConfirm = async (event?: any) => {
|
||||
const confirmedKeyword = typeof event?.detail?.value === 'string'
|
||||
? event.detail.value
|
||||
: searchDraftKeyword.value
|
||||
|
||||
searchKeyword.value = confirmedKeyword.trim()
|
||||
searchDraftKeyword.value = searchKeyword.value
|
||||
currentFilter.value = 'all'
|
||||
isSearchEditing.value = false
|
||||
searchInputFocused.value = false
|
||||
await loadFacilityResults()
|
||||
}
|
||||
|
||||
const handleFilterChange = (filterId: FilterItem['id']) => {
|
||||
@@ -160,7 +244,7 @@ const handleFacilityClick = (facility: FacilityResult) => {
|
||||
|
||||
const handleResultAction = (facility: FacilityResult) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${facility.id}&target=${encodeURIComponent(facility.name)}&state=planning`
|
||||
url: `/pages/route/detail?facilityId=${facility.id}&target=${encodeURIComponent(facility.name)}&state=preview`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,12 +256,101 @@ const handleFloorChange = (floor: string) => {
|
||||
console.log('搜索页切换楼层:', floor)
|
||||
}
|
||||
|
||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
navigateToGuideTopTab(tab)
|
||||
}
|
||||
|
||||
const handlePageBack = () => {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-edit-row {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 48;
|
||||
}
|
||||
|
||||
.search-edit-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(224, 223, 0, 0.55);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 2px rgba(224, 223, 0, 0.16);
|
||||
}
|
||||
|
||||
.search-edit-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
color: #1f2329;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: #8b8b84;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.search-clear-text {
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.search-confirm-btn {
|
||||
width: 52px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
background: #000000;
|
||||
border: 1px solid #000000;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.search-confirm-text {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
position: absolute;
|
||||
top: 102px;
|
||||
top: 146px;
|
||||
left: 23px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
@@ -210,10 +383,6 @@ const handleFloorChange = (floor: string) => {
|
||||
width: 82px;
|
||||
}
|
||||
|
||||
.filter-nearest {
|
||||
width: 74px;
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const NAV_ASSET_BASE_URL = '/static/nav-assets/app_nav_assets_v2_clean_20260609_075339'
|
||||
|
||||
export const NAV_ROUTE_GRAPH_READY = false
|
||||
|
||||
export const NAV_ROUTE_UNAVAILABLE_MESSAGE = '正式路线数据尚未接入,可先查看馆内三维位置'
|
||||
|
||||
export interface NavFloorOption {
|
||||
@@ -55,9 +53,9 @@ const searchableCategories = new Set([
|
||||
'operation_experience'
|
||||
])
|
||||
|
||||
let poiCache: CleanNavPoi[] | null = null
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
const poiCache = new Map<string, CleanNavPoi[]>()
|
||||
const poiRequestCache = new Map<string, Promise<CleanNavPoi[]>>()
|
||||
|
||||
export const navAssetUrl = (relativePath: string, baseUrl = NAV_ASSET_BASE_URL) => (
|
||||
`${normalizeBaseUrl(baseUrl)}/${relativePath.replace(/^\/+/, '')}`
|
||||
@@ -106,15 +104,29 @@ const requestJson = <T>(url: string): Promise<T> => new Promise((resolve, reject
|
||||
})
|
||||
|
||||
export const loadCleanNavPois = async (baseUrl = NAV_ASSET_BASE_URL) => {
|
||||
if (poiCache) return poiCache
|
||||
const cacheKey = normalizeBaseUrl(baseUrl)
|
||||
const cachedPois = poiCache.get(cacheKey)
|
||||
if (cachedPois) return cachedPois
|
||||
|
||||
const data = await requestJson<CleanPoiIndex>(navAssetUrl('data/poi_all.json', baseUrl))
|
||||
if (data.status !== 'pass') {
|
||||
throw new Error('导览 POI 数据状态不是 pass')
|
||||
}
|
||||
const pendingPois = poiRequestCache.get(cacheKey)
|
||||
if (pendingPois) return pendingPois
|
||||
|
||||
poiCache = data.pois.filter((poi) => searchableCategories.has(poi.primaryCategory))
|
||||
return poiCache
|
||||
const request = requestJson<CleanPoiIndex>(navAssetUrl('data/poi_all.json', cacheKey))
|
||||
.then((data) => {
|
||||
if (data.status !== 'pass') {
|
||||
throw new Error('导览 POI 数据状态不是 pass')
|
||||
}
|
||||
|
||||
const pois = data.pois.filter((poi) => searchableCategories.has(poi.primaryCategory))
|
||||
poiCache.set(cacheKey, pois)
|
||||
return pois
|
||||
})
|
||||
.finally(() => {
|
||||
poiRequestCache.delete(cacheKey)
|
||||
})
|
||||
|
||||
poiRequestCache.set(cacheKey, request)
|
||||
return request
|
||||
}
|
||||
|
||||
export const loadCleanNavPoiById = async (id: string, baseUrl = NAV_ASSET_BASE_URL) => {
|
||||
|
||||
18
src/utils/guideTopTabs.ts
Normal file
18
src/utils/guideTopTabs.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type GuideTopTab = 'guide' | 'explain'
|
||||
|
||||
export const GUIDE_TOP_TABS: Array<{ id: GuideTopTab; label: string }> = [
|
||||
{ id: 'guide', label: '导览' },
|
||||
{ id: 'explain', label: '讲解' }
|
||||
]
|
||||
|
||||
export const isGuideTopTab = (value: unknown): value is GuideTopTab => {
|
||||
return value === 'guide' || value === 'explain'
|
||||
}
|
||||
|
||||
export const guideTopTabUrl = (tab: GuideTopTab) => `/pages/index/index?tab=${tab}`
|
||||
|
||||
export const navigateToGuideTopTab = (tab: GuideTopTab) => {
|
||||
uni.reLaunch({
|
||||
url: guideTopTabUrl(tab)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user