修复导览与讲解位置预览逻辑

This commit is contained in:
lyf
2026-06-30 11:00:49 +08:00
parent f2a33888b1
commit 1c2cc788d1
29 changed files with 1111 additions and 756 deletions

View File

@@ -18,17 +18,18 @@
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5010120260519001",
"@dcloudio/uni-app-plus": "3.0.0-alpha-5010120260519001",
"@dcloudio/uni-components": "3.0.0-alpha-5010120260519001",
"@dcloudio/uni-h5": "3.0.0-alpha-5010120260519001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-5010120260519001",
"three": "0.163.0",
"vue": "3.4.21"
},
"devDependencies": {
"@types/three": "0.163.0",
"@dcloudio/types": "3.4.31",
"@dcloudio/uni-automator": "3.0.0-alpha-5010120260519001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260519001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260519001",
"@types/three": "0.163.0",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@vue/runtime-core": "3.4.21",

109
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@dcloudio/uni-app-plus':
specifier: 3.0.0-alpha-5010120260519001
version: 3.0.0-alpha-5010120260519001(postcss@8.5.15)(rollup@4.60.4)(vite@5.2.8(@types/node@25.9.1)(sass@1.99.0)(terser@5.47.1))(vue@3.4.21(typescript@5.3.3))
'@dcloudio/uni-components':
specifier: 3.0.0-alpha-5010120260519001
version: 3.0.0-alpha-5010120260519001(postcss@8.5.15)(rollup@4.60.4)(vite@5.2.8(@types/node@25.9.1)(sass@1.99.0)(terser@5.47.1))(vue@3.4.21(typescript@5.3.3))
'@dcloudio/uni-h5':
specifier: 3.0.0-alpha-5010120260519001
version: 3.0.0-alpha-5010120260519001(postcss@8.5.15)(rollup@4.60.4)(vite@5.2.8(@types/node@25.9.1)(sass@1.99.0)(terser@5.47.1))(vue@3.4.21(typescript@5.3.3))
@@ -136,6 +139,10 @@ packages:
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@7.29.7':
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
engines: {node: '>=6.9.0'}
'@babel/helper-member-expression-to-functions@7.28.5':
resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==}
engines: {node: '>=6.9.0'}
@@ -178,14 +185,26 @@ packages:
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.29.7':
resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
engines: {node: '>=6.9.0'}
'@babel/helper-wrap-function@7.28.6':
resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==}
engines: {node: '>=6.9.0'}
@@ -2420,8 +2439,8 @@ packages:
debug:
optional: true
form-data@3.0.4:
resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==}
form-data@3.0.5:
resolution: {integrity: sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==}
engines: {node: '>= 6'}
forwarded@0.2.0:
@@ -2485,9 +2504,9 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
glob@7.2.0:
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -2533,6 +2552,10 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
@@ -2836,8 +2859,8 @@ packages:
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@3.14.2:
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
js-yaml@3.15.0:
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
hasBin: true
js-yaml@4.2.0:
@@ -3097,8 +3120,8 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
nwsapi@2.2.24:
resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
@@ -4054,8 +4077,8 @@ packages:
write-file-atomic@3.0.3:
resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==}
ws@7.5.10:
resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
ws@7.5.11:
resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==}
engines: {node: '>=8.3.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -4128,8 +4151,8 @@ packages:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
yargs@16.2.2:
resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==}
engines: {node: '>=10'}
yocto-queue@0.1.0:
@@ -4150,7 +4173,7 @@ snapshots:
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
'@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
@@ -4218,7 +4241,7 @@ snapshots:
'@babel/helper-compilation-targets@7.28.6':
dependencies:
'@babel/compat-data': 7.29.3
'@babel/helper-validator-option': 7.27.1
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.2
lru-cache: 5.1.1
semver: 6.3.1
@@ -4256,6 +4279,8 @@ snapshots:
'@babel/helper-globals@7.28.0': {}
'@babel/helper-globals@7.29.7': {}
'@babel/helper-member-expression-to-functions@7.28.5':
dependencies:
'@babel/traverse': 7.29.0
@@ -4274,7 +4299,7 @@ snapshots:
dependencies:
'@babel/core': 7.25.2
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -4283,7 +4308,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.0
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -4321,10 +4346,16 @@ snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helper-validator-option@7.29.7': {}
'@babel/helper-wrap-function@7.28.6':
dependencies:
'@babel/template': 7.28.6
@@ -4955,7 +4986,7 @@ snapshots:
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/types': 7.29.0
@@ -4971,8 +5002,8 @@ snapshots:
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@bcoe/v8-coverage@0.2.3': {}
@@ -5559,7 +5590,7 @@ snapshots:
camelcase: 5.3.1
find-up: 4.1.0
get-package-type: 0.1.0
js-yaml: 3.14.2
js-yaml: 3.15.0
resolve-from: 5.0.0
'@istanbuljs/schema@0.1.6': {}
@@ -5643,7 +5674,7 @@ snapshots:
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit: 0.1.2
glob: 7.2.3
glob: 7.2.0
graceful-fs: 4.2.11
istanbul-lib-coverage: 3.2.2
istanbul-lib-instrument: 5.2.1
@@ -7271,12 +7302,12 @@ snapshots:
follow-redirects@1.16.0: {}
form-data@3.0.4:
form-data@3.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.3
hasown: 2.0.4
mime-types: 2.1.35
forwarded@0.2.0: {}
@@ -7336,7 +7367,7 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@7.2.3:
glob@7.2.0:
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
@@ -7385,6 +7416,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
html-encoding-sniffer@2.0.1:
@@ -7567,7 +7602,7 @@ snapshots:
jest-util: 27.5.1
jest-validate: 27.5.1
prompts: 2.4.2
yargs: 16.2.0
yargs: 16.2.2
transitivePeerDependencies:
- bufferutil
- canvas
@@ -7584,7 +7619,7 @@ snapshots:
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
glob: 7.2.0
graceful-fs: 4.2.11
jest-circus: 27.5.1
jest-environment-jsdom: 27.5.1
@@ -7789,7 +7824,7 @@ snapshots:
cjs-module-lexer: 1.4.3
collect-v8-coverage: 1.0.3
execa: 5.1.1
glob: 7.2.3
glob: 7.2.0
graceful-fs: 4.2.11
jest-haste-map: 27.5.1
jest-message-util: 27.5.1
@@ -7898,7 +7933,7 @@ snapshots:
js-tokens@9.0.1: {}
js-yaml@3.14.2:
js-yaml@3.15.0:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
@@ -7918,12 +7953,12 @@ snapshots:
decimal.js: 10.6.0
domexception: 2.0.1
escodegen: 2.1.0
form-data: 3.0.4
form-data: 3.0.5
html-encoding-sniffer: 2.0.1
http-proxy-agent: 4.0.1
https-proxy-agent: 5.0.1
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.23
nwsapi: 2.2.24
parse5: 6.0.1
saxes: 5.0.1
symbol-tree: 3.2.4
@@ -7934,7 +7969,7 @@ snapshots:
whatwg-encoding: 1.0.5
whatwg-mimetype: 2.3.0
whatwg-url: 8.7.0
ws: 7.5.10
ws: 7.5.11
xml-name-validator: 3.0.0
transitivePeerDependencies:
- bufferutil
@@ -8140,7 +8175,7 @@ snapshots:
dependencies:
boolbase: 1.0.0
nwsapi@2.2.23: {}
nwsapi@2.2.24: {}
object-inspect@1.13.4: {}
@@ -8478,7 +8513,7 @@ snapshots:
rimraf@3.0.2:
dependencies:
glob: 7.2.3
glob: 7.2.0
rollup@4.60.4:
dependencies:
@@ -8812,7 +8847,7 @@ snapshots:
test-exclude@6.0.0:
dependencies:
'@istanbuljs/schema': 0.1.6
glob: 7.2.3
glob: 7.2.0
minimatch: 3.1.5
text-table@0.2.0: {}
@@ -9069,7 +9104,7 @@ snapshots:
signal-exit: 3.0.7
typedarray-to-buffer: 3.1.5
ws@7.5.10: {}
ws@7.5.11: {}
ws@8.18.0: {}
@@ -9111,7 +9146,7 @@ snapshots:
yargs-parser@20.2.9: {}
yargs@16.2.0:
yargs@16.2.2:
dependencies:
cliui: 7.0.4
escalade: 3.2.0

View File

@@ -2,7 +2,7 @@
这些 JSON 是早期演示数据,当前导览模块不再以这里作为数据源。
当前内导览、设施搜索、位置预览统一通过 Provider / Adapter / Repository / UseCase 数据边界读取 `static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy`,页面和展示组件不要直接依赖静态包 JSON 文件。
当前内导览、设施搜索、位置预览统一通过 Provider / Adapter / Repository / UseCase 数据边界读取 `static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy`,页面和展示组件不要直接依赖静态包 JSON 文件。
当前展厅、单元、讲解点、展品、讲解词和 POI 业务内容统一通过 `static/guide-data` 静态数据包读取,默认数据源模式为 `VITE_GUIDE_CONTENT_SOURCE_MODE=static`

View File

