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

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": { "dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5010120260519001", "@dcloudio/uni-app": "3.0.0-alpha-5010120260519001",
"@dcloudio/uni-app-plus": "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-h5": "3.0.0-alpha-5010120260519001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-5010120260519001", "@dcloudio/uni-mp-weixin": "3.0.0-alpha-5010120260519001",
"three": "0.163.0", "three": "0.163.0",
"vue": "3.4.21" "vue": "3.4.21"
}, },
"devDependencies": { "devDependencies": {
"@types/three": "0.163.0",
"@dcloudio/types": "3.4.31", "@dcloudio/types": "3.4.31",
"@dcloudio/uni-automator": "3.0.0-alpha-5010120260519001", "@dcloudio/uni-automator": "3.0.0-alpha-5010120260519001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260519001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260519001",
"@dcloudio/vite-plugin-uni": "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/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0", "@typescript-eslint/parser": "7.18.0",
"@vue/runtime-core": "3.4.21", "@vue/runtime-core": "3.4.21",

109
pnpm-lock.yaml generated
View File

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

View File

@@ -2,7 +2,7 @@
这些 JSON 是早期演示数据,当前导览模块不再以这里作为数据源。 这些 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` 当前展厅、单元、讲解点、展品、讲解词和 POI 业务内容统一通过 `static/guide-data` 静态数据包读取,默认数据源模式为 `VITE_GUIDE_CONTENT_SOURCE_MODE=static`

View File

@@ -24,11 +24,6 @@
</view> </view>
<template v-else> <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-if="filteredHalls.length" class="hall-list">
<view <view
v-for="hall in filteredHalls" v-for="hall in filteredHalls"
@@ -37,10 +32,10 @@
@tap="handleHallClick(hall.id)" @tap="handleHallClick(hall.id)"
> >
<image <image
v-if="hall.image" v-if="hallIconUrl(hall)"
class="hall-thumb" class="hall-thumb"
:src="hall.image" :src="hallIconUrl(hall)"
mode="aspectFill" mode="aspectFit"
/> />
<view v-else class="hall-thumb placeholder"> <view v-else class="hall-thumb placeholder">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text> <text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
@@ -96,8 +91,26 @@ const emit = defineEmits<{
back: [] 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 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 hallIconText = (name: string) => name.trim().slice(0, 1) || '讲'
const handleHallClick = (hallId: string) => { const handleHallClick = (hallId: string) => {
@@ -124,8 +137,6 @@ const handleBack = () => {
box-sizing: border-box; box-sizing: border-box;
} }
.section-title,
.section-count,
.state-title, .state-title,
.state-desc, .state-desc,
.hall-name, .hall-name,
@@ -180,29 +191,6 @@ const handleBack = () => {
color: #151713; 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 { .hall-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -231,7 +219,10 @@ const handleBack = () => {
width: 68px; width: 68px;
height: 68px; height: 68px;
border-radius: 8px; 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 { .hall-thumb.placeholder {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,5 +67,7 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => ({
sourceObjectName: poi.sourceObjectName, sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence, sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness, 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 GuideRouteTarget
} from '@/domain/museum' } from '@/domain/museum'
import type { import type {
SgsRoutePathPointPayload,
SgsRoutePlanResponsePayload SgsRoutePlanResponsePayload
} from '@/data/providers/sgsSdkApiProvider' } from '@/data/providers/sgsSdkApiProvider'
import type { import type {
@@ -31,10 +32,25 @@ type GeoJsonLineString = {
coordinates?: Array<[number, number] | [number, number, number]> 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] => [ const pointPosition = (point: SgsRoutePathNode): [number, number, number] => [
Number(point.x || 0), finiteNumber(point.x) ?? 0,
Number(point.y || 0), finiteNumber(point.y) ?? 0,
Number(point.z || 0) finiteNumber(point.z) ?? 0
] ]
const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({ const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
@@ -165,8 +181,10 @@ export const toGuideRouteResultFromSgs = (
const floorLabelFor = ( const floorLabelFor = (
floorId: string, floorId: string,
startTarget: GuideRouteTarget, startTarget: GuideRouteTarget,
endTarget: GuideRouteTarget endTarget: GuideRouteTarget,
fallbackLabel?: string | null
) => { ) => {
if (fallbackLabel) return fallbackLabel
if (floorId === startTarget.floorId) return startTarget.floorLabel if (floorId === startTarget.floorId) return startTarget.floorLabel
if (floorId === endTarget.floorId) return endTarget.floorLabel if (floorId === endTarget.floorId) return endTarget.floorLabel
return floorId return floorId
@@ -176,19 +194,54 @@ const pointFromBackendNode = (
node: NonNullable<SgsRoutePlanResponsePayload['nodePaths']>[number], node: NonNullable<SgsRoutePlanResponsePayload['nodePaths']>[number],
index: number, index: number,
fallbackFloorId: string fallbackFloorId: string
): GuideRoutePoint => ({ ): GuideRoutePoint | null => {
nodeId: String(node.id || `sgs-route-node-${index}`), const x = finiteNumber(node.x)
floorId: toAppFloorId(node.floorId ?? fallbackFloorId), const z = finiteNumber(node.y)
position: [ if (typeof x !== 'number' || typeof z !== 'number') return null
Number(node.x || 0),
0, return {
Number(node.y || 0) 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 = ( const pointsFromGeoJson = (
geoJson: string | null | undefined, geoJson: string | null | undefined,
floorId: string floorId: string,
nodeIdPrefix = floorId
): GuideRoutePoint[] => { ): GuideRoutePoint[] => {
if (!geoJson) return [] if (!geoJson) return []
@@ -198,20 +251,147 @@ const pointsFromGeoJson = (
? parsed.coordinates ? parsed.coordinates
: [] : []
return coordinates.map((coordinate, index) => ({ return coordinates
nodeId: `${floorId}-geo-${index}`, .map((coordinate, index) => {
floorId, const x = finiteNumber(coordinate[0])
position: [ const z = finiteNumber(coordinate[1])
Number(coordinate[0] || 0), if (typeof x !== 'number' || typeof z !== 'number') return null
0,
Number(coordinate[1] || 0) return {
] nodeId: `${nodeIdPrefix}-geo-${index}`,
})) floorId,
position: [
x,
finiteNumber(coordinate[2]) ?? 0,
z
]
}
})
.filter((point): point is GuideRoutePoint => Boolean(point))
} catch { } catch {
return [] 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 = ( export const toGuideRouteResultFromSgsPlan = (
route: SgsRoutePlanResponsePayload, route: SgsRoutePlanResponsePayload,
startTarget: GuideRouteTarget, startTarget: GuideRouteTarget,
@@ -219,67 +399,39 @@ export const toGuideRouteResultFromSgsPlan = (
): GuideRouteResult => { ): GuideRouteResult => {
const start = targetEndpoint(startTarget) const start = targetEndpoint(startTarget)
const end = targetEndpoint(endTarget) const end = targetEndpoint(endTarget)
const nodePoints = (route.nodePaths || []).map((node, index) => ( const startPoint = routePointFromEndpoint(start, 'start')
pointFromBackendNode(node, index, start.floorId) const endPoint = routePointFromEndpoint(end, 'end')
)) const nodePoints = (route.nodePaths || [])
const routePoints = nodePoints.length .map((node, index) => pointFromBackendNode(node, index, start.floorId))
? [ .filter((point): point is GuideRoutePoint => Boolean(point))
{ const segmentFloorSegments = createSegmentFloorSegments(
nodeId: start.routeNodeId, route,
floorId: start.floorId, start,
position: start.position end,
}, startTarget,
...nodePoints, endTarget,
{ nodePoints
nodeId: end.routeNodeId, )
floorId: end.floorId, const routeGeoPoints = pointsFromGeoJson(route.pathGeoJson, start.floorId, 'sgs-route')
position: end.position const fallbackPoints = nodePoints.length
} ? [startPoint, ...nodePoints, endPoint]
] : routeGeoPoints.length
: pointsFromGeoJson(route.pathGeoJson, start.floorId) ? [startPoint, ...routeGeoPoints, endPoint]
: [startPoint, endPoint]
const fallbackPoints = routePoints.length const floorSegments = segmentFloorSegments.length
? routePoints ? segmentFloorSegments
: [ : createPlanFloorSegments(fallbackPoints, startTarget, endTarget)
{ const routePoints = floorSegments.flatMap((segment) => segment.points)
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)
return { return {
id: `sgs-api-route-${start.poiId}-${end.poiId}`, id: `sgs-api-route-${start.poiId}-${end.poiId}`,
start, start,
end, end,
distanceMeters: Number(route.distance || 0), distanceMeters: Number(route.distance || 0),
nodeIds: fallbackPoints.map((point) => point.nodeId), nodeIds: routePoints.map((point) => point.nodeId),
points: fallbackPoints, points: routePoints,
floorSegments, 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++) { for (let i = 0; i < points.length - 1; i++) {
const currentFloorId = toAppFloorId(points[i].floorId) const currentFloorId = points[i].floorId
const nextFloorId = toAppFloorId(points[i + 1].floorId) const nextFloorId = points[i + 1].floorId
if (currentFloorId !== nextFloorId) { if (currentFloorId !== nextFloorId) {
const fromLabel = labels.get(currentFloorId) || currentFloorId const fromLabel = labels.get(currentFloorId) || currentFloorId

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import type {
export const NAV_ROUTE_GRAPH_READY = false 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 = { export const NAV_ROUTE_READINESS: GuideRouteReadiness = {
ready: NAV_ROUTE_GRAPH_READY, ready: NAV_ROUTE_GRAPH_READY,

View File

@@ -61,7 +61,7 @@
<view class="action-bar"> <view class="action-bar">
<view <view
class="location-btn" class="location-btn"
:class="{ disabled: !exhibit.location }" :class="{ disabled: !hallLocationId }"
@tap="handleNavigate" @tap="handleNavigate"
> >
<svg class="location-icon" width="19" height="19" viewBox="0 0 24 24" fill="none"> <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 currentAudio = ref<AudioItem | null>(null)
const isPlaying = ref(false) const isPlaying = ref(false)
const activeTopTab = ref<GuideTopTab>('explain') const activeTopTab = ref<GuideTopTab>('explain')
const resolvedHallId = ref('')
const heroImage = computed(() => exhibit.value.coverImages[0]) const heroImage = computed(() => exhibit.value.coverImages[0])
const detailMeta = computed(() => [ const detailMeta = computed(() => [
exhibit.value.hallName, exhibit.value.hallName,
exhibit.value.floorLabel exhibit.value.floorLabel
].filter(Boolean).join(' · ')) ].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 = {}) => { onLoad(async (options: any = {}) => {
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab 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) const exhibitData = await explainUseCase.getExhibitById(exhibitId)
if (exhibitData) { if (exhibitData) {
exhibit.value = toExplainDetailPageViewModel(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 () => { const handleNavigate = async () => {
if (!exhibit.value.location?.poiId) { const matchedHallPoi = await resolveHallGuidePoi()
if (!matchedHallPoi) {
uni.showToast({ uni.showToast({
title: '该讲解暂无三维位置数据', title: '该讲解暂无所属展厅位置数据',
icon: 'none' icon: 'none'
}) })
return return
} }
const matchedPoi = await guideUseCase.getPoiById(exhibit.value.location.poiId) resolvedHallId.value = matchedHallPoi.id
if (!matchedPoi) { exhibit.value = {
uni.showToast({ ...exhibit.value,
title: '该讲解位置尚未映射到当前三维导览资源', hallId: matchedHallPoi.id,
icon: 'none' hallName: matchedHallPoi.hallName || matchedHallPoi.name,
}) location: exhibit.value.location || {
return status: 'hallFallback',
poiId: matchedHallPoi.id,
sourcePoiId: matchedHallPoi.id,
actionText: '查看所属展厅',
previewOnly: true,
floorId: matchedHallPoi.floorId,
floorLabel: matchedHallPoi.floorLabel,
note: '已定位到该讲解所属展厅。'
}
} }
uni.navigateTo({ 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-top-tabs="false"
show-bottom-nav show-bottom-nav
show-back show-back
:show-cancel="isStartPanelOpen"
@tab-change="handleTopTabChange" @tab-change="handleTopTabChange"
@back="handlePageBack" @back="handlePageBack"
@cancel="handleCancelStartSelection"
> >
<GuideMapShell <GuideMapShell
:search-text="facility.name" :search-text="facility.name"
search-top="60px" search-top="20px"
mode-top="104px" floor-bottom="153px"
floor-top="208px" floor-max-height="206px"
tools-top="176px" floor-side="left"
:active-mode="activeMode" :active-mode="activeMode"
:active-floor="activeFloor" :active-floor="activeFloor"
:map-type="activeMode === '2d' ? 'outdoor' : 'indoor'" map-type="indoor"
:tools="mapTools" :tools="[]"
:indoor-model-source="indoorModelSource" :indoor-model-source="indoorModelSource"
:floors="guideFloors" :floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId" :normalize-floor-id="normalizeGuideFloorId"
@search-tap="handleSearchTap" :target-focus-request="targetFocusRequest"
@mode-change="handleModeChange" :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" @floor-change="handleFloorChange"
@tool-click="handleToolClick"
> >
<view class="detail-sheet"> <view v-if="isDetailPanelVisible" class="detail-sheet">
<view class="detail-title-row"> <view class="detail-title-row">
<text class="detail-title">{{ facility.name }}</text> <view class="target-dot"></view>
<text class="open-status">{{ facility.status }}</text> <view class="detail-title-copy">
</view> <text class="detail-title">{{ facility.name }}</text>
<text class="detail-subtitle">{{ facility.location }}</text>
<view class="detail-lines"> </view>
<text class="detail-line">所在区域{{ facility.location }}</text> <view class="detail-close" @tap.stop="handleCloseDetailPanel">
<text class="detail-line">能力说明{{ facility.traffic }}</text> <text class="detail-close-text">×</text>
<text v-if="confirmedStart" class="detail-line">预览起点{{ confirmedStart.label }}</text> </view>
</view> </view>
<view class="tag-row"> <view class="tag-row">
@@ -51,82 +53,35 @@
</view> </view>
</view> </view>
<view class="action-row"> <text class="detail-hint">已在对应楼层模型中标出点位</text>
<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>
<view <view
v-if="isStartPanelOpen" v-else
class="start-panel-mask" class="detail-restore"
@tap="handleCancelStartSelection" @tap.stop="handleOpenDetailPanel"
></view>
<view
v-if="isStartPanelOpen"
class="start-select-sheet"
@tap.stop=""
> >
<text class="start-select-title">选择预览起点</text> <view class="target-dot small"></view>
<text class="start-select-desc">当前支持选择预览起点用于三维位置预览</text> <text class="detail-restore-text">点位</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> </view>
</GuideMapShell> </GuideMapShell>
</GuidePageFrame> </GuidePageFrame>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue' import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue' import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import { import {
guideUseCase guideUseCase
} from '@/usecases/guideUseCase' } from '@/usecases/guideUseCase'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import { import {
toFacilityDetailViewModel, toFacilityDetailViewModel,
toTargetFocusRequestViewModel,
type TargetPoiFocusRequestViewModel,
type FacilityDetailViewModel type FacilityDetailViewModel
} from '@/view-models/guideViewModels' } from '@/view-models/guideViewModels'
import { import {
@@ -134,64 +89,51 @@ import {
type GuideTopTab type GuideTopTab
} from '@/utils/guideTopTabs' } from '@/utils/guideTopTabs'
import type { import type {
MuseumFloor MuseumFloor,
MuseumPoi
} from '@/domain/museum' } from '@/domain/museum'
import type {
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import {
StaticGuideModelRepository
} from '@/repositories/GuideModelRepository'
interface ConfirmedStart { const fallbackIndoorModelSource = new StaticGuideModelRepository()
label: string const indoorModelSource = ref<GuideModelSource>(fallbackIndoorModelSource)
floor: string
source: 'facility-detail'
}
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([]) 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 activeMode = ref<'2d' | '3d'>('3d')
const activeFloor = ref('1F') const activeFloor = ref('1F')
const targetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
const targetFocusRequestId = ref(0)
const isDetailPanelVisible = ref(true)
const facility = ref<FacilityDetailViewModel>({ const facility = ref<FacilityDetailViewModel>({
id: '', id: '',
name: '目标位置', name: '目标位置',
status: '可预览', status: '可预览',
location: '中心线路网导览点位', location: '正在定位三维点位',
traffic: guideUseCase.getRouteReadinessSnapshot().message, 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 () => { const loadGuideFloors = async () => {
try { try {
const floors = await guideUseCase.getFloors() const modelPackage = await fallbackIndoorModelSource.loadPackage()
guideFloors.value = floors guideFloors.value = modelPackage.floors.map((floor) => ({
const defaultFloor = floors.find((floor) => floor.label === selectedStartFloor.value)?.label 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.find((floor) => floor.id === 'L1')?.label
|| floors[0]?.label || floors[0]?.label
|| selectedStartFloor.value || activeFloor.value
selectedStartFloor.value = defaultFloor
activeFloor.value = floors.find((floor) => floor.label === activeFloor.value)?.label activeFloor.value = floors.find((floor) => floor.label === activeFloor.value)?.label
|| defaultFloor || defaultFloor
} catch (error) { } 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) => { onLoad(async (options: any) => {
void loadGuideFloors() await loadGuideFloors()
if (options.id) { if (options.id) {
facility.value.id = options.id facility.value.id = options.id
@@ -211,108 +293,41 @@ onLoad(async (options: any) => {
facility.value.name = decodeURIComponent(options.target) facility.value.name = decodeURIComponent(options.target)
} }
if (!facility.value.id) return if (!facility.value.id && !normalizePoiName(facility.value.name)) return
try { try {
const routeReadiness = await guideUseCase.getRouteReadiness() const routeReadinessMessage = guideUseCase.getRouteReadinessSnapshot().message
const poi = await guideUseCase.getPoiById(facility.value.id)
if (!poi) return
facility.value = toFacilityDetailViewModel( // 详情页以位置预览为主,优先使用模型点位,避免旧内容兜底覆盖真实三维点位。
poi, const renderPoi = await tryResolveRenderPoiByName()
routeReadiness.message if (renderPoi) {
) applyRenderPoiToFacility(renderPoi, routeReadinessMessage)
return
}
const poi = await tryResolveFacilityPoi()
if (poi) {
facility.value = toFacilityDetailViewModel(
poi,
routeReadinessMessage
)
focusFacilityPoi(poi)
}
} catch (error) { } 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) => { const handleFloorChange = (floor: string) => {
activeFloor.value = floor activeFloor.value = floor
} }
const handleToolClick = (tool: string) => { const handleCloseDetailPanel = () => {
if (tool === '2D') { isDetailPanelVisible.value = false
handleModeChange('2d') }
return
}
if (tool === '3D') { const handleOpenDetailPanel = () => {
handleModeChange('3d') isDetailPanelVisible.value = true
return
}
if (tool === '重置') {
activeMode.value = '3d'
}
uni.showToast({
title: tool === '重置' ? '已回到三维位置预览' : '该工具暂未开放',
icon: 'none'
})
} }
const handleTopTabChange = (tab: GuideTopTab) => { const handleTopTabChange = (tab: GuideTopTab) => {
@@ -335,86 +350,106 @@ const handlePageBack = () => {
<style scoped lang="scss"> <style scoped lang="scss">
.detail-sheet { .detail-sheet {
position: absolute; position: absolute;
left: 0; left: 84px;
right: 0; right: 16px;
bottom: calc(env(safe-area-inset-bottom) + 116px); bottom: calc(env(safe-area-inset-bottom) + 116px);
height: 252px; min-height: 110px;
padding: 22px 20px 20px; padding: 14px 14px 12px;
box-sizing: border-box; box-sizing: border-box;
background: #ffffff; background: rgba(255, 255, 255, 0.96);
border: 0; border: 1px solid #e5e6de;
border-radius: 20px; border-radius: 8px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14); box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12);
z-index: 45; z-index: 45;
} }
.detail-title-row { .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; height: 28px;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; 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 { .detail-title {
flex: 1; font-size: 17px;
min-width: 0;
font-size: 18px;
line-height: 24px; line-height: 24px;
font-weight: 700; font-weight: 700;
color: #000000; color: #151713;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.open-status { .detail-subtitle {
width: 60px; font-size: 12px;
flex-shrink: 0; line-height: 17px;
text-align: right; color: #696962;
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;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.tag-row { .tag-row {
margin-top: 21px; margin-top: 12px;
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.detail-tag { .detail-tag {
height: 28px; height: 26px;
min-width: 88px; min-width: 68px;
padding: 0 15px; padding: 0 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-sizing: border-box; box-sizing: border-box;
background: #ffffff; background: #ffffff;
border: 1px solid #d9d9d9; border: 1px solid #e5e6de;
border-radius: 14px; border-radius: 8px;
} }
.detail-tag.active { .detail-tag.active {
background: #000000; background: #151713;
border-color: #000000; border-color: #151713;
} }
.detail-tag-text { .detail-tag-text {
@@ -428,191 +463,58 @@ const handlePageBack = () => {
color: var(--museum-accent); color: var(--museum-accent);
} }
.action-row { .detail-hint {
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 {
display: block; display: block;
font-size: 18px; margin-top: 10px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.start-select-desc {
display: block;
margin-top: 6px;
font-size: 12px; font-size: 12px;
line-height: 17px; 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; font-weight: 500;
color: #60666d; color: #1a7f37;
} }
.start-chip-row { .detail-restore {
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; position: absolute;
left: 20px; right: 18px;
right: 20px; bottom: calc(env(safe-area-inset-bottom) + 266px);
bottom: 16px; height: 40px;
display: flex; min-width: 74px;
gap: 16px; padding: 0 12px;
}
.start-panel-btn {
height: 42px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 7px;
box-sizing: border-box; box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dde5df;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 45;
} }
.start-panel-btn.secondary { .target-dot.small {
width: 151px; width: 10px;
background: #ffffff; height: 10px;
border: 1px solid #000000; margin-top: 0;
border-width: 2px;
box-shadow: none;
} }
.start-panel-btn.primary { .detail-restore-text {
width: 168px; font-size: 13px;
background: #000000; line-height: 18px;
border: 1px solid #000000; font-weight: 700;
} color: #151713;
.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) { @media (max-width: 360px) {
.start-panel-actions { .detail-sheet {
gap: 10px; left: 78px;
right: 12px;
} }
.start-panel-btn.secondary, .detail-tag {
.start-panel-btn.primary { min-width: 0;
width: auto; padding: 0 9px;
flex: 1;
} }
} }
</style> </style>

View File

@@ -14,7 +14,7 @@
<text class="hall-meta">{{ hall.floorLabel }} · {{ explainCountText }}</text> <text class="hall-meta">{{ hall.floorLabel }} · {{ explainCountText }}</text>
<view <view
class="location-action" class="location-action"
:class="{ disabled: !hall.poiId }" :class="{ disabled: !hallLocationPoiId }"
@tap="handleNavigate" @tap="handleNavigate"
> >
<svg class="location-icon" width="16" height="16" viewBox="0 0 24 24" fill="none"> <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 activeTopTab = ref<GuideTopTab>('explain')
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const resolvedHallPoiId = ref('')
const hall = ref<HallDetailViewModel>({ const hall = ref<HallDetailViewModel>({
id: '', id: '',
@@ -116,6 +117,37 @@ const explainCountText = computed(() => (
exhibits.value.length > 0 ? `${exhibits.value.length} 条讲解` : '暂无讲解' 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 = {}) => { onLoad(async (options: any = {}) => {
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
if (isGuideTopTab(tab)) { if (isGuideTopTab(tab)) {
@@ -141,6 +173,25 @@ onLoad(async (options: any = {}) => {
hall.value = toHallDetailViewModel(hallData) hall.value = toHallDetailViewModel(hallData)
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id) const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
exhibits.value = hallExhibits.map(toExplainExhibitViewModel) 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) { } catch (err) {
console.error('加载展厅讲解失败:', err) console.error('加载展厅讲解失败:', err)
error.value = '展厅讲解加载失败,请稍后重试' error.value = '展厅讲解加载失败,请稍后重试'
@@ -163,7 +214,8 @@ const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
} }
const handleNavigate = async () => { const handleNavigate = async () => {
if (!hall.value.poiId) { const matchedPoi = await resolveHallGuidePoi()
if (!matchedPoi) {
uni.showToast({ uni.showToast({
title: '该展厅暂无三维位置数据', title: '该展厅暂无三维位置数据',
icon: 'none' icon: 'none'
@@ -171,17 +223,24 @@ const handleNavigate = async () => {
return return
} }
const matchedPoi = await guideUseCase.getPoiById(hall.value.poiId) resolvedHallPoiId.value = matchedPoi.id
if (!matchedPoi) { hall.value = {
uni.showToast({ ...hall.value,
title: '该展厅位置尚未映射到当前三维导览资源', poiId: matchedPoi.id,
icon: 'none' location: hall.value.location || {
}) status: 'exact',
return poiId: matchedPoi.id,
sourcePoiId: matchedPoi.id,
actionText: '查看三维位置',
previewOnly: true,
floorId: matchedPoi.floorId,
floorLabel: matchedPoi.floorLabel,
note: '已定位到该展厅的三维点位。'
}
} }
uni.navigateTo({ 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" :layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor" :active-floor="activeGuideFloor"
layer-mode-toggle-top="154px" layer-mode-toggle-top="154px"
floor-bottom="185px" floor-bottom="153px"
floor-max-height="206px" floor-max-height="206px"
floor-side="left" floor-side="left"
show-floor-header show-floor-header
:show-layer-mode-toggle="false" :show-layer-mode-toggle="false"
:show-floor="is3DMode" :show-floor="is3DMode"
show-zoom-controls show-zoom-controls
zoom-controls-top="calc(100vh - 284px)" zoom-controls-top="calc(100vh - 252px)"
:route-preview="activeRoutePreview" :route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)" :show-route="Boolean(activeRoutePreview)"
:disable-auto-exit="disableIndoorAutoExit" :disable-auto-exit="disableIndoorAutoExit"
@@ -71,7 +71,7 @@
<view v-if="isPoiCardCollapsed" class="poi-card-collapsed" @tap="expandPoiCard"> <view v-if="isPoiCardCollapsed" class="poi-card-collapsed" @tap="expandPoiCard">
<view class="poi-card-copy"> <view class="poi-card-copy">
<text class="poi-card-title">{{ selectedGuidePoi.name }}</text> <text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
<text class="poi-card-subtitle">位置预览 · 点按展开操作</text> <text class="poi-card-subtitle">{{ selectedGuidePoiCollapsedSubtitle }}</text>
</view> </view>
<view class="poi-card-mini-action"> <view class="poi-card-mini-action">
<text class="poi-card-mini-text">展开</text> <text class="poi-card-mini-text">展开</text>
@@ -92,15 +92,12 @@
</view> </view>
</view> </view>
</view> </view>
<view class="poi-card-actions"> <view
<view class="poi-action primary" @tap="handlePreviewSelectedPoi"> v-if="selectedGuidePoiIsHall"
<text class="poi-action-text">查看位置</text> class="poi-card-actions"
</view> >
<view class="poi-action" @tap="handleSetSelectedPoiAsStart"> <view class="poi-action primary" @tap="handleViewSelectedHall">
<text class="poi-action-text">从这里出发</text> <text class="poi-action-text">查看展厅</text>
</view>
<view class="poi-action" @tap="handleSetSelectedPoiAsEnd">
<text class="poi-action-text">去这里</text>
</view> </view>
<view class="poi-action" @tap="handleSelectedPoiRelated"> <view class="poi-action" @tap="handleSelectedPoiRelated">
<text class="poi-action-text">相关讲解</text> <text class="poi-action-text">相关讲解</text>
@@ -248,7 +245,8 @@ import type {
GuideRouteResult, GuideRouteResult,
GuideRouteTarget, GuideRouteTarget,
MuseumExhibit, MuseumExhibit,
MuseumFloor MuseumFloor,
MuseumHall
} from '@/domain/museum' } from '@/domain/museum'
import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum' import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum'
import { import {
@@ -400,13 +398,27 @@ const selectedGuidePoiSubtitle = computed(() => {
return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}` 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(() => { const routeSimulationStepText = computed(() => {
if (!activeRoutePreview.value) return routeReady.value ? '馆内导览 · 查看路线' : '馆内导览 · 查看位置' if (!activeRoutePreview.value) return '位置预览 · 查看位置关系'
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0 const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
if (routeReady.value) { return hasConnector ? '位置预览 · 跨楼层位置关系' : '位置预览 · 同层位置关系'
return hasConnector ? '馆内导览 · 跨楼层路线' : '馆内导览 · 同层路线'
}
return hasConnector ? '馆内导览 · 跨楼层位置关系' : '馆内导览 · 同层位置关系'
}) })
const explainHallItems = ref<ExplainHallSelectItem[]>([]) const explainHallItems = ref<ExplainHallSelectItem[]>([])
@@ -667,12 +679,12 @@ const handleIndoorToolClick = (tool: string) => {
const handleGuidePoiClick = (poi: GuideRenderPoi) => { const handleGuidePoiClick = (poi: GuideRenderPoi) => {
selectedGuidePoi.value = poi selectedGuidePoi.value = poi
isPoiCardCollapsed.value = false isPoiCardCollapsed.value = !selectedGuidePoiIsHall.value
indoorView.value = 'floor' indoorView.value = 'floor'
isSimulatingRoute.value = false isSimulatingRoute.value = false
const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId) const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId)
activeGuideFloor.value = matchedFloor?.label || poi.floorId activeGuideFloor.value = matchedFloor?.label || poi.floorId
showIndoorHint('已选中标记,可查看位置预览', 3200) showIndoorHint('已在当前楼层标出点位', 3000)
} }
const handleGuideSelectionClear = () => { const handleGuideSelectionClear = () => {
@@ -692,55 +704,84 @@ const togglePoiCardCollapsed = () => {
isPoiCardCollapsed.value = !isPoiCardCollapsed.value isPoiCardCollapsed.value = !isPoiCardCollapsed.value
} }
const findRouteOptionForPoi = async (poi: GuideRenderPoi) => { const normalizeHallMatchText = (value?: string) => (value || '')
if (!routePointOptions.value.length) { .trim()
await loadRouteTargets() .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') => { const navigateToHallDetail = (hallId: string) => {
if (!selectedGuidePoi.value) return uni.navigateTo({
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
})
}
const option = await findRouteOptionForPoi(selectedGuidePoi.value) const handleViewSelectedHall = async () => {
if (!option) { const hall = await resolveSelectedPoiHall()
if (!hall) {
uni.showToast({ uni.showToast({
title: '该点位暂无线路数据,可先查看三维位置', title: '该展厅详情暂未接入',
icon: 'none' icon: 'none'
}) })
return return
} }
if (mode === 'start') { navigateToHallDetail(hall.id)
routeStartPoint.value = option }
} else {
routeEndPoint.value = option const handleSelectedPoiRelated = async () => {
if (!selectedGuidePoi.value) return
const hall = await resolveSelectedPoiHall()
if (hall) {
navigateToHallDetail(hall.id)
return
} }
clearRoutePreviewState() const keyword = selectedGuidePoi.value.hallName || selectedGuidePoi.value.name
openRoutePlanner() 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 (firstHall?.hallId) {
if (!selectedGuidePoi.value) return navigateToHallDetail(firstHall.hallId)
indoorView.value = 'floor' return
showRoutePlanner.value = false }
isSimulatingRoute.value = false
}
const handleSetSelectedPoiAsStart = () => { if (firstExhibit?.exhibitId) {
void setSelectedPoiAsRoutePoint('start') uni.navigateTo({
} url: `/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
})
return
}
const handleSetSelectedPoiAsEnd = () => { uni.showToast({
void setSelectedPoiAsRoutePoint('end') title: '该展厅暂无对应讲解',
} icon: 'none'
const handleSelectedPoiRelated = () => {
if (!selectedGuidePoi.value) return
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(selectedGuidePoi.value.name)}`
}) })
} }
@@ -769,15 +810,16 @@ const refreshRouteReadinessState = async () => {
routeReady.value = readiness.ready routeReady.value = readiness.ready
routePreviewUnavailableMessage.value = readiness.ready routePreviewUnavailableMessage.value = readiness.ready
? '' ? ''
: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意' : readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
} catch (error) { } catch (error) {
console.warn('读取路线能力状态失败:', error) console.warn('读取路线能力状态失败:', error)
routeReady.value = false routeReady.value = false
routePreviewUnavailableMessage.value = '当前暂不支持正式导航,可查看位置预览和路线示意' routePreviewUnavailableMessage.value = '当前可查看位置预览和位置关系,暂不提供正式室内导航'
} }
} }
const openRoutePlanner = () => { const openRoutePlanner = () => {
closeOutdoorNavPanel()
showRoutePlanner.value = true showRoutePlanner.value = true
is3DMode.value = true is3DMode.value = true
indoorView.value = indoorView.value === 'overview' ? 'floor' : indoorView.value indoorView.value = indoorView.value === 'overview' ? 'floor' : indoorView.value
@@ -898,6 +940,7 @@ const handleRouteSimulate = () => {
// 进入 3D 馆内模式 // 进入 3D 馆内模式
const handleEnter3DMode = () => { const handleEnter3DMode = () => {
console.log('进入 3D 馆内模式') console.log('进入 3D 馆内模式')
closeOutdoorNavPanel()
indoorView.value = 'overview' indoorView.value = 'overview'
is3DMode.value = true is3DMode.value = true
guideOutdoorState.value = 'home' guideOutdoorState.value = 'home'
@@ -919,6 +962,7 @@ const handleModeChange = (mode: '2d' | '3d') => {
isSimulatingRoute.value = false isSimulatingRoute.value = false
clearRoutePreviewState() clearRoutePreviewState()
} else { } else {
closeOutdoorNavPanel()
indoorView.value = 'overview' indoorView.value = 'overview'
showIndoorHint() showIndoorHint()
if (!routePointOptions.value.length) { if (!routePointOptions.value.length) {
@@ -970,18 +1014,21 @@ const clearOutdoorNavState = () => {
outdoorNavManualMode.value = false outdoorNavManualMode.value = false
} }
const closeOutdoorNavPanel = () => {
showOutdoorNavPanel.value = false
clearOutdoorNavState()
}
const handleMoreOutdoorNav = () => { const handleMoreOutdoorNav = () => {
openOutdoorNavPanel() openOutdoorNavPanel()
} }
const handleOutdoorNavClose = () => { const handleOutdoorNavClose = () => {
showOutdoorNavPanel.value = false closeOutdoorNavPanel()
clearOutdoorNavState()
} }
const handleOutdoorNavBack = () => { const handleOutdoorNavBack = () => {
showOutdoorNavPanel.value = false closeOutdoorNavPanel()
clearOutdoorNavState()
} }
const handleOutdoorNavGpsStart = () => { const handleOutdoorNavGpsStart = () => {
@@ -1291,7 +1338,7 @@ const handleExplainBack = () => {
.poi-card-actions { .poi-card-actions {
margin-top: 12px; margin-top: 12px;
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 8px;
} }

View File

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

View File

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

View File

@@ -136,6 +136,10 @@ const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
requiredData: ['route_graph', 'nav_data'] requiredData: ['route_graph', 'nav_data']
}) })
const logRouteReadinessIssue = (message: string, payload: Record<string, unknown>) => {
console.error(`[RouteReadiness] ${message}`, payload)
}
export class StaticGuideRouteRepository implements GuideRouteRepository { export class StaticGuideRouteRepository implements GuideRouteRepository {
private datasetCache: NavRouteDataset | null = null private datasetCache: NavRouteDataset | null = null
private datasetRequest: Promise<NavRouteDataset> | null = null private datasetRequest: Promise<NavRouteDataset> | null = null
@@ -156,11 +160,17 @@ export class StaticGuideRouteRepository implements GuideRouteRepository {
try { try {
const dataset = await this.loadDataset() const dataset = await this.loadDataset()
if (!dataset.nodes.size || !dataset.edges.length || !dataset.anchorsByPoiId.size) { 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) return applyRouteReadinessGate(manifestReadiness)
} catch (error) { } catch (error) {
console.error('[RouteReadiness] 静态路线数据加载失败', error)
return createUnavailableReadiness(error instanceof Error ? error.message : '路线示意数据加载失败') return createUnavailableReadiness(error instanceof Error ? error.message : '路线示意数据加载失败')
} }
} }

View File

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

View File

@@ -1,7 +1,7 @@
/* CSS 变量定义 */ /* CSS 变量定义 */
:root { :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; --museum-accent: #E0DF00;

View File

@@ -73,7 +73,7 @@ export class GuideRouteUseCase {
if (!readiness.ready) { if (!readiness.ready) {
return { return {
route: null, route: null,
error: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意' error: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
} }
} }
@@ -95,7 +95,7 @@ export class GuideRouteUseCase {
return error.message 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_LOCATION_PREVIEW_STORAGE_KEY = 'museum-guide:last-location-preview-url'
export const GUIDE_TOP_TABS: Array<{ id: GuideTopTab; label: string }> = [ export const GUIDE_TOP_TABS: Array<{ id: GuideTopTab; label: string }> = [
{ id: 'guide', label: '导览' }, { id: 'guide', label: '馆内' },
{ id: 'explain', label: '讲解' } { id: 'explain', label: '讲解' }
] ]

View File

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