@@ -24,11 +24,6 @@
</view>
<template v-else>
<view class="section-heading">
<text class="section-title">请选择展厅</text>
<text class="section-count">{{ filteredHalls.length }} 个展厅</text>
</view>
<view v-if="filteredHalls.length" class="hall-list">
<view
v-for="hall in filteredHalls"
@@ -37,10 +32,10 @@
@tap="handleHallClick(hall.id)"
>
<image
v-if="hall.image"
v-if="hallIconUrl(hall)"
class="hall-thumb"
:src="hall.image"
mode="aspectFill"
:src="hallIconUrl(hall)"
mode="aspectFit"
/>
<view v-else class="hall-thumb placeholder">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
@@ -96,8 +91,26 @@ const emit = defineEmits<{
back: []
}>()
const HALL_ICON_BASE = '/static/icons/halls'
const hallIconMap: Record<string, string> = {
宇宙厅: `${HALL_ICON_BASE}/universe.jpg`,
地球厅: `${HALL_ICON_BASE}/earth.jpg`,
演化厅: `${HALL_ICON_BASE}/evolution.jpg`,
恐龙厅: `${HALL_ICON_BASE}/dinosaur.jpg`,
人类厅: `${HALL_ICON_BASE}/human.jpg`,
动物厅: `${HALL_ICON_BASE}/animal.jpg`,
生物厅: `${HALL_ICON_BASE}/biology.jpg`,
生态厅: `${HALL_ICON_BASE}/ecology.jpg`,
家园厅: `${HALL_ICON_BASE}/homeland.jpg`
}
const filteredHalls = computed(() => props.halls)
const hallIconUrl = (hall: ExplainHallSelectItem) => {
return hallIconMap[hall.name.trim()] || hall.image || ''
}
const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲'
const handleHallClick = (hallId: string) => {
@@ -124,8 +137,6 @@ const handleBack = () => {
box-sizing: border-box;
}
.section-title,
.section-count,
.state-title,
.state-desc,
.hall-name,
@@ -180,29 +191,6 @@ const handleBack = () => {
color: #151713;
}
.section-heading {
margin-top: 0;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title {
font-size: 19px;
line-height: 27px;
font-weight: 800;
color: #151713;
}
.section-count {
flex-shrink: 0;
font-size: 12px;
line-height: 18px;
color: #7b8275;
}
.hall-list {
display: flex;
flex-direction: column;
@@ -231,7 +219,10 @@ const handleBack = () => {
width: 68px;
height: 68px;
border-radius: 8px;
background: #f1f2ee;
background: #ffffff;
border: 1px solid rgba(36, 49, 42, 0.06);
box-sizing: border-box;
object-fit: contain;
}
.hall-thumb.placeholder {

View File

@@ -38,10 +38,10 @@
<!-- 功能按钮 -->
<cover-view class="action-buttons">
<!-- 进入 3D 内模式按钮 -->
<!-- 进入 3D 内模式按钮 -->
<cover-view class="action-btn indoor-mode-btn" @tap="handleEnter3D">
<cover-view class="btn-text">🏛</cover-view>
<cover-view class="btn-sub-text"></cover-view>
<cover-view class="btn-sub-text"></cover-view>
</cover-view>
<cover-view class="action-btn" @tap="handleLocation">
<cover-view class="btn-text">📍</cover-view>
@@ -129,7 +129,7 @@ const emit = defineEmits<{
// POI ID: 7043042949572197975
const mapCenter = ref<MapCenter>({
latitude: 22.692763, // 深圳自然博物馆纬度
longitude: 114.363572 // 深圳自然博物馆经度(调整到外 2D 视觉中心)
longitude: 114.363572 // 深圳自然博物馆经度(调整到外 2D 视觉中心)
})
// 地图缩放级别(调整为适中比例,既能看到建筑全貌又能看清细节)
@@ -378,9 +378,9 @@ const handleZoomOut = () => {
}
}
// 进入 3D 内模式
// 进入 3D 内模式
const handleEnter3D = () => {
console.log('进入 3D 内模式')
console.log('进入 3D 内模式')
emit('enter3DMode')
}
@@ -490,7 +490,7 @@ onMounted(async () => {
color: #333333;
}
// 内模式按钮
// 内模式按钮
.indoor-mode-btn {
width: 46px;
height: 64px !important;

View File

@@ -72,6 +72,7 @@ type TouchGestureMode = 'orbit' | 'pan'
type ZoomCameraSource = 'button' | 'gesture'
const CANVAS_FONT_FAMILY = '"HarmonyOS Sans SC", "HarmonyOS Sans", "鸿蒙黑体", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", Arial, sans-serif'
const FLOOR_CAMERA_DISTANCE_FACTOR = 0.78
interface CameraFitOptions {
distanceFactor?: number
@@ -203,7 +204,7 @@ const props = withDefaults(defineProps<{
showPoi: false,
targetFocus: null,
touchGestureMode: 'orbit',
targetFocusDistanceFactor: 0.22,
targetFocusDistanceFactor: 0.36,
routePreview: null,
showRoute: false,
autoSwitch: true,
@@ -327,6 +328,9 @@ const syncControlInteractionOptions = () => {
}
const corePoiCategories = new Set([
'touring_poi',
'exhibition_hall',
'exhibition_hall_entrance',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service'
@@ -334,10 +338,12 @@ const corePoiCategories = new Set([
const poiCategoryPriority: Record<string, number> = {
target_preview: 100,
exhibition_hall: 96,
exhibition_hall_entrance: 92,
touring_poi: 90,
accessibility_special_service: 88,
basic_service_facility: 82,
transport_circulation: 76,
touring_poi: 58,
operation_experience: 46
}
@@ -351,6 +357,9 @@ const poiGlyphMap: Record<string, string> = {
stair: '梯',
experience: '展',
theater: '演',
exhibition_hall: '展',
hall_entrance: '门',
entrance_exit: '门',
target_preview: '位'
}
@@ -2217,6 +2226,11 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
screenOffsetRatio: new THREE.Vector2(-0.045, 0.02),
up: new THREE.Vector3(0, 1, 0)
}
: activeView.value === 'floor'
? {
distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
up: new THREE.Vector3(0, 1, 0)
}
: {}
setCameraView(center, maxDim, direction, overviewFitOptions)
@@ -2918,6 +2932,8 @@ const showFocusPoiAffordances = (poi: RenderPoi) => {
const getPoiColor = (category: string) => {
const colorMap: Record<string, string> = {
touring_poi: '#2f6fed',
exhibition_hall: '#2f6fed',
exhibition_hall_entrance: '#4659d8',
basic_service_facility: '#1f8f5f',
transport_circulation: '#d77b20',
accessibility_special_service: '#8a5cf6',
@@ -3166,7 +3182,7 @@ const triggerPoiTapFeedback = (sprite: THREE.Sprite) => {
getPoiSpriteUserData(sprite).feedbackUntil = performance.now() + poiTapFeedbackDurationMs
}
const handleSceneTap = (event: PointerEvent) => {
const handleSceneTap = async (event: PointerEvent) => {
if (!camera || !renderer || !getContainerElement()) return
const rect = renderer.domElement.getBoundingClientRect()
@@ -3177,7 +3193,7 @@ const handleSceneTap = (event: PointerEvent) => {
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(pointer, camera)
if (activeView.value === 'floor' && poiGroup) {
if ((activeView.value === 'floor' || activeView.value === 'overview') && poiGroup) {
const poiHits = raycaster.intersectObjects([...getPoiHitTargets(), ...getPoiSprites()], false)
const hit = poiHits.find((item) => (
item.object.visible
@@ -3187,10 +3203,27 @@ const handleSceneTap = (event: PointerEvent) => {
const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : findNearestPoiMarkerByScreenPoint(event, rect)
if (hitMarker?.userData.poi) {
const poi = hitMarker.userData.poi as RenderPoi
const selectedPoi = hitMarker.userData.poi as RenderPoi
if (activeView.value === 'overview') {
disableAutoSwitchTemporarily(10000)
try {
await loadFloor(selectedPoi.floorId, {
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
suppressProgress: true,
allowOverviewFallback: true
})
emit('floorChange', selectedPoi.floorId)
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('展厅点位楼层切换失败:', error)
}
}
const poi = findLoadedPoi(selectedPoi.id) || selectedPoi
const focusMarker = findPoiSprite(poi.id) || hitMarker
selectedPOI.value = poi
activeFocusPoiId.value = poi.id
triggerPoiTapFeedback(hitMarker)
triggerPoiTapFeedback(focusMarker)
updatePoiMarkerFocus()
showFocusPoiAffordances(poi)
focusCameraOnPoi(poi)
@@ -3231,7 +3264,7 @@ const handlePointerUp = (event: PointerEvent) => {
if (moveDistance > 8) return
handleSceneTap(event)
void handleSceneTap(event)
}
const emitTargetFocus = (
@@ -3302,9 +3335,12 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
try {
if (activeView.value !== 'floor' || currentFloor.value !== request.floorId) {
isLoading.value = true
loadError.value = false
await loadFloor(request.floorId)
await loadFloor(request.floorId, {
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
suppressProgress: true,
allowOverviewFallback: true
})
isLoading.value = false
emit('floorChange', request.floorId)
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
@@ -3419,31 +3455,19 @@ const init3DScene = async () => {
const handleFloorChange = async (floorId: string) => {
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
const showLoading = !hasSceneToKeep && !canLoadFloorSilently(floorId)
try {
if (showLoading) {
isLoading.value = true
}
loadError.value = false
await loadFloor(floorId, {
preserveCurrentSceneUntilReady: hasSceneToKeep,
suppressProgress: !showLoading
suppressProgress: true,
allowOverviewFallback: true
})
updatePoiVisibilityByDistance()
if (showLoading) {
isLoading.value = false
} else {
isLoading.value = false
}
isLoading.value = false
emit('floorChange', floorId)
} catch (error) {
if (isStaleModelLoadError(error)) {
if (showLoading) {
isLoading.value = false
}
return
}
if (isStaleModelLoadError(error)) return
console.error('楼层模型加载失败:', error)
loadError.value = true

View File

@@ -126,11 +126,15 @@
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
</view>
<view
<scroll-view
v-if="showIndoorRightControls && showFloor"
class="floor-switcher"
:class="`side-${floorSide}`"
:style="floorSwitcherStyle"
scroll-y
:show-scrollbar="false"
:scroll-into-view="activeFloorScrollIntoView"
:scroll-with-animation="true"
>
<view
v-if="showFloorHeader"
@@ -142,15 +146,16 @@
<text class="floor-header-label">楼层</text>
</view>
<view
v-for="floor in floorLabels"
:key="floor"
v-for="floor in floorItems"
:id="floor.scrollId"
:key="floor.id"
class="floor-item"
:class="{ active: activeFloor === floor && layerMode !== 'multi' }"
@tap="handleFloorChange(floor)"
:class="{ active: activeFloorId === floor.id && layerMode !== 'multi' }"
@tap="handleFloorChange(floor.label)"
>
<text class="floor-label">{{ floor }}</text>
<text class="floor-label">{{ floor.label }}</text>
</view>
</view>
</scroll-view>
<view v-if="showZoomControls" class="zoom-controls" :style="zoomControlsStyle">
<view class="zoom-btn" @tap="handleZoomClick('in')">
@@ -364,7 +369,7 @@ const props = withDefaults(defineProps<{
dimmed: false,
targetFocusRequest: null,
touchGestureMode: 'orbit',
targetFocusDistanceFactor: 0.22,
targetFocusDistanceFactor: 0.36,
routePreview: null,
showRoute: false,
disableAutoExit: false,
@@ -415,7 +420,11 @@ const indoorRendererRef = ref<{
} | null>(null)
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
const floorLabels = computed(() => indoorFloors.value.map((floor) => floor.label))
const createFloorScrollId = (floorId: string) => `guide-floor-${floorId.replace(/[^a-zA-Z0-9_-]/g, '-')}`
const floorItems = computed(() => indoorFloors.value.map((floor) => ({
...floor,
scrollId: createFloorScrollId(floor.id)
})))
// #ifdef H5
const effectiveIndoorModelSource = computed(() => props.indoorModelSource)
// #endif
@@ -435,6 +444,29 @@ const activeFloorId = computed(() => {
))
return normalizedFloor?.id || normalizedFloorId
})
const activeFloorScrollIntoView = ref('')
const syncActiveFloorScroll = () => {
if (props.mapType !== 'indoor' || props.indoorView === 'overview' || props.layerMode === 'multi') {
activeFloorScrollIntoView.value = ''
return
}
const matchedFloor = floorItems.value.find((floor) => floor.id === activeFloorId.value)
activeFloorScrollIntoView.value = matchedFloor?.scrollId || ''
}
watch(
() => [
activeFloorId.value,
props.indoorView,
props.layerMode,
props.mapType,
floorItems.value.map((floor) => floor.id).join('|')
],
syncActiveFloorScroll,
{ immediate: true }
)
const searchFieldStyle = computed(() => ({
top: props.searchTop

View File

@@ -22,7 +22,7 @@
<view class="action-row">
<view class="action-btn secondary" @tap="emit('viewOutdoorMap')">
<text class="action-text">外入口参考</text>
<text class="action-text">外入口参考</text>
</view>
<view class="action-btn primary" @tap="handleShowTargetLocation">
<text class="action-text">查看三维位置</text>

View File

@@ -220,7 +220,7 @@ const canNavigate = computed(() => {
const navigateButtonText = computed(() => {
if (props.loading) return '规划中...'
if (hasRoute.value) return '开始导航'
if (hasRoute.value) return '打开地图'
return '规划路线'
})

View File

@@ -195,17 +195,11 @@ const canViewRoute = computed(() => Boolean(
const canPrimaryAction = computed(() => canViewRoute.value && !props.error)
const primaryActionText = computed(() => (
props.hasRoutePreview
? '开始导览'
: (props.routeReady ? '查看路线' : '查看位置关系')
))
const primaryActionText = computed(() => '查看位置关系')
const routeOptionsTitle = computed(() => '馆内导览')
const loadingText = computed(() => (
props.routeReady ? '正在规划馆内导览路线' : '正在生成馆内位置关系'
))
const loadingText = computed(() => '正在生成馆内位置关系')
const collapsedSummary = computed(() => {
if (props.startPoint && props.endPoint) {
@@ -217,7 +211,7 @@ const collapsedSummary = computed(() => {
const routeOptions = computed(() => {
const unavailable = Boolean(props.error)
const summaryText = props.summary || (props.routeReady ? '选择后规划真实路线' : '选择后查看位置关系')
const summaryText = props.summary || '选择后查看位置关系'
return [
{
id: 'recommended',
@@ -226,23 +220,21 @@ const routeOptions = computed(() => {
? '当前暂不可用'
: props.hasRoutePreview
? summaryText
: props.routeReady
? '调用 SGS SDK 路线规划'
: '查看起点终点位置关系',
: '查看起点终点位置关系',
active: !unavailable,
disabled: unavailable
},
{
id: 'stairs',
title: '少走楼梯',
meta: props.routeReady ? '偏好能力待接入' : '偏好预览,待路线数据验证',
meta: '偏好预览,待路线数据验证',
active: false,
disabled: true
},
{
id: 'elevator',
title: '电梯优先',
meta: props.routeReady ? '偏好能力待接入' : '偏好预览,待路线数据验证',
meta: '偏好预览,待路线数据验证',
active: false,
disabled: true
}

View File

@@ -67,5 +67,7 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => ({
sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness,
accessible: isPoiAccessible(poi)
accessible: isPoiAccessible(poi),
kind: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility',
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined
})

View File

@@ -7,6 +7,7 @@ import type {
GuideRouteTarget
} from '@/domain/museum'
import type {
SgsRoutePathPointPayload,
SgsRoutePlanResponsePayload
} from '@/data/providers/sgsSdkApiProvider'
import type {
@@ -31,10 +32,25 @@ type GeoJsonLineString = {
coordinates?: Array<[number, number] | [number, number, number]>
}
const finiteNumber = (value: unknown) => {
const numericValue = Number(value)
return Number.isFinite(numericValue) ? numericValue : undefined
}
const stringifyRouteId = (value: unknown, fallback = '') => {
if (value === null || typeof value === 'undefined' || value === '') return fallback
return String(value)
}
const samePosition = (
a: [number, number, number],
b: [number, number, number]
) => a.every((value, index) => Math.abs(value - b[index]) < 0.001)
const pointPosition = (point: SgsRoutePathNode): [number, number, number] => [
Number(point.x || 0),
Number(point.y || 0),
Number(point.z || 0)
finiteNumber(point.x) ?? 0,
finiteNumber(point.y) ?? 0,
finiteNumber(point.z) ?? 0
]
const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
@@ -165,8 +181,10 @@ export const toGuideRouteResultFromSgs = (
const floorLabelFor = (
floorId: string,
startTarget: GuideRouteTarget,
endTarget: GuideRouteTarget
endTarget: GuideRouteTarget,
fallbackLabel?: string | null
) => {
if (fallbackLabel) return fallbackLabel
if (floorId === startTarget.floorId) return startTarget.floorLabel
if (floorId === endTarget.floorId) return endTarget.floorLabel
return floorId
@@ -176,19 +194,54 @@ const pointFromBackendNode = (
node: NonNullable<SgsRoutePlanResponsePayload['nodePaths']>[number],
index: number,
fallbackFloorId: string
): GuideRoutePoint => ({
nodeId: String(node.id || `sgs-route-node-${index}`),
floorId: toAppFloorId(node.floorId ?? fallbackFloorId),
position: [
Number(node.x || 0),
0,
Number(node.y || 0)
]
})
): GuideRoutePoint | null => {
const x = finiteNumber(node.x)
const z = finiteNumber(node.y)
if (typeof x !== 'number' || typeof z !== 'number') return null
return {
nodeId: stringifyRouteId(node.id, `sgs-route-node-${index}`),
floorId: stringifyRouteId(node.floorId, fallbackFloorId),
position: [
x,
finiteNumber(node.z) ?? 0,
z
]
}
}
const pointFromPathPoint = (
point: SgsRoutePathPointPayload,
floorId: string,
nodeId: string
): GuideRoutePoint | null => {
const x = finiteNumber(point.x)
const z = finiteNumber(point.z)
if (typeof x !== 'number' || typeof z !== 'number') return null
return {
nodeId,
floorId,
position: [
x,
finiteNumber(point.y) ?? 0,
z
]
}
}
const pointsFromPathPoints = (
pathPoints: SgsRoutePathPointPayload[] | null | undefined,
floorId: string,
nodeIdPrefix: string
) => (pathPoints || [])
.map((point, index) => pointFromPathPoint(point, floorId, `${nodeIdPrefix}-pp-${index}`))
.filter((point): point is GuideRoutePoint => Boolean(point))
const pointsFromGeoJson = (
geoJson: string | null | undefined,
floorId: string
floorId: string,
nodeIdPrefix = floorId
): GuideRoutePoint[] => {
if (!geoJson) return []
@@ -198,20 +251,147 @@ const pointsFromGeoJson = (
? parsed.coordinates
: []
return coordinates.map((coordinate, index) => ({
nodeId: `${floorId}-geo-${index}`,
floorId,
position: [
Number(coordinate[0] || 0),
0,
Number(coordinate[1] || 0)
]
}))
return coordinates
.map((coordinate, index) => {
const x = finiteNumber(coordinate[0])
const z = finiteNumber(coordinate[1])
if (typeof x !== 'number' || typeof z !== 'number') return null
return {
nodeId: `${nodeIdPrefix}-geo-${index}`,
floorId,
position: [
x,
finiteNumber(coordinate[2]) ?? 0,
z
]
}
})
.filter((point): point is GuideRoutePoint => Boolean(point))
} catch {
return []
}
}
const routePointFromEndpoint = (
endpoint: GuideRouteEndpoint,
suffix: string
): GuideRoutePoint => ({
nodeId: endpoint.routeNodeId || `${endpoint.poiId}-${suffix}`,
floorId: endpoint.floorId,
position: endpoint.position
})
const prependIfDifferent = (
points: GuideRoutePoint[],
point: GuideRoutePoint
) => {
const firstPoint = points[0]
return firstPoint && samePosition(firstPoint.position, point.position)
? points
: [point, ...points]
}
const appendIfDifferent = (
points: GuideRoutePoint[],
point: GuideRoutePoint
) => {
const lastPoint = points[points.length - 1]
return lastPoint && samePosition(lastPoint.position, point.position)
? points
: [...points, point]
}
const createPlanFloorSegments = (
points: GuideRoutePoint[],
startTarget: GuideRouteTarget,
endTarget: GuideRouteTarget
): GuideRouteFloorSegment[] => points.reduce<GuideRouteFloorSegment[]>((segments, point) => {
const current = segments[segments.length - 1]
if (current && current.floorId === point.floorId) {
current.points.push(point)
return segments
}
segments.push({
floorId: point.floorId,
floorLabel: floorLabelFor(point.floorId, startTarget, endTarget),
points: [point]
})
return segments
}, [])
const isWalkSegment = (
segment: NonNullable<SgsRoutePlanResponsePayload['segments']>[number]
) => {
const type = stringifyRouteId(segment.transferType || segment.segmentType || segment.type).toUpperCase()
return !type || type === 'WALK'
}
const createSegmentFloorSegments = (
route: SgsRoutePlanResponsePayload,
start: GuideRouteEndpoint,
end: GuideRouteEndpoint,
startTarget: GuideRouteTarget,
endTarget: GuideRouteTarget,
nodePoints: GuideRoutePoint[]
): GuideRouteFloorSegment[] => {
const segments = (route.segments || [])
.filter(isWalkSegment)
.map<GuideRouteFloorSegment | null>((segment, segmentIndex) => {
const floorId = stringifyRouteId(segment.floorId, start.floorId)
const nodeIdPrefix = `${floorId}-segment-${segmentIndex}`
const nodePathIds = new Set(
(segment.nodePathIds || [])
.map((nodeId) => stringifyRouteId(nodeId))
.filter(Boolean)
)
const points = pointsFromPathPoints(segment.pathPoints, floorId, nodeIdPrefix)
const geoPoints = points.length
? points
: pointsFromGeoJson(segment.pathGeoJson, floorId, nodeIdPrefix)
const fallbackPoints = geoPoints.length
? geoPoints
: nodePoints.filter((point) => (
point.floorId === floorId
&& (!nodePathIds.size || nodePathIds.has(point.nodeId))
))
if (!fallbackPoints.length) return null
return {
floorId,
floorLabel: floorLabelFor(floorId, startTarget, endTarget, segment.floorName),
points: fallbackPoints.map((point, index) => ({
...point,
nodeId: point.nodeId || `${nodeIdPrefix}-${index}`
}))
}
})
.filter((segment): segment is GuideRouteFloorSegment => Boolean(segment))
if (!segments.length) return []
const startPoint = routePointFromEndpoint(start, 'start')
const endPoint = routePointFromEndpoint(end, 'end')
const anchoredSegments = segments.map((segment) => ({
...segment,
points: [...segment.points]
}))
const firstSegment = anchoredSegments[0]
const lastSegment = anchoredSegments[anchoredSegments.length - 1]
if (firstSegment?.floorId === start.floorId) {
firstSegment.points = prependIfDifferent(firstSegment.points, startPoint)
}
if (lastSegment?.floorId === end.floorId) {
lastSegment.points = appendIfDifferent(lastSegment.points, endPoint)
}
return anchoredSegments
}
export const toGuideRouteResultFromSgsPlan = (
route: SgsRoutePlanResponsePayload,
startTarget: GuideRouteTarget,
@@ -219,67 +399,39 @@ export const toGuideRouteResultFromSgsPlan = (
): GuideRouteResult => {
const start = targetEndpoint(startTarget)
const end = targetEndpoint(endTarget)
const nodePoints = (route.nodePaths || []).map((node, index) => (
pointFromBackendNode(node, index, start.floorId)
))
const routePoints = nodePoints.length
? [
{
nodeId: start.routeNodeId,
floorId: start.floorId,
position: start.position
},
...nodePoints,
{
nodeId: end.routeNodeId,
floorId: end.floorId,
position: end.position
}
]
: pointsFromGeoJson(route.pathGeoJson, start.floorId)
const fallbackPoints = routePoints.length
? routePoints
: [
{
nodeId: start.routeNodeId,
floorId: start.floorId,
position: start.position
},
{
nodeId: end.routeNodeId,
floorId: end.floorId,
position: end.position
}
]
const floorSegments = route.segments?.length
? route.segments.map((segment, segmentIndex) => {
const floorId = String(segment.floorId || start.floorId)
const points = pointsFromGeoJson(segment.pathGeoJson, floorId)
return {
floorId,
floorLabel: segment.floorName || floorLabelFor(floorId, startTarget, endTarget),
points: points.length
? points
: fallbackPoints.filter((point) => point.floorId === floorId).map((point, index) => ({
...point,
nodeId: point.nodeId || `${floorId}-segment-${segmentIndex}-${index}`
}))
}
}).filter((segment) => segment.points.length)
: createFloorSegments(fallbackPoints, startTarget, endTarget)
const startPoint = routePointFromEndpoint(start, 'start')
const endPoint = routePointFromEndpoint(end, 'end')
const nodePoints = (route.nodePaths || [])
.map((node, index) => pointFromBackendNode(node, index, start.floorId))
.filter((point): point is GuideRoutePoint => Boolean(point))
const segmentFloorSegments = createSegmentFloorSegments(
route,
start,
end,
startTarget,
endTarget,
nodePoints
)
const routeGeoPoints = pointsFromGeoJson(route.pathGeoJson, start.floorId, 'sgs-route')
const fallbackPoints = nodePoints.length
? [startPoint, ...nodePoints, endPoint]
: routeGeoPoints.length
? [startPoint, ...routeGeoPoints, endPoint]
: [startPoint, endPoint]
const floorSegments = segmentFloorSegments.length
? segmentFloorSegments
: createPlanFloorSegments(fallbackPoints, startTarget, endTarget)
const routePoints = floorSegments.flatMap((segment) => segment.points)
return {
id: `sgs-api-route-${start.poiId}-${end.poiId}`,
start,
end,
distanceMeters: Number(route.distance || 0),
nodeIds: fallbackPoints.map((point) => point.nodeId),
points: fallbackPoints,
nodeIds: routePoints.map((point) => point.nodeId),
points: routePoints,
floorSegments,
connectorPoints: extractFloorConnectorPoints(fallbackPoints, startTarget, endTarget)
connectorPoints: extractFloorConnectorPoints(routePoints, startTarget, endTarget)
}
}
@@ -297,8 +449,8 @@ const extractFloorConnectorPoints = (
])
for (let i = 0; i < points.length - 1; i++) {
const currentFloorId = toAppFloorId(points[i].floorId)
const nextFloorId = toAppFloorId(points[i + 1].floorId)
const currentFloorId = points[i].floorId
const nextFloorId = points[i + 1].floorId
if (currentFloorId !== nextFloorId) {
const fromLabel = labels.get(currentFloorId) || currentFloorId

View File

@@ -161,19 +161,30 @@ export interface SgsRouteStepNodePayload {
nodeType?: string | null
x?: number | null
y?: number | null
z?: number | null
}
export interface SgsRoutePathPointPayload {
x?: number | null
y?: number | null
z?: number | null
}
export interface SgsRouteSegmentPayload {
floorId?: string | number | null
floorCode?: string | null
floorName?: string | null
startNodeId?: string | number | null
endNodeId?: string | number | null
startNodeName?: string | null
endNodeName?: string | null
segmentType?: string | null
type?: string | null
transferType?: string | null
distance?: number | null
duration?: number | null
pathGeoJson?: string | null
pathPoints?: SgsRoutePathPointPayload[]
nodePathIds?: Array<string | number | null>
}

View File

@@ -230,7 +230,7 @@ export const createStaticNavAssetsProvider = (
manifestRequest = requestJson<StaticNavManifestPayload>(provider.assetUrl('app_nav_manifest.json'))
.then((data) => {
if (data.status !== 'pass') {
throw new Error('内导览资源包状态不是 pass')
throw new Error('内导览资源包状态不是 pass')
}
manifestCache = data

View File

@@ -18,7 +18,7 @@ interface FloorLike {
const indoorFloorCodePattern = /^L-?\d+(?:\.\d+)?$/i
const indoorFloorLabelPattern = /^(?:B\d+(?:\.\d+)?|\d+(?:\.\d+)?F)$/i
const rawNumericIdPattern = /^\d{6,}$/
const nonNavigableFloorPattern = /(EXTERIOR|OUTDOOR|BUILDING|FACADE|EXTERNAL|外立面|建筑外观|建筑外立面|外模型|外场景|外底图|外建筑|外外观|外)$/i
const nonNavigableFloorPattern = /(EXTERIOR|OUTDOOR|BUILDING|FACADE|EXTERNAL|外立面|建筑外观|建筑外立面|外模型|外场景|外底图|外建筑|外外观|外)$/i
const normalizeValue = (value: unknown) => (
value === null || typeof value === 'undefined' ? '' : String(value).trim()

View File

@@ -1,3 +1,8 @@
import type {
MuseumPoiEntrance,
MuseumPoiKind
} from '@/domain/museum'
export interface GuideRenderPoi {
id: string
name: string
@@ -7,6 +12,10 @@ export interface GuideRenderPoi {
iconType: string
positionGltf?: [number, number, number]
sourceObjectName?: string
kind?: MuseumPoiKind
hallId?: string
hallName?: string
entrances?: MuseumPoiEntrance[]
}
export interface GuideModelFloorAsset {

View File

@@ -4,7 +4,7 @@ import type {
export const NAV_ROUTE_GRAPH_READY = false
export const NAV_ROUTE_UNAVAILABLE_MESSAGE = '当前暂不支持正式导航,可查看位置预览、路线示意和室外参考'
export const NAV_ROUTE_UNAVAILABLE_MESSAGE = '当前可查看位置预览、位置关系和馆外参考,暂不提供正式室内导航'
export const NAV_ROUTE_READINESS: GuideRouteReadiness = {
ready: NAV_ROUTE_GRAPH_READY,

View File

@@ -61,7 +61,7 @@
<view class="action-bar">
<view
class="location-btn"
:class="{ disabled: !exhibit.location }"
:class="{ disabled: !hallLocationId }"
@tap="handleNavigate"
>
<svg class="location-icon" width="19" height="19" viewBox="0 0 24 24" fill="none">
@@ -134,12 +134,45 @@ const showAudioPlayer = ref(false)
const currentAudio = ref<AudioItem | null>(null)
const isPlaying = ref(false)
const activeTopTab = ref<GuideTopTab>('explain')
const resolvedHallId = ref('')
const heroImage = computed(() => exhibit.value.coverImages[0])
const detailMeta = computed(() => [
exhibit.value.hallName,
exhibit.value.floorLabel
].filter(Boolean).join(' · '))
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
const normalizePoiName = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
const resolveHallGuidePoi = async () => {
const hallId = exhibit.value.hallId
if (hallId) {
const hallPoi = await guideUseCase.getPoiById(hallId)
if (hallPoi?.kind === 'hall' || hallPoi?.primaryCategory.id === 'exhibition_hall') {
return hallPoi
}
}
const normalizedTarget = normalizePoiName(exhibit.value.hallName || '')
if (!normalizedTarget) return null
const candidates = await guideUseCase.searchPois(exhibit.value.hallName || exhibit.value.title || '')
return candidates.find((candidate) => {
const candidateName = normalizePoiName(candidate.name)
const candidateHallName = normalizePoiName(candidate.hallName || '')
return candidate.kind === 'hall'
|| candidate.primaryCategory.id === 'exhibition_hall'
|| candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
}) || candidates[0] || null
}
onLoad(async (options: any = {}) => {
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
@@ -153,6 +186,9 @@ onLoad(async (options: any = {}) => {
const exhibitData = await explainUseCase.getExhibitById(exhibitId)
if (exhibitData) {
exhibit.value = toExplainDetailPageViewModel(exhibitData)
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
}
})
@@ -247,25 +283,34 @@ const handleAudioError = (_audio: AudioItem | null, message: string) => {
}
const handleNavigate = async () => {
if (!exhibit.value.location?.poiId) {
const matchedHallPoi = await resolveHallGuidePoi()
if (!matchedHallPoi) {
uni.showToast({
title: '该讲解暂无三维位置数据',
title: '该讲解暂无所属展厅位置数据',
icon: 'none'
})
return
}
const matchedPoi = await guideUseCase.getPoiById(exhibit.value.location.poiId)
if (!matchedPoi) {
uni.showToast({
title: '该讲解位置尚未映射到当前三维导览资源',
icon: 'none'
})
return
resolvedHallId.value = matchedHallPoi.id
exhibit.value = {
...exhibit.value,
hallId: matchedHallPoi.id,
hallName: matchedHallPoi.hallName || matchedHallPoi.name,
location: exhibit.value.location || {
status: 'hallFallback',
poiId: matchedHallPoi.id,
sourcePoiId: matchedHallPoi.id,
actionText: '查看所属展厅',
previewOnly: true,
floorId: matchedHallPoi.floorId,
floorLabel: matchedHallPoi.floorLabel,
note: '已定位到该讲解所属展厅。'
}
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.value.location.poiId)}&target=${encodeURIComponent(exhibit.value.title)}&state=preview`
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedHallPoi.id)}&target=${encodeURIComponent(exhibit.value.hallName || exhibit.value.title)}&state=preview`
})
}

View File

@@ -5,39 +5,41 @@
:show-top-tabs="false"
show-bottom-nav
show-back
:show-cancel="isStartPanelOpen"
@tab-change="handleTopTabChange"
@back="handlePageBack"
@cancel="handleCancelStartSelection"
>
<GuideMapShell
:search-text="facility.name"
search-top="60px"
mode-top="104px"
floor-top="208px"
tools-top="176px"
search-top="20px"
floor-bottom="153px"
floor-max-height="206px"
floor-side="left"
:active-mode="activeMode"
:active-floor="activeFloor"
:map-type="activeMode === '2d' ? 'outdoor' : 'indoor'"
:tools="mapTools"
map-type="indoor"
:tools="[]"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
@search-tap="handleSearchTap"
@mode-change="handleModeChange"
:target-focus-request="targetFocusRequest"
:target-focus-distance-factor="0.86"
:show-search="false"
:show-mode-row="false"
show-floor-header
show-zoom-controls
zoom-controls-top="calc(100vh - 252px)"
@floor-change="handleFloorChange"
@tool-click="handleToolClick"
>
<view class="detail-sheet">
<view v-if="isDetailPanelVisible" 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>
<text v-if="confirmedStart" class="detail-line">预览起点{{ confirmedStart.label }}</text>
<view class="target-dot"></view>
<view class="detail-title-copy">
<text class="detail-title">{{ facility.name }}</text>
<text class="detail-subtitle">{{ facility.location }}</text>
</view>
<view class="detail-close" @tap.stop="handleCloseDetailPanel">
<text class="detail-close-text">×</text>
</view>
</view>
<view class="tag-row">
@@ -51,82 +53,35 @@
</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>
<text class="detail-hint">已在对应楼层模型中标出点位</text>
</view>
<view
v-if="isStartPanelOpen"
class="start-panel-mask"
@tap="handleCancelStartSelection"
></view>
<view
v-if="isStartPanelOpen"
class="start-select-sheet"
@tap.stop=""
v-else
class="detail-restore"
@tap.stop="handleOpenDetailPanel"
>
<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="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 class="target-dot small"></view>
<text class="detail-restore-text">点位</text>
</view>
</GuideMapShell>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
toFacilityDetailViewModel,
toTargetFocusRequestViewModel,
type TargetPoiFocusRequestViewModel,
type FacilityDetailViewModel
} from '@/view-models/guideViewModels'
import {
@@ -134,64 +89,51 @@ import {
type GuideTopTab
} from '@/utils/guideTopTabs'
import type {
MuseumFloor
MuseumFloor,
MuseumPoi
} from '@/domain/museum'
import type {
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import {
StaticGuideModelRepository
} from '@/repositories/GuideModelRepository'
interface ConfirmedStart {
label: string
floor: string
source: 'facility-detail'
}
const indoorModelSource = guideUseCase.getModelSource()
const fallbackIndoorModelSource = new StaticGuideModelRepository()
const indoorModelSource = ref<GuideModelSource>(fallbackIndoorModelSource)
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const normalizeGuideFloorId = (labelOrId: string) =>
guideFloors.value.find((floor) => floor.id === labelOrId || floor.label === labelOrId)?.id
|| guideUseCase.normalizeFloorId(labelOrId)
const activeMode = ref<'2d' | '3d'>('3d')
const activeFloor = ref('1F')
const targetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
const targetFocusRequestId = ref(0)
const isDetailPanelVisible = ref(true)
const facility = ref<FacilityDetailViewModel>({
id: '',
name: '目标位置',
status: '可预览',
location: '中心线路网导览点位',
location: '正在定位三维点位',
traffic: guideUseCase.getRouteReadinessSnapshot().message,
tags: ['三维位置', '中心线路网']
tags: ['三维位置', '位置预览']
})
const isStartPanelOpen = ref(false)
const selectedStartFloor = ref('1F')
const selectedStartArea = ref('服务台附近')
const confirmedStart = ref<ConfirmedStart | null>(null)
const startFloors = computed(() => guideFloors.value.map((floor) => floor.label))
const mapTools = computed(() => ['重置', activeMode.value === '2d' ? '3D' : '2D'])
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()
}
const loadGuideFloors = async () => {
try {
const floors = await guideUseCase.getFloors()
guideFloors.value = floors
const defaultFloor = floors.find((floor) => floor.label === selectedStartFloor.value)?.label
const modelPackage = await fallbackIndoorModelSource.loadPackage()
guideFloors.value = modelPackage.floors.map((floor) => ({
id: floor.floorId,
label: floor.label,
order: floor.order
}))
const floors = guideFloors.value
const defaultFloor = floors.find((floor) => floor.label === activeFloor.value)?.label
|| floors.find((floor) => floor.id === 'L1')?.label
|| floors[0]?.label
|| selectedStartFloor.value
selectedStartFloor.value = defaultFloor
|| activeFloor.value
activeFloor.value = floors.find((floor) => floor.label === activeFloor.value)?.label
|| defaultFloor
} catch (error) {
@@ -200,8 +142,148 @@ const loadGuideFloors = async () => {
}
}
const focusFacilityPoi = (poi: MuseumPoi) => {
if (!poi.floorId) return
activeMode.value = '3d'
activeFloor.value = poi.floorLabel
targetFocusRequestId.value += 1
targetFocusRequest.value = toTargetFocusRequestViewModel({
poiId: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
primaryCategoryZh: poi.primaryCategory.label,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName
}, targetFocusRequestId.value)
}
const focusRenderPoi = (poi: GuideRenderPoi) => {
if (!poi.floorId) return
const floorLabel = guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
activeMode.value = '3d'
activeFloor.value = floorLabel
targetFocusRequestId.value += 1
targetFocusRequest.value = toTargetFocusRequestViewModel({
poiId: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel,
primaryCategoryZh: poi.primaryCategoryZh,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
kind: poi.kind,
hallId: poi.hallId,
hallName: poi.hallName,
entrances: poi.entrances
}, targetFocusRequestId.value)
}
const getRenderPoiFloorLabel = (poi: GuideRenderPoi) =>
guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
const applyRenderPoiToFacility = (poi: GuideRenderPoi, routeMessage: string) => {
const floorLabel = getRenderPoiFloorLabel(poi)
const categoryLabel = poi.primaryCategoryZh || '三维位置'
facility.value = {
id: poi.id,
name: poi.name,
status: '可预览',
location: `${floorLabel} · ${categoryLabel}`,
traffic: routeMessage,
tags: [
floorLabel,
categoryLabel,
'展示点位'
]
}
focusRenderPoi(poi)
}
const normalizePoiName = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
const resolvePoiFromHall = async () => {
if (!facility.value.id) return null
const hall = await explainUseCase.getHallById(facility.value.id).catch(() => null)
const poiId = hall?.location?.poiId || hall?.poiId
return poiId ? guideUseCase.getPoiById(poiId) : null
}
const resolveFacilityPoi = async () => {
const poiById = facility.value.id
? await guideUseCase.getPoiById(facility.value.id)
: null
if (poiById) return poiById
const poiByHall = await resolvePoiFromHall()
if (poiByHall) return poiByHall
const normalizedTarget = normalizePoiName(facility.value.name)
if (!normalizedTarget) return null
const candidates = await guideUseCase.searchPois(facility.value.name)
return candidates.find((candidate) => {
const candidateName = normalizePoiName(candidate.name)
const candidateHallName = normalizePoiName(candidate.hallName || '')
return candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
}) || candidates[0] || null
}
const resolveRenderPoiByName = async () => {
const normalizedTarget = normalizePoiName(facility.value.name)
if (!normalizedTarget) return null
const modelPackage = await indoorModelSource.value.loadPackage()
for (const floor of modelPackage.floors) {
const floorId = floor.floorId
const pois = await indoorModelSource.value.loadFloorPois(floorId).catch(() => [])
const matchedPoi = pois.find((poi) => {
const poiName = normalizePoiName(poi.name)
const hallName = normalizePoiName(poi.hallName || '')
const sourceObjectName = normalizePoiName(poi.sourceObjectName || '')
return poiName === normalizedTarget
|| hallName === normalizedTarget
|| sourceObjectName === normalizedTarget
|| poiName.includes(normalizedTarget)
|| normalizedTarget.includes(poiName)
|| sourceObjectName.includes(normalizedTarget)
|| normalizedTarget.includes(sourceObjectName)
})
if (matchedPoi) return matchedPoi
}
return null
}
const tryResolveRenderPoiByName = async () => {
try {
return await resolveRenderPoiByName()
} catch (error) {
console.warn('当前模型源点位解析失败,尝试静态资源兜底:', error)
return null
}
}
const tryResolveFacilityPoi = async () => {
try {
return await resolveFacilityPoi()
} catch (error) {
console.warn('内容点位解析失败,继续使用模型点位预览:', error)
return null
}
}
onLoad(async (options: any) => {
void loadGuideFloors()
await loadGuideFloors()
if (options.id) {
facility.value.id = options.id
@@ -211,108 +293,41 @@ onLoad(async (options: any) => {
facility.value.name = decodeURIComponent(options.target)
}
if (!facility.value.id) return
if (!facility.value.id && !normalizePoiName(facility.value.name)) return
try {
const routeReadiness = await guideUseCase.getRouteReadiness()
const poi = await guideUseCase.getPoiById(facility.value.id)
if (!poi) return
const routeReadinessMessage = guideUseCase.getRouteReadinessSnapshot().message
facility.value = toFacilityDetailViewModel(
poi,
routeReadiness.message
)
// 详情页以位置预览为主,优先使用模型点位,避免旧内容兜底覆盖真实三维点位。
const renderPoi = await tryResolveRenderPoiByName()
if (renderPoi) {
applyRenderPoiToFacility(renderPoi, routeReadinessMessage)
return
}
const poi = await tryResolveFacilityPoi()
if (poi) {
facility.value = toFacilityDetailViewModel(
poi,
routeReadinessMessage
)
focusFacilityPoi(poi)
}
} catch (error) {
console.error('加载中心线路网设施详情失败:', error)
console.error('加载设施位置详情失败:', error)
}
})
const handleSearchTap = () => {
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(facility.value.name)}`
})
}
const handleChooseStart = () => {
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?${query}`
})
}
const handleModeChange = (mode: '2d' | '3d') => {
activeMode.value = mode
uni.showToast({
title: mode === '2d' ? '已切换馆外入口参考' : '已切换馆内三维位置',
icon: 'none'
})
}
const handleFloorChange = (floor: string) => {
activeFloor.value = floor
}
const handleToolClick = (tool: string) => {
if (tool === '2D') {
handleModeChange('2d')
return
}
const handleCloseDetailPanel = () => {
isDetailPanelVisible.value = false
}
if (tool === '3D') {
handleModeChange('3d')
return
}
if (tool === '重置') {
activeMode.value = '3d'
}
uni.showToast({
title: tool === '重置' ? '已回到三维位置预览' : '该工具暂未开放',
icon: 'none'
})
const handleOpenDetailPanel = () => {
isDetailPanelVisible.value = true
}
const handleTopTabChange = (tab: GuideTopTab) => {
@@ -335,86 +350,106 @@ const handlePageBack = () => {
<style scoped lang="scss">
.detail-sheet {
position: absolute;
left: 0;
right: 0;
left: 84px;
right: 16px;
bottom: calc(env(safe-area-inset-bottom) + 116px);
height: 252px;
padding: 22px 20px 20px;
min-height: 110px;
padding: 14px 14px 12px;
box-sizing: border-box;
background: #ffffff;
border: 0;
border-radius: 20px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12);
z-index: 45;
}
.detail-title-row {
display: flex;
align-items: flex-start;
gap: 10px;
}
.target-dot {
width: 14px;
height: 14px;
margin-top: 5px;
flex-shrink: 0;
box-sizing: border-box;
background: var(--museum-accent);
border: 3px solid #151713;
border-radius: 999px;
box-shadow: 0 0 0 4px rgba(224, 225, 0, 0.18);
}
.detail-title-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-close {
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
box-sizing: border-box;
background: #f4f5ef;
border: 1px solid #dde5df;
border-radius: 8px;
}
.detail-close-text {
font-size: 18px;
line-height: 20px;
font-weight: 500;
color: #30352f;
}
.detail-title {
flex: 1;
min-width: 0;
font-size: 18px;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
color: #151713;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.open-status {
width: 60px;
flex-shrink: 0;
text-align: right;
font-size: 13px;
line-height: 18px;
font-weight: 500;
color: #1a7f37;
}
.detail-lines {
margin-top: 17px;
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-line {
font-size: 13px;
line-height: 18px;
color: #6b7178;
.detail-subtitle {
font-size: 12px;
line-height: 17px;
color: #696962;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-row {
margin-top: 21px;
margin-top: 12px;
display: flex;
gap: 8px;
}
.detail-tag {
height: 28px;
min-width: 88px;
padding: 0 15px;
height: 26px;
min-width: 68px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #d9d9d9;
border-radius: 14px;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.detail-tag.active {
background: #000000;
border-color: #000000;
background: #151713;
border-color: #151713;
}
.detail-tag-text {
@@ -428,191 +463,58 @@ const handlePageBack = () => {
color: var(--museum-accent);
}
.action-row {
position: absolute;
left: 20px;
right: 20px;
bottom: 20px;
display: flex;
gap: 16px;
}
.action-btn {
height: 44px;
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);
}
.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) + 116px);
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 {
.detail-hint {
display: block;
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.start-select-desc {
display: block;
margin-top: 6px;
margin-top: 10px;
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;
color: #1a7f37;
}
.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 {
.detail-restore {
position: absolute;
left: 20px;
right: 20px;
bottom: 16px;
display: flex;
gap: 16px;
}
.start-panel-btn {
height: 42px;
right: 18px;
bottom: calc(env(safe-area-inset-bottom) + 266px);
height: 40px;
min-width: 74px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dde5df;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 45;
}
.start-panel-btn.secondary {
width: 151px;
background: #ffffff;
border: 1px solid #000000;
.target-dot.small {
width: 10px;
height: 10px;
margin-top: 0;
border-width: 2px;
box-shadow: none;
}
.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);
.detail-restore-text {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #151713;
}
@media (max-width: 360px) {
.start-panel-actions {
gap: 10px;
.detail-sheet {
left: 78px;
right: 12px;
}
.start-panel-btn.secondary,
.start-panel-btn.primary {
width: auto;
flex: 1;
.detail-tag {
min-width: 0;
padding: 0 9px;
}
}
</style>

View File

@@ -14,7 +14,7 @@
<text class="hall-meta">{{ hall.floorLabel }} · {{ explainCountText }}</text>
<view
class="location-action"
:class="{ disabled: !hall.poiId }"
:class="{ disabled: !hallLocationPoiId }"
@tap="handleNavigate"
>
<svg class="location-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
@@ -99,6 +99,7 @@ import {
const activeTopTab = ref<GuideTopTab>('explain')
const loading = ref(false)
const error = ref('')
const resolvedHallPoiId = ref('')
const hall = ref<HallDetailViewModel>({
id: '',
@@ -116,6 +117,37 @@ const explainCountText = computed(() => (
exhibits.value.length > 0 ? `${exhibits.value.length} 条讲解` : '暂无讲解'
))
const hallLocationPoiId = computed(() => hall.value.poiId || resolvedHallPoiId.value)
const normalizePoiName = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
// 展厅数据里可能没有直接写入 poiId优先走内容层映射再按展厅名称兜底匹配导览点位。
const resolveHallGuidePoi = async () => {
const directPoiId = hall.value.location?.poiId || hall.value.poiId
if (directPoiId) {
const directPoi = await guideUseCase.getPoiById(directPoiId)
if (directPoi) return directPoi
}
const normalizedTarget = normalizePoiName(hall.value.name)
if (!normalizedTarget) return null
const candidates = await guideUseCase.searchPois(hall.value.name)
return candidates.find((candidate) => {
const candidateName = normalizePoiName(candidate.name)
const candidateHallName = normalizePoiName(candidate.hallName || '')
return candidate.kind === 'hall'
|| candidate.primaryCategory.id === 'exhibition_hall'
|| candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
}) || candidates[0] || null
}
onLoad(async (options: any = {}) => {
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
if (isGuideTopTab(tab)) {
@@ -141,6 +173,25 @@ onLoad(async (options: any = {}) => {
hall.value = toHallDetailViewModel(hallData)
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
const matchedPoi = await resolveHallGuidePoi()
if (matchedPoi) {
resolvedHallPoiId.value = matchedPoi.id
hall.value = {
...hall.value,
poiId: matchedPoi.id,
location: hall.value.location || {
status: 'exact',
poiId: matchedPoi.id,
sourcePoiId: matchedPoi.id,
actionText: '查看三维位置',
previewOnly: true,
floorId: matchedPoi.floorId,
floorLabel: matchedPoi.floorLabel,
note: '已定位到该展厅的三维点位。'
}
}
}
} catch (err) {
console.error('加载展厅讲解失败:', err)
error.value = '展厅讲解加载失败,请稍后重试'
@@ -163,7 +214,8 @@ const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
}
const handleNavigate = async () => {
if (!hall.value.poiId) {
const matchedPoi = await resolveHallGuidePoi()
if (!matchedPoi) {
uni.showToast({
title: '该展厅暂无三维位置数据',
icon: 'none'
@@ -171,17 +223,24 @@ const handleNavigate = async () => {
return
}
const matchedPoi = await guideUseCase.getPoiById(hall.value.poiId)
if (!matchedPoi) {
uni.showToast({
title: '该展厅位置尚未映射到当前三维导览资源',
icon: 'none'
})
return
resolvedHallPoiId.value = matchedPoi.id
hall.value = {
...hall.value,
poiId: matchedPoi.id,
location: hall.value.location || {
status: 'exact',
poiId: matchedPoi.id,
sourcePoiId: matchedPoi.id,
actionText: '查看三维位置',
previewOnly: true,
floorId: matchedPoi.floorId,
floorLabel: matchedPoi.floorLabel,
note: '已定位到该展厅的三维点位。'
}
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(hall.value.poiId)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedPoi.id)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
})
}

View File

@@ -29,14 +29,14 @@
:layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor"
layer-mode-toggle-top="154px"
floor-bottom="185px"
floor-bottom="153px"
floor-max-height="206px"
floor-side="left"
show-floor-header
:show-layer-mode-toggle="false"
:show-floor="is3DMode"
show-zoom-controls
zoom-controls-top="calc(100vh - 284px)"
zoom-controls-top="calc(100vh - 252px)"
:route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)"
:disable-auto-exit="disableIndoorAutoExit"
@@ -71,7 +71,7 @@
<view v-if="isPoiCardCollapsed" class="poi-card-collapsed" @tap="expandPoiCard">
<view class="poi-card-copy">
<text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
<text class="poi-card-subtitle">位置预览 · 点按展开操作</text>
<text class="poi-card-subtitle">{{ selectedGuidePoiCollapsedSubtitle }}</text>
</view>
<view class="poi-card-mini-action">
<text class="poi-card-mini-text">展开</text>
@@ -92,15 +92,12 @@
</view>
</view>
</view>
<view class="poi-card-actions">
<view class="poi-action primary" @tap="handlePreviewSelectedPoi">
<text class="poi-action-text">查看位置</text>
</view>
<view class="poi-action" @tap="handleSetSelectedPoiAsStart">
<text class="poi-action-text">从这里出发</text>
</view>
<view class="poi-action" @tap="handleSetSelectedPoiAsEnd">
<text class="poi-action-text">去这里</text>
<view
v-if="selectedGuidePoiIsHall"
class="poi-card-actions"
>
<view class="poi-action primary" @tap="handleViewSelectedHall">
<text class="poi-action-text">查看展厅</text>
</view>
<view class="poi-action" @tap="handleSelectedPoiRelated">
<text class="poi-action-text">相关讲解</text>
@@ -248,7 +245,8 @@ import type {
GuideRouteResult,
GuideRouteTarget,
MuseumExhibit,
MuseumFloor
MuseumFloor,
MuseumHall
} from '@/domain/museum'
import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum'
import {
@@ -400,13 +398,27 @@ const selectedGuidePoiSubtitle = computed(() => {
return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}`
})
const selectedGuidePoiIsHall = computed(() => {
const poi = selectedGuidePoi.value
return Boolean(
poi
&& (
poi.kind === 'hall'
|| poi.primaryCategory === 'touring_poi'
|| poi.primaryCategory === 'exhibition_hall'
|| poi.primaryCategory === 'exhibition_hall_entrance'
)
)
})
const selectedGuidePoiCollapsedSubtitle = computed(() => (
selectedGuidePoiIsHall.value ? '已定位展厅 · 点按展开操作' : '已定位点位'
))
const routeSimulationStepText = computed(() => {
if (!activeRoutePreview.value) return routeReady.value ? '馆内导览 · 查看路线' : '馆内导览 · 查看位置'
if (!activeRoutePreview.value) return '位置预览 · 查看位置关系'
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
if (routeReady.value) {
return hasConnector ? '馆内导览 · 跨楼层路线' : '馆内导览 · 同层路线'
}
return hasConnector ? '馆内导览 · 跨楼层位置关系' : '馆内导览 · 同层位置关系'
return hasConnector ? '位置预览 · 跨楼层位置关系' : '位置预览 · 同层位置关系'
})
const explainHallItems = ref<ExplainHallSelectItem[]>([])
@@ -667,12 +679,12 @@ const handleIndoorToolClick = (tool: string) => {
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
selectedGuidePoi.value = poi
isPoiCardCollapsed.value = false
isPoiCardCollapsed.value = !selectedGuidePoiIsHall.value
indoorView.value = 'floor'
isSimulatingRoute.value = false
const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId)
activeGuideFloor.value = matchedFloor?.label || poi.floorId
showIndoorHint('已选中标记,可查看位置预览', 3200)
showIndoorHint('已在当前楼层标出点位', 3000)
}
const handleGuideSelectionClear = () => {
@@ -692,55 +704,84 @@ const togglePoiCardCollapsed = () => {
isPoiCardCollapsed.value = !isPoiCardCollapsed.value
}
const findRouteOptionForPoi = async (poi: GuideRenderPoi) => {
if (!routePointOptions.value.length) {
await loadRouteTargets()
const normalizeHallMatchText = (value?: string) => (value || '')
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
const resolveSelectedPoiHall = async (): Promise<MuseumHall | null> => {
const poi = selectedGuidePoi.value
if (!poi) return null
const halls = await explainUseCase.listHalls()
const hallId = poi.hallId
if (hallId) {
const matchedById = halls.find((hall) => hall.id === hallId || hall.poiId === hallId)
if (matchedById) return matchedById
}
return routePointOptions.value.find((option) => option.poiId === poi.id) || null
const matchedByPoiId = halls.find((hall) => (
hall.poiId === poi.id
|| hall.location?.poiId === poi.id
|| (poi.entrances || []).some((entrance) => hall.poiId === entrance.sourcePlaceId)
))
if (matchedByPoiId) return matchedByPoiId
const poiHallName = normalizeHallMatchText(poi.hallName || poi.name)
return halls.find((hall) => {
const hallName = normalizeHallMatchText(hall.name)
return hallName && poiHallName && (hallName.includes(poiHallName) || poiHallName.includes(hallName))
}) || null
}
const setSelectedPoiAsRoutePoint = async (mode: 'start' | 'end') => {
if (!selectedGuidePoi.value) return
const navigateToHallDetail = (hallId: string) => {
uni.navigateTo({
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
})
}
const option = await findRouteOptionForPoi(selectedGuidePoi.value)
if (!option) {
const handleViewSelectedHall = async () => {
const hall = await resolveSelectedPoiHall()
if (!hall) {
uni.showToast({
title: '该点位暂无线路数据,可先查看三维位置',
title: '该展厅详情暂未接入',
icon: 'none'
})
return
}
if (mode === 'start') {
routeStartPoint.value = option
} else {
routeEndPoint.value = option
navigateToHallDetail(hall.id)
}
const handleSelectedPoiRelated = async () => {
if (!selectedGuidePoi.value) return
const hall = await resolveSelectedPoiHall()
if (hall) {
navigateToHallDetail(hall.id)
return
}
clearRoutePreviewState()
openRoutePlanner()
}
const keyword = selectedGuidePoi.value.hallName || selectedGuidePoi.value.name
const results = await explainUseCase.searchExplain(keyword)
const firstHall = results.find((item) => item.type === 'hall' && item.hallId)
const firstExhibit = results.find((item) => item.type === 'exhibit' && item.exhibitId)
const handlePreviewSelectedPoi = () => {
if (!selectedGuidePoi.value) return
indoorView.value = 'floor'
showRoutePlanner.value = false
isSimulatingRoute.value = false
}
if (firstHall?.hallId) {
navigateToHallDetail(firstHall.hallId)
return
}
const handleSetSelectedPoiAsStart = () => {
void setSelectedPoiAsRoutePoint('start')
}
if (firstExhibit?.exhibitId) {
uni.navigateTo({
url: `/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
})
return
}
const handleSetSelectedPoiAsEnd = () => {
void setSelectedPoiAsRoutePoint('end')
}
const handleSelectedPoiRelated = () => {
if (!selectedGuidePoi.value) return
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(selectedGuidePoi.value.name)}`
uni.showToast({
title: '该展厅暂无对应讲解',
icon: 'none'
})
}
@@ -769,15 +810,16 @@ const refreshRouteReadinessState = async () => {
routeReady.value = readiness.ready
routePreviewUnavailableMessage.value = readiness.ready
? ''
: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意'
: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
} catch (error) {
console.warn('读取路线能力状态失败:', error)
routeReady.value = false
routePreviewUnavailableMessage.value = '当前暂不支持正式导航,可查看位置预览和路线示意'
routePreviewUnavailableMessage.value = '当前可查看位置预览和位置关系,暂不提供正式室内导航'
}
}
const openRoutePlanner = () => {
closeOutdoorNavPanel()
showRoutePlanner.value = true
is3DMode.value = true
indoorView.value = indoorView.value === 'overview' ? 'floor' : indoorView.value
@@ -898,6 +940,7 @@ const handleRouteSimulate = () => {
// 进入 3D 馆内模式
const handleEnter3DMode = () => {
console.log('进入 3D 馆内模式')
closeOutdoorNavPanel()
indoorView.value = 'overview'
is3DMode.value = true
guideOutdoorState.value = 'home'
@@ -919,6 +962,7 @@ const handleModeChange = (mode: '2d' | '3d') => {
isSimulatingRoute.value = false
clearRoutePreviewState()
} else {
closeOutdoorNavPanel()
indoorView.value = 'overview'
showIndoorHint()
if (!routePointOptions.value.length) {
@@ -970,18 +1014,21 @@ const clearOutdoorNavState = () => {
outdoorNavManualMode.value = false
}
const closeOutdoorNavPanel = () => {
showOutdoorNavPanel.value = false
clearOutdoorNavState()
}
const handleMoreOutdoorNav = () => {
openOutdoorNavPanel()
}
const handleOutdoorNavClose = () => {
showOutdoorNavPanel.value = false
clearOutdoorNavState()
closeOutdoorNavPanel()
}
const handleOutdoorNavBack = () => {
showOutdoorNavPanel.value = false
clearOutdoorNavState()
closeOutdoorNavPanel()
}
const handleOutdoorNavGpsStart = () => {
@@ -1291,7 +1338,7 @@ const handleExplainBack = () => {
.poi-card-actions {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}

View File

@@ -135,7 +135,7 @@
@tap="handleStartIndoorGuide"
>
<text class="indoor-guide-btn-text">
{{ routePlanning ? '规划中' : routeReady ? '开始导览' : '导航不可用' }}
{{ routePlanning ? '生成中' : routeReady ? '查看位置关系' : '位置关系不可用' }}
</text>
</view>
</view>
@@ -304,7 +304,7 @@ const shellConfig = computed(() => {
}
return {
searchText: routeReady.value ? `馆内导览:${route.value.target}` : `位置预览:${route.value.target}`,
searchText: `位置预览:${route.value.target}`,
searchTop: '60px',
activeMode: '3d' as GuideMode,
activeFloor: activeFloor.value,
@@ -323,17 +323,17 @@ const shellConfig = computed(() => {
})
const indoorGuideTitle = computed(() => (
routeReady.value ? `馆内导览:${route.value.target}` : `位置预览:${route.value.target}`
`位置预览:${route.value.target}`
))
const indoorGuideDesc = computed(() => {
if (activeRoutePreview.value) {
return `规划 ${activeRoutePreview.value.start.name}${activeRoutePreview.value.end.name},约 ${Math.round(activeRoutePreview.value.distanceMeters)} 米。`
return `生成 ${activeRoutePreview.value.start.name}${activeRoutePreview.value.end.name} 的位置关系,约 ${Math.round(activeRoutePreview.value.distanceMeters)} 米。`
}
return routeReady.value
? 'SGS 路线数据已就绪,可从已选起点开始规划馆内路线。'
: routeReadinessMessage.value || '当前暂不支持正式导航,可查看位置预览。'
? '可选择起点查看馆内位置关系,当前不作为正式室内导航。'
: routeReadinessMessage.value || '当前可查看位置预览,暂不提供正式室内导航。'
})
const safeDecode = (value: string) => {
@@ -635,7 +635,7 @@ const handleStartIndoorGuide = async () => {
: null)
if (!fallbackStart || !endTarget) {
routePlanError.value = '未找到可用于路线规划的起点或终点'
routePlanError.value = '未找到可用于位置关系预览的起点或终点'
return
}
@@ -645,7 +645,7 @@ const handleStartIndoorGuide = async () => {
})
if (!result.route) {
routePlanError.value = result.error || '馆内导览路线规划失败'
routePlanError.value = result.error || '馆内位置关系生成失败'
activeRoutePreview.value = null
return
}
@@ -653,7 +653,7 @@ const handleStartIndoorGuide = async () => {
activeRoutePreview.value = result.route
activeFloor.value = result.route.start.floorLabel
} catch (error) {
routePlanError.value = error instanceof Error ? error.message : '馆内导览路线规划失败'
routePlanError.value = error instanceof Error ? error.message : '馆内位置关系生成失败'
activeRoutePreview.value = null
} finally {
routePlanning.value = false

View File

@@ -36,12 +36,15 @@ import {
toGuideMapDiagnostics,
toLocationPreviewFromPoi,
toMuseumFloorFromSgs,
toMuseumHallPoisFromSgs,
toMuseumPoiFromSgs,
toRouteReadinessFromSgsDiagnostics
} from '@/data/adapters/sgsSdkGuideAdapter'
const searchableCategories = new Set([
'touring_poi',
'exhibition_hall',
'exhibition_hall_entrance',
'basic_service_facility',
'transport_circulation',
'accessibility_special_service',
@@ -50,6 +53,7 @@ const searchableCategories = new Set([
const toSearchText = (poi: MuseumPoi) => [
poi.name,
poi.hallName,
poi.floorId,
poi.floorLabel,
poi.primaryCategory.label,
@@ -71,7 +75,11 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
floorLabel: poi.floorLabel,
primaryCategoryZh: poi.primaryCategory.label,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName
sourceObjectName: poi.sourceObjectName,
kind: poi.kind,
hallId: poi.hallId,
hallName: poi.hallName,
entrances: poi.entrances
})
const isSearchableIndoorPoi = (poi: MuseumPoi) => (
@@ -79,6 +87,15 @@ const isSearchableIndoorPoi = (poi: MuseumPoi) => (
&& isPoiOnIndoorNavigableFloor(poi)
)
const dedupePoisById = (pois: MuseumPoi[]) => {
const seen = new Set<string>()
return pois.filter((poi) => {
if (!poi.id || seen.has(poi.id)) return false
seen.add(poi.id)
return true
})
}
export interface GuideRepository {
getAssetBaseUrl(): string
getFloors(): Promise<MuseumFloor[]>
@@ -245,16 +262,38 @@ export class SgsSdkGuideRepository implements GuideRepository {
const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor)
const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId)))
this.floorAliases = buildSgsFloorAliases(indoorFloors)
const poiGroups = await Promise.all(
indoorFloors.map((floor) => this.provider.getFloorPois(String(floor.floorId)))
const floorDataGroups = await Promise.all(
indoorFloors.map(async (floor) => {
const floorId = String(floor.floorId)
const [pois, spaces, navigablePlaces] = await Promise.all([
this.provider.getFloorPois(floorId).catch(() => []),
this.provider.getFloorSpaces(floorId).catch(() => []),
this.provider.getNavigablePlaces(floorId).catch(() => [])
])
return {
floorId,
pois,
hallPois: toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId)
}
})
)
this.poiCache = poiGroups
.flat()
const ordinaryPois = floorDataGroups
.flatMap((group) => group.pois)
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter((poi) => poi.id && poi.name)
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
const hallPois = floorDataGroups
.flatMap((group) => group.hallPois)
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
this.poiCache = dedupePoisById([
...hallPois,
...ordinaryPois
])
return this.poiCache
}

View File

@@ -136,6 +136,10 @@ const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
requiredData: ['route_graph', 'nav_data']
})
const logRouteReadinessIssue = (message: string, payload: Record<string, unknown>) => {
console.error(`[RouteReadiness] ${message}`, payload)
}
export class StaticGuideRouteRepository implements GuideRouteRepository {
private datasetCache: NavRouteDataset | null = null
private datasetRequest: Promise<NavRouteDataset> | null = null
@@ -156,11 +160,17 @@ export class StaticGuideRouteRepository implements GuideRouteRepository {
try {
const dataset = await this.loadDataset()
if (!dataset.nodes.size || !dataset.edges.length || !dataset.anchorsByPoiId.size) {
return createUnavailableReadiness('路线示意数据不完整,当前暂不支持正式导航')
logRouteReadinessIssue('路线数据不完整', {
nodes: dataset.nodes.size,
edges: dataset.edges.length,
anchorsByPoiId: dataset.anchorsByPoiId.size
})
return createUnavailableReadiness('无法定位,当前仅支持点位位置预览')
}
return applyRouteReadinessGate(manifestReadiness)
} catch (error) {
console.error('[RouteReadiness] 静态路线数据加载失败', error)
return createUnavailableReadiness(error instanceof Error ? error.message : '路线示意数据加载失败')
}
}

View File

@@ -21,7 +21,6 @@ import {
toGuideRouteResultFromSgsPlan
} from '@/data/adapters/sgsSdkRouteAdapter'
import { stringifyId, formatSgsFloorLabel } from '@/data/adapters/sgsSdkGuideAdapter'
import { toAppFloorId } from '@/services/sgs/SgsMapEventAdapter'
const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
ready: false,
@@ -29,6 +28,10 @@ const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
requiredData: ['route_graph', 'nav_data']
})
const logSdkRouteReadinessIssue = (message: string, payload: Record<string, unknown>) => {
console.error(`[SDKRouteReadiness] ${message}`, payload)
}
const toNumber = (value: unknown) => (
Number.isFinite(Number(value)) ? Number(value) : undefined
)
@@ -51,11 +54,6 @@ const positionFromPlace = (place: SgsNavigablePlacePayload): [number, number, nu
]
}
const numericId = (value: unknown) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : undefined
}
export class SdkGuideRouteRepository {
private targetsCache: GuideRouteTarget[] | null = null
@@ -84,7 +82,19 @@ export class SdkGuideRouteRepository {
// 没有楼层有路线数据时不可用
if (readyFloors.length === 0) {
return createUnavailableReadiness('SDK 路线规划数据不完整,当前暂不支持正式导航')
logSdkRouteReadinessIssue('SDK 路线数据不完整', {
floorCount: floors.length,
readyFloorCount: readyFloors.length,
floors: floors.map((floor) => ({
floorId: floor.floorId,
floorName: floor.floorName,
floorCode: floor.floorCode,
routePlanningReady: floor.routePlanningReady,
routeNodeCount: floor.routeNodeCount,
routeEdgeCount: floor.routeEdgeCount
}))
})
return createUnavailableReadiness('无法定位,当前仅支持点位位置预览')
}
// 收集未就绪的楼层
@@ -97,19 +107,20 @@ export class SdkGuideRouteRepository {
.join('、')
return {
ready: true,
message: `部分楼层路线数据未就绪:${floorLabels},已就绪楼层可正常使用导览功能`,
message: `部分楼层位置关系数据未就绪:${floorLabels}可查看已就绪楼层的位置关系`,
requiredData: []
}
}
return {
ready: true,
message: 'SDK 路线数据已就绪',
message: 'SDK 位置关系数据已接入,可进行位置预览',
requiredData: []
}
} catch (error) {
console.error('[SDKRouteReadiness] 读取 SDK 路线状态失败', error)
return createUnavailableReadiness(
error instanceof Error ? error.message : 'SDK 路线数据加载失败'
error instanceof Error ? error.message : 'SDK 位置关系数据加载失败'
)
}
}
@@ -148,8 +159,8 @@ export class SdkGuideRouteRepository {
targets.push({
poiId: placeId,
name: placeName,
// 展示用标准化的 floorId(如 L1
floorId: toAppFloorId(place.floorId ?? rawFloorId),
// 保留后端原始 floorId,确保与 SDK 模型楼层和 ThreeMap 当前楼层一致。
floorId: stringifyId(place.floorId ?? rawFloorId),
floorLabel,
categoryLabel,
positionGltf,
@@ -188,7 +199,7 @@ export class SdkGuideRouteRepository {
const endTarget = targets.find((target) => target.poiId === endPoiId)
if (!startTarget || !endTarget) {
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点或终点不在 SDK 可导航目的地列表中')
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点或终点不在 SDK 可预览目的地列表中')
}
try {
@@ -196,24 +207,15 @@ export class SdkGuideRouteRepository {
throw new GuideRouteError('ROUTE_TARGET_UNANCHORED', '起点或终点缺少可规划坐标')
}
// 获取原始 floorId 用于 API 调用
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
const floorIdMap = new Map<string, string>()
for (const floor of (diagnostics.floors || []).filter(isIndoorNavigableFloor)) {
floorIdMap.set(toAppFloorId(floor.floorId), stringifyId(floor.floorId))
}
const rawStartFloorId = floorIdMap.get(startTarget.floorId) || startTarget.floorId
const rawEndFloorId = floorIdMap.get(endTarget.floorId) || endTarget.floorId
const route = await this.sdkApiProvider.planRoute({
startFloorId: rawStartFloorId,
startFloorId: startTarget.floorId,
startX: startTarget.positionGltf[0],
startY: startTarget.positionGltf[2],
startNodeId: numericId(startTarget.routeNodeId) || null,
endFloorId: rawEndFloorId,
startNodeId: startTarget.routeNodeId || null,
endFloorId: endTarget.floorId,
endX: endTarget.positionGltf[0],
endY: endTarget.positionGltf[2],
endNodeId: numericId(endTarget.routeNodeId) || null,
endNodeId: endTarget.routeNodeId || null,
wheelchair: 0
})
@@ -222,7 +224,7 @@ export class SdkGuideRouteRepository {
if (error instanceof GuideRouteError) throw error
throw new GuideRouteError(
'ROUTE_NOT_FOUND',
error instanceof Error ? error.message : 'SGS 路线接口规划失败'
error instanceof Error ? error.message : 'SGS 位置关系接口生成失败'
)
}
}

View File

@@ -1,7 +1,7 @@
/* CSS 变量定义 */
:root {
/* 字体 */
--museum-font-family: 'HarmonyOS Sans SC', 'HarmonyOS Sans', '鸿蒙黑体', 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', Arial, sans-serif;
--museum-font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', Arial, sans-serif;
/* 主色调 */
--museum-accent: #E0DF00;

View File

@@ -73,7 +73,7 @@ export class GuideRouteUseCase {
if (!readiness.ready) {
return {
route: null,
error: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意'
error: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
}
}
@@ -95,7 +95,7 @@ export class GuideRouteUseCase {
return error.message
}
return error instanceof Error ? error.message : '室内导览位置关系生成失败'
return error instanceof Error ? error.message : '馆内位置关系生成失败'
}
}

View File

@@ -2,7 +2,7 @@ export type GuideTopTab = 'guide' | 'explain'
export const GUIDE_LOCATION_PREVIEW_STORAGE_KEY = 'museum-guide:last-location-preview-url'
export const GUIDE_TOP_TABS: Array<{ id: GuideTopTab; label: string }> = [
{ id: 'guide', label: '导览' },
{ id: 'guide', label: '馆内' },
{ id: 'explain', label: '讲解' }
]

View File

@@ -37,6 +37,7 @@ export interface ExplainDetailPageViewModel {
subtitle?: string
contentType: ExplainContentType
coverImages: string[]
hallId?: string
hallName?: string
floorLabel?: string
summary: string
@@ -144,6 +145,7 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
subtitle: buildSubtitle(exhibit),
contentType: contentTypeFor(exhibit),
coverImages: [exhibit.image || '/static/exhibit-placeholder.jpg'].filter(Boolean),
hallId: exhibit.hallId,
hallName: exhibit.hallName,
floorLabel: exhibit.floorLabel,
summary: exhibit.guideText || exhibit.description || '该展项暂无讲解文稿。',