修复 H5 来馆第三方导航构建边界
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-12 00:30:18 +08:00
parent e99d713dd0
commit e006333c0a
13 changed files with 560 additions and 523 deletions

View File

@@ -16,6 +16,7 @@
"dev:test:mp-weixin": "uni -p mp-weixin --mode test",
"build:test:mp-weixin": "uni build -p mp-weixin --mode test",
"build:mp-weixin": "uni build -p mp-weixin --mode production",
"verify:arrival-build": "node scripts/verify-arrival-navigation-build.cjs",
"test:unit": "vitest run",
"type-check": "vue-tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,vue}\""
@@ -37,9 +38,12 @@
"@types/three": "0.163.0",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@vitejs/plugin-vue": "5.2.4",
"@vue/runtime-core": "3.4.21",
"@vue/test-utils": "2.4.6",
"eslint": "8.57.1",
"eslint-plugin-vue": "9.30.0",
"happy-dom": "14.12.3",
"sass": "1.99.0",
"sass-embedded": "1.99.0",
"typescript": "5.3.3",

138
pnpm-lock.yaml generated
View File

@@ -51,15 +51,24 @@ importers:
'@typescript-eslint/parser':
specifier: 7.18.0
version: 7.18.0(eslint@8.57.1)(typescript@5.3.3)
'@vitejs/plugin-vue':
specifier: 5.2.4
version: 5.2.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))
'@vue/runtime-core':
specifier: 3.4.21
version: 3.4.21
'@vue/test-utils':
specifier: 2.4.6
version: 2.4.6
eslint:
specifier: 8.57.1
version: 8.57.1
eslint-plugin-vue:
specifier: 9.30.0
version: 9.30.0(eslint@8.57.1)
happy-dom:
specifier: 14.12.3
version: 14.12.3
sass:
specifier: 1.99.0
version: 1.99.0
@@ -74,7 +83,7 @@ importers:
version: 5.2.8(@types/node@25.9.1)(sass@1.99.0)(terser@5.47.1)
vitest:
specifier: 1.6.1
version: 1.6.1(@types/node@25.9.1)(jsdom@16.7.0)(sass@1.99.0)(terser@5.47.1)
version: 1.6.1(@types/node@25.9.1)(happy-dom@14.12.3)(jsdom@16.7.0)(sass@1.99.0)(terser@5.47.1)
vue-eslint-parser:
specifier: 9.4.3
version: 9.4.3(eslint@8.57.1)
@@ -1272,6 +1281,9 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
@@ -1764,10 +1776,16 @@ packages:
'@vue/shared@3.5.34':
resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==}
'@vue/test-utils@2.4.6':
resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
abbrev@1.0.3:
resolution: {integrity: sha512-s07HMJf6O5iTLVDx9cH7c9VbOdrmzxE+AzWz9CPi94zVNBQQA3jIwIZKTrHQj4dGR1T/MdwMnVJzSpjaVEXtXw==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -2075,6 +2093,10 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@10.0.0:
resolution: {integrity: sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==}
engines: {node: '>=14'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -2093,6 +2115,9 @@ packages:
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
config-chain@1.1.13:
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -2270,6 +2295,11 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
editorconfig@1.0.7:
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
engines: {node: '>=14'}
hasBin: true
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2573,6 +2603,11 @@ packages:
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
deprecated: Glob versions prior to v9 are no longer supported
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -2594,6 +2629,10 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
happy-dom@14.12.3:
resolution: {integrity: sha512-vsYlEs3E9gLwA1Hp+w3qzu+RUDFf4VTT8cyKqVICoZ2k7WM++Qyd2LwzyTi5bqMJFiIC/vNpTDYuxdreENRK/g==}
engines: {node: '>=16.0.0'}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
@@ -2695,6 +2734,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.4:
resolution: {integrity: sha512-VUA7WAWNCWfm6/8f9kAb8Y6iGBWnmCfgFS5dTrv2C38LLm1KUmpY388mCVCJCsMKQomvOQ1oW8/edXdChd9ZXQ==}
deprecated: Please update to ini >=1.3.6 to avoid a prototype pollution issue
invert-kv@3.0.1:
resolution: {integrity: sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==}
engines: {node: '>=8'}
@@ -2926,6 +2969,11 @@ packages:
jpeg-js@0.3.7:
resolution: {integrity: sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==}
js-beautify@1.14.9:
resolution: {integrity: sha512-coM7xq1syLcMyuVGyToxcj2AlzhkDjmfklL8r0JgJ7A76wyGMpJ1oA35mr4APdYNO/o/4YY8H54NQIJzhMbhBg==}
engines: {node: '>=12'}
hasBin: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3141,6 +3189,10 @@ packages:
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -3189,6 +3241,11 @@ packages:
resolution: {integrity: sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg==}
engines: {node: '>=18'}
nopt@6.0.0:
resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
hasBin: true
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -3471,6 +3528,9 @@ packages:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -4172,6 +4232,9 @@ packages:
jsdom:
optional: true
vue-component-type-helpers@2.0.0:
resolution: {integrity: sha512-r+lO2aYasxliE6I76dutaY0GBCnqM7Q/c3VO2MPxrJsIUwPZIESLfis9/1EXAMWajnpF+o0syJAIV6Y6/crKNw==}
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -4219,6 +4282,10 @@ packages:
resolution: {integrity: sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==}
engines: {node: '>=10.4'}
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -4228,6 +4295,10 @@ packages:
whatwg-mimetype@2.3.0:
resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==}
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
whatwg-url@8.7.0:
resolution: {integrity: sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==}
engines: {node: '>=10'}
@@ -6245,6 +6316,8 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@one-ini/wasm@0.1.1': {}
'@parcel/watcher-android-arm64@2.5.6':
optional: true
@@ -6759,8 +6832,15 @@ snapshots:
'@vue/shared@3.5.34': {}
'@vue/test-utils@2.4.6':
dependencies:
js-beautify: 1.14.9
vue-component-type-helpers: 2.0.0
abab@2.0.6: {}
abbrev@1.0.3: {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -7106,6 +7186,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
commander@10.0.0: {}
commander@2.20.3: {}
compare-versions@3.6.0: {}
@@ -7118,6 +7200,11 @@ snapshots:
confbox@0.2.4: {}
config-chain@1.1.13:
dependencies:
ini: 1.3.4
proto-list: 1.2.4
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
@@ -7245,6 +7332,13 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
editorconfig@1.0.7:
dependencies:
'@one-ini/wasm': 0.1.1
commander: 10.0.0
minimatch: 9.0.9
semver: 7.8.0
ee-first@1.1.1: {}
electron-to-chromium@1.5.360: {}
@@ -7637,6 +7731,14 @@ snapshots:
once: 1.4.0
path-is-absolute: 1.0.1
glob@8.1.0:
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 5.1.9
once: 1.4.0
global@4.4.0:
dependencies:
min-document: 2.19.2
@@ -7661,6 +7763,12 @@ snapshots:
graphemer@1.4.0: {}
happy-dom@14.12.3:
dependencies:
entities: 4.5.0
webidl-conversions: 7.0.0
whatwg-mimetype: 3.0.0
has-flag@3.0.0: {}
has-flag@4.0.0: {}
@@ -7751,6 +7859,8 @@ snapshots:
inherits@2.0.4: {}
ini@1.3.4: {}
invert-kv@3.0.1: {}
ipaddr.js@1.9.1: {}
@@ -8194,6 +8304,13 @@ snapshots:
jpeg-js@0.3.7: {}
js-beautify@1.14.9:
dependencies:
config-chain: 1.1.13
editorconfig: 1.0.7
glob: 8.1.0
nopt: 6.0.0
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -8401,6 +8518,10 @@ snapshots:
dependencies:
brace-expansion: 1.1.14
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.0
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.0
@@ -8439,6 +8560,10 @@ snapshots:
node-releases@2.0.45: {}
nopt@6.0.0:
dependencies:
abbrev: 1.0.3
normalize-path@3.0.0: {}
normalize-range@0.1.2: {}
@@ -8706,6 +8831,8 @@ snapshots:
kleur: 3.0.3
sisteransi: 1.0.5
proto-list@1.2.4: {}
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -9350,7 +9477,7 @@ snapshots:
sass: 1.99.0
terser: 5.47.1
vitest@1.6.1(@types/node@25.9.1)(jsdom@16.7.0)(sass@1.99.0)(terser@5.47.1):
vitest@1.6.1(@types/node@25.9.1)(happy-dom@14.12.3)(jsdom@16.7.0)(sass@1.99.0)(terser@5.47.1):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
@@ -9374,6 +9501,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.9.1
happy-dom: 14.12.3
jsdom: 16.7.0
transitivePeerDependencies:
- less
@@ -9384,6 +9512,8 @@ snapshots:
- supports-color
- terser
vue-component-type-helpers@2.0.0: {}
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.4.3
@@ -9440,6 +9570,8 @@ snapshots:
webidl-conversions@6.1.0: {}
webidl-conversions@7.0.0: {}
webpack-virtual-modules@0.6.2: {}
whatwg-encoding@1.0.5:
@@ -9448,6 +9580,8 @@ snapshots:
whatwg-mimetype@2.3.0: {}
whatwg-mimetype@3.0.0: {}
whatwg-url@8.7.0:
dependencies:
lodash: 4.18.1

View File

@@ -0,0 +1,107 @@
const fs = require('node:fs')
const path = require('node:path')
const projectRoot = path.resolve(__dirname, '..')
const h5Root = path.resolve(projectRoot, process.argv[2] || path.join('dist', 'build', 'h5'))
const mpRoot = path.resolve(projectRoot, process.argv[3] || path.join('dist', 'build', 'mp-weixin'))
const readText = (filePath) => fs.readFileSync(filePath, 'utf8')
const assertContains = (source, value, label) => {
if (!source.includes(value)) {
throw new Error(`${label} 缺少:${value}`)
}
}
const assertNotContains = (source, value, label) => {
if (source.includes(value)) {
throw new Error(`${label} 不应包含:${value}`)
}
}
const collectJavaScriptFiles = (root) => fs.readdirSync(root, { withFileTypes: true })
.flatMap((entry) => {
const entryPath = path.join(root, entry.name)
if (entry.isDirectory()) return collectJavaScriptFiles(entryPath)
return entry.isFile() && entry.name.endsWith('.js') ? [entryPath] : []
})
const htmlPath = path.join(h5Root, 'index.html')
const html = readText(htmlPath)
const entryMatch = html.match(/<script[^>]+src="([^"]+\.js)"/)
if (!entryMatch) throw new Error('H5 index.html 未找到入口 JS')
const entryPath = path.join(h5Root, entryMatch[1].replace(/^\//, ''))
const entryCode = readText(entryPath)
const pageChunkMatch = entryCode.match(/pages-index-index\.[A-Za-z0-9_-]+\.js/)
if (!pageChunkMatch) throw new Error('H5 入口 JS 未找到首页页面 chunk')
const pageChunkPath = path.join(path.dirname(entryPath), pageChunkMatch[0])
const pageChunkCode = readText(pageChunkPath)
const h5JavaScriptFiles = collectJavaScriptFiles(h5Root)
const h5JavaScriptCode = h5JavaScriptFiles
.map((filePath) => readText(filePath))
.join('\n')
for (const required of ['第三方导航', 'third-party-providers']) {
assertContains(pageChunkCode, required, 'H5 首页 chunk')
}
for (const forbidden of [
'打开地图导航',
'native-location',
'jweixin',
'wx.miniProgram',
'宿主地图',
'/pages/open-location/index'
]) {
assertNotContains(h5JavaScriptCode, forbidden, 'H5 全量 JS')
}
if (h5JavaScriptFiles.some((filePath) => path.basename(filePath).includes('pages-open-location-index'))) {
throw new Error('H5 构建不应生成 open-location 页面 chunk')
}
const mpArrivalPanelPath = path.join(
mpRoot,
'components',
'navigation',
'ArrivalPanel.js'
)
const mpArrivalPanelCode = readText(mpArrivalPanelPath)
const mpArrivalPanelTemplatePath = path.join(
mpRoot,
'components',
'navigation',
'ArrivalPanel.wxml'
)
const mpArrivalPanelTemplate = readText(mpArrivalPanelTemplatePath)
assertContains(mpArrivalPanelCode, '打开地图导航', 'mp-weixin 来馆组件')
assertContains(
mpArrivalPanelCode,
'../../services/WechatOpenLocationService.js',
'mp-weixin 来馆组件'
)
assertNotContains(mpArrivalPanelCode, 'third-party-providers', 'mp-weixin 来馆组件')
assertNotContains(mpArrivalPanelTemplate, 'provider-sheet', 'mp-weixin 来馆模板')
assertNotContains(mpArrivalPanelTemplate, '第三方导航', 'mp-weixin 来馆模板')
const mpAppConfigPath = path.join(mpRoot, 'app.json')
const mpAppConfig = JSON.parse(readText(mpAppConfigPath))
if (!mpAppConfig.pages?.includes('pages/open-location/index')) {
throw new Error('mp-weixin app.json 未注册 open-location 页面')
}
console.log(JSON.stringify({
h5: {
html: path.relative(projectRoot, htmlPath),
entry: path.relative(projectRoot, entryPath),
pageChunk: path.relative(projectRoot, pageChunkPath),
navigationMode: 'third-party-providers'
},
mpWeixin: {
component: path.relative(projectRoot, mpArrivalPanelPath),
template: path.relative(projectRoot, mpArrivalPanelTemplatePath),
navigationMode: 'native-location'
}
}, null, 2))

View File

@@ -77,6 +77,7 @@
</template>
</view>
<!-- #ifndef MP-WEIXIN -->
<view
v-if="providerSheetVisible && selectedTarget"
class="provider-scrim"
@@ -109,6 +110,24 @@
<text class="provider-action">打开</text>
</view>
</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view
v-if="buildDiagnostics"
class="arrival-build-diagnostics"
data-testid="arrival-build-diagnostics"
>
<text class="arrival-build-diagnostics-title">H5 构建诊断</text>
<text class="arrival-build-diagnostics-line">构建 ID{{ buildDiagnostics.buildId }}</text>
<text class="arrival-build-diagnostics-line">Git commit{{ buildDiagnostics.gitCommit }}</text>
<text class="arrival-build-diagnostics-line">构建平台{{ buildDiagnostics.platform }}</text>
<text class="arrival-build-diagnostics-line">navigationMode{{ buildDiagnostics.navigationMode }}</text>
<text class="arrival-build-diagnostics-line">页面 chunk{{ buildDiagnostics.chunkFileName }}</text>
<text class="arrival-build-diagnostics-line">location.href{{ buildDiagnostics.href }}</text>
<text class="arrival-build-diagnostics-line">UA{{ buildDiagnostics.userAgent }}</text>
</view>
<!-- #endif -->
</template>
<script setup lang="ts">
@@ -118,17 +137,19 @@ import {
type ArrivalSearchTarget,
type ArrivalTargetType
} from '@/data/arrivalSearchTargets'
// #ifndef MP-WEIXIN
import {
openThirdPartyMapSearch,
openWechatMiniProgramLocation,
THIRD_PARTY_MAP_PROVIDERS,
type ThirdPartyMapProviderOption
} from '@/services/ThirdPartyMapSearchService'
import { isNativeWechatMiniProgram } from '@/utils/hostEnvironment'
import {
getArrivalNavigationActionText,
resolveArrivalNavigationMode
} from '@/components/navigation/arrivalNavigationPolicy'
// #endif
// #ifdef MP-WEIXIN
import { openWechatHostLocation } from '@/services/WechatOpenLocationService'
import { validateWechatOpenLocationTarget } from '@/utils/wechatOpenLocationProtocol'
// #endif
type ArrivalNavigationMode = 'third-party-providers' | 'native-location'
const props = withDefaults(defineProps<{
visible?: boolean
@@ -151,17 +172,64 @@ const emit = defineEmits<{
}>()
const typeOptions = computed(() => ARRIVAL_TARGET_TYPES)
// #ifndef MP-WEIXIN
const mapProviders = computed(() => THIRD_PARTY_MAP_PROVIDERS)
const navigationMode = resolveArrivalNavigationMode(isNativeWechatMiniProgram())
const primaryActionText = computed(() => (
getArrivalNavigationActionText(navigationMode, isOpeningNavigation.value)
))
// #endif
const navigationMode: ArrivalNavigationMode = __APP_BUILD_PLATFORM__ === 'mp-weixin'
? 'native-location'
: 'third-party-providers'
const arrivalPanelRef = ref<unknown>(null)
const isCollapsed = ref(false)
const providerSheetVisible = ref(false)
const isOpeningNavigation = ref(false)
let resizeObserver: ResizeObserver | null = null
const primaryActionText = computed(() => {
// #ifdef MP-WEIXIN
if (navigationMode === 'native-location') {
return isOpeningNavigation.value ? '正在打开...' : '打开地图导航'
}
// #endif
return '第三方导航'
})
// #ifdef H5
const hasDebugBuildFlag = () => {
if (typeof window === 'undefined') return false
const searchParams = new URLSearchParams(window.location.search)
if (searchParams.get('debug-build') === '1') return true
const hash = window.location.hash || ''
const hashQueryStart = hash.indexOf('?')
if (hashQueryStart < 0) return false
return new URLSearchParams(hash.slice(hashQueryStart + 1)).get('debug-build') === '1'
}
const resolveCurrentChunkFileName = () => {
try {
const pathname = new URL(import.meta.url).pathname
return decodeURIComponent(pathname.split('/').pop() || pathname)
} catch {
return import.meta.url
}
}
const buildDiagnostics = hasDebugBuildFlag()
? {
buildId: __APP_BUILD_ID__,
gitCommit: __APP_GIT_COMMIT__,
platform: __APP_BUILD_PLATFORM__,
navigationMode,
chunkFileName: resolveCurrentChunkFileName(),
href: window.location.href,
userAgent: window.navigator.userAgent
}
: null
// #endif
const activeTypeLabel = computed(() => (
typeOptions.value.find((option) => option.type === props.activeType)?.label || '来馆'
))
@@ -246,7 +314,8 @@ const startLayoutObserver = () => {
const handleNavigateTap = async () => {
if (isOpeningNavigation.value) return
if (!props.selectedTarget) {
const selectedTarget = props.selectedTarget
if (!selectedTarget) {
uni.showToast({
title: '请先选择点位',
icon: 'none'
@@ -254,26 +323,44 @@ const handleNavigateTap = async () => {
return
}
// H5含小程序 web-view统一由用户选择第三方地图。
if (navigationMode === 'third-party-providers') {
// #ifndef MP-WEIXIN
if (__APP_BUILD_PLATFORM__ !== 'mp-weixin') {
providerSheetVisible.value = true
return
}
// #endif
const target = {
latitude: props.selectedTarget.latitude,
longitude: props.selectedTarget.longitude,
name: props.selectedTarget.title,
address: props.selectedTarget.subtitle
// #ifdef MP-WEIXIN
const validation = validateWechatOpenLocationTarget({
latitude: selectedTarget.latitude,
longitude: selectedTarget.longitude,
name: selectedTarget.title,
address: selectedTarget.subtitle
})
if (!validation.ok) {
uni.showToast({
title: validation.message,
icon: 'none'
})
return
}
isOpeningNavigation.value = true
try {
await openWechatMiniProgramLocation(target)
const result = await openWechatHostLocation(validation.value)
if (result.status !== 'opened') {
uni.showToast({
title: result.reason === 'timeout' ? '微信地图响应超时' : '无法打开微信地图',
icon: 'none'
})
}
} finally {
isOpeningNavigation.value = false
}
// #endif
}
// #ifndef MP-WEIXIN
const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => {
const target = props.selectedTarget
if (!target) return
@@ -283,6 +370,7 @@ const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => {
region: target.region
})
}
// #endif
watch(
() => props.visible,
@@ -722,4 +810,36 @@ defineExpose({
font-weight: 600;
color: #696962;
}
.arrival-build-diagnostics {
position: fixed;
top: calc(env(safe-area-inset-top) + 8px);
left: 8px;
right: 8px;
z-index: 9999;
max-height: 42vh;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
overflow: auto;
box-sizing: border-box;
border: 1px solid rgba(224, 225, 0, 0.75);
border-radius: 6px;
background: rgba(20, 22, 19, 0.94);
pointer-events: none;
}
.arrival-build-diagnostics-title,
.arrival-build-diagnostics-line {
overflow-wrap: anywhere;
font-size: 11px;
line-height: 16px;
color: #ffffff;
}
.arrival-build-diagnostics-title {
font-weight: 700;
color: #e0e100;
}
</style>

View File

@@ -1,15 +0,0 @@
export type ArrivalNavigationMode = 'third-party-providers' | 'native-location'
export const resolveArrivalNavigationMode = (
isNativeWechatMiniProgram: boolean
): ArrivalNavigationMode => (
isNativeWechatMiniProgram ? 'native-location' : 'third-party-providers'
)
export const getArrivalNavigationActionText = (
mode: ArrivalNavigationMode,
isOpening: boolean
) => {
if (isOpening) return '正在打开...'
return mode === 'native-location' ? '打开地图导航' : '第三方导航'
}

6
src/env.d.ts vendored
View File

@@ -1,7 +1,13 @@
/// <reference types="vite/client" />
declare const __APP_BUILD_ID__: string
declare const __APP_GIT_COMMIT__: string
declare const __APP_BUILD_PLATFORM__: 'h5' | 'mp-weixin'
interface ImportMetaEnv {
readonly VITE_APP_PUBLIC_BASE?: string
readonly VITE_APP_BUILD_ID?: string
readonly VITE_APP_GIT_COMMIT?: string
readonly VITE_GUIDE_DATA_SOURCE_MODE?: 'static' | 'api' | 'sdk'
readonly VITE_EXPLAIN_CONTENT_SOURCE_MODE?: 'static' | 'remote' | 'mock'
readonly VITE_DATA_SOURCE_MODE?: 'static' | 'api' | 'sdk'

View File

@@ -69,7 +69,9 @@
"navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom"
}
},
}
// #ifdef MP-WEIXIN
,
{
"path": "pages/open-location/index",
"style": {
@@ -77,6 +79,7 @@
"navigationBarBackgroundColor": "#FFFFFF"
}
}
// #endif
],
"globalStyle": {
"navigationBarTextStyle": "black",

View File

@@ -1,14 +1,3 @@
import { openExternalNavigation } from '@/services/MapNavigationService'
import {
isEmbeddedInWechatMiniProgram,
isNativeWechatMiniProgram
} from '@/utils/hostEnvironment'
import {
buildWechatOpenLocationPageUrl,
validateWechatOpenLocationTarget,
type WechatOpenLocationTarget
} from '@/utils/wechatOpenLocationProtocol'
export type ThirdPartyMapProvider = 'amap' | 'baidu' | 'tencent'
export interface ThirdPartyMapSearchTarget {
@@ -16,29 +5,6 @@ export interface ThirdPartyMapSearchTarget {
region: string
}
export type WechatMiniProgramLocationTarget = WechatOpenLocationTarget
interface WechatMiniProgramBridge {
getEnv?: (callback: (result: { miniprogram?: boolean }) => void) => void
navigateTo?: (options: {
url: string
success?: () => void
fail?: (error: unknown) => void
}) => void
}
export type WechatMiniProgramLocationOpenResult =
| { status: 'opened' }
| { status: 'not-embedded' }
| { status: 'failed'; reason: 'invalid-target' | 'bridge-unavailable' | 'not-in-mini-program' | 'navigation-failed' }
const BRIDGE_WAIT_TIMEOUT = 2500
const BRIDGE_POLL_INTERVAL = 50
const SDK_SCRIPT_LOAD_TIMEOUT = 10000
const GET_ENV_TIMEOUT = 1500
const NAVIGATION_TIMEOUT = 3000
let miniProgramNavigationPromise: Promise<WechatMiniProgramLocationOpenResult> | null = null
export interface ThirdPartyMapProviderOption {
provider: ThirdPartyMapProvider
label: string
@@ -89,226 +55,3 @@ export const openThirdPartyMapSearch = (
icon: 'none'
})
}
export const buildWechatMiniProgramOpenLocationUrl = (
target: WechatMiniProgramLocationTarget
) => buildWechatOpenLocationPageUrl(target)
const getWechatMiniProgramBridge = (): WechatMiniProgramBridge | null => {
if (typeof window === 'undefined') return null
return (window as Window & {
wx?: {
miniProgram?: WechatMiniProgramBridge
}
}).wx?.miniProgram || null
}
const ensureWechatJsSdkScript = () => {
if (typeof document === 'undefined' || getWechatMiniProgramBridge()) return
const existingScript = document.querySelector<HTMLScriptElement>('script[data-wechat-js-sdk="true"]')
if (existingScript) {
const startedAt = Number(existingScript.dataset.wechatJsSdkStartedAt || 0)
const isExpiredLoading = existingScript.dataset.wechatJsSdkState === 'loading'
&& Date.now() - startedAt > SDK_SCRIPT_LOAD_TIMEOUT
if (existingScript.dataset.wechatJsSdkState !== 'error' && !isExpiredLoading) return
existingScript.remove()
}
const script = document.createElement('script')
script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
script.async = true
script.dataset.wechatJsSdk = 'true'
script.dataset.wechatJsSdkState = 'loading'
script.dataset.wechatJsSdkStartedAt = String(Date.now())
script.onload = () => {
script.dataset.wechatJsSdkState = 'loaded'
}
script.onerror = () => {
script.dataset.wechatJsSdkState = 'error'
script.remove()
}
try {
document.head.appendChild(script)
} catch (error) {
console.error('加载微信 JS SDK 失败:', error)
}
}
const isWechatBrowser = () => (
typeof window !== 'undefined'
&& /MicroMessenger/i.test(window.navigator?.userAgent || '')
)
const wait = (duration: number) => new Promise<void>((resolve) => {
setTimeout(resolve, duration)
})
const waitForWechatMiniProgramBridge = async () => {
const immediateBridge = getWechatMiniProgramBridge()
if (immediateBridge) return immediateBridge
ensureWechatJsSdkScript()
const startedAt = Date.now()
while (Date.now() - startedAt < BRIDGE_WAIT_TIMEOUT) {
await wait(BRIDGE_POLL_INTERVAL)
const bridge = getWechatMiniProgramBridge()
if (bridge) return bridge
}
return null
}
const resolveMiniProgramWebViewEnvironment = (
miniProgram: WechatMiniProgramBridge
) => {
if (isEmbeddedInWechatMiniProgram()) return Promise.resolve(true)
const getEnv = miniProgram.getEnv
if (!getEnv) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
let settled = false
const finish = (isMiniProgram: boolean) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(isMiniProgram)
}
const timeout = setTimeout(() => finish(isEmbeddedInWechatMiniProgram()), GET_ENV_TIMEOUT)
try {
getEnv((result) => finish(Boolean(result?.miniprogram) || isEmbeddedInWechatMiniProgram()))
} catch (error) {
console.error('识别小程序 web-view 环境失败:', error)
// 保留超时重检,让延迟注入的宿主环境标识仍有机会生效。
}
})
}
const navigateToMiniProgramLocationPage = (
miniProgram: WechatMiniProgramBridge,
target: WechatMiniProgramLocationTarget
) => new Promise<WechatMiniProgramLocationOpenResult>((resolve) => {
let settled = false
const finish = (result: WechatMiniProgramLocationOpenResult) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(result)
}
const timeout = setTimeout(() => {
uni.showToast({
title: '宿主地图页响应超时',
icon: 'none'
})
finish({ status: 'failed', reason: 'navigation-failed' })
}, NAVIGATION_TIMEOUT)
if (!miniProgram.navigateTo) {
uni.showToast({
title: '宿主地图桥接不可用',
icon: 'none'
})
finish({ status: 'failed', reason: 'bridge-unavailable' })
return
}
try {
miniProgram.navigateTo({
url: buildWechatMiniProgramOpenLocationUrl(target),
success: () => finish({ status: 'opened' }),
fail: (error) => {
if (settled) return
console.error('跳转小程序地图页失败:', error)
const errorMessage = typeof error === 'object' && error && 'errMsg' in error
? String(error.errMsg)
: ''
uni.showToast({
title: /page.*not found|not found.*page/i.test(errorMessage)
? '宿主小程序未配置地图页'
: '宿主地图页跳转失败',
icon: 'none'
})
finish({ status: 'failed', reason: 'navigation-failed' })
}
})
} catch (error) {
console.error('调用小程序地图桥接失败:', error)
uni.showToast({
title: '宿主地图桥接调用失败',
icon: 'none'
})
finish({ status: 'failed', reason: 'navigation-failed' })
}
})
const openWechatMiniProgramLocationOnce = async (
target: WechatMiniProgramLocationTarget
): Promise<WechatMiniProgramLocationOpenResult> => {
const validation = validateWechatOpenLocationTarget(target)
if (!validation.ok) {
uni.showToast({
title: validation.message,
icon: 'none'
})
return { status: 'failed', reason: 'invalid-target' }
}
if (isNativeWechatMiniProgram()) {
try {
const opened = await openExternalNavigation(validation.value)
return opened
? { status: 'opened' }
: { status: 'failed', reason: 'navigation-failed' }
} catch (error) {
console.error('原生小程序地图能力调用失败:', error)
uni.showToast({
title: '无法打开微信地图',
icon: 'none'
})
return { status: 'failed', reason: 'navigation-failed' }
}
}
if (!isEmbeddedInWechatMiniProgram() && !isWechatBrowser()) {
return { status: 'not-embedded' }
}
const miniProgram = await waitForWechatMiniProgramBridge()
if (!miniProgram?.navigateTo) {
if (isEmbeddedInWechatMiniProgram()) {
uni.showToast({
title: '小程序地图桥接加载失败',
icon: 'none'
})
return { status: 'failed', reason: 'bridge-unavailable' }
}
return { status: 'not-embedded' }
}
const isMiniProgramWebView = await resolveMiniProgramWebViewEnvironment(miniProgram)
if (!isMiniProgramWebView) {
if (isEmbeddedInWechatMiniProgram()) {
uni.showToast({
title: '当前页面未连接宿主小程序',
icon: 'none'
})
return { status: 'failed', reason: 'not-in-mini-program' }
}
return { status: 'not-embedded' }
}
return navigateToMiniProgramLocationPage(miniProgram, validation.value)
}
export const openWechatMiniProgramLocation = (
target: WechatMiniProgramLocationTarget
) => {
if (miniProgramNavigationPromise) return miniProgramNavigationPromise
miniProgramNavigationPromise = openWechatMiniProgramLocationOnce(target)
.finally(() => {
miniProgramNavigationPromise = null
})
return miniProgramNavigationPromise
}

View File

@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ArrivalPanel from '@/components/navigation/ArrivalPanel.vue'
import { ARRIVAL_TARGETS } from '@/data/arrivalSearchTargets'
const selectedTarget = ARRIVAL_TARGETS[0]
const defaultUserAgent = window.navigator.userAgent
const mountArrivalPanel = () => mount(ArrivalPanel, {
props: {
visible: true,
activeType: 'bus',
targets: [selectedTarget],
selectedTargetId: selectedTarget.id,
selectedTarget
}
})
beforeEach(() => {
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 MicroMessenger/8.0.50 miniProgram'
})
Object.assign(window, {
__wxjs_environment: 'miniprogram',
wx: {
miniProgram: {
navigateTo: vi.fn()
}
}
})
vi.stubGlobal('uni', {
showToast: vi.fn()
})
})
afterEach(() => {
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: defaultUserAgent
})
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
delete (window as Window & { wx?: unknown }).wx
vi.unstubAllGlobals()
})
describe('来馆面板 H5 实际渲染', () => {
it('主按钮显示第三方导航,点击后展示三家地图选择层', async () => {
const wrapper = mountArrivalPanel()
const navigateTo = (
window as Window & {
wx?: { miniProgram?: { navigateTo?: ReturnType<typeof vi.fn> } }
}
).wx?.miniProgram?.navigateTo
expect(wrapper.get('.arrival-primary-text').text()).toBe('第三方导航')
expect(wrapper.text()).not.toContain('打开地图导航')
await wrapper.get('.arrival-primary').trigger('tap')
expect(wrapper.find('.provider-sheet').exists()).toBe(true)
expect(wrapper.findAll('.provider-row')).toHaveLength(3)
expect(wrapper.text()).toContain('高德地图')
expect(wrapper.text()).toContain('百度地图')
expect(wrapper.text()).toContain('腾讯地图')
expect(navigateTo).not.toHaveBeenCalled()
})
it('诊断信息默认隐藏,仅在 debug-build=1 时显示 H5 构建边界', () => {
const normalWrapper = mountArrivalPanel()
expect(normalWrapper.find('[data-testid="arrival-build-diagnostics"]').exists()).toBe(false)
normalWrapper.unmount()
window.location.hash = '#/pages/index/index?tab=guide&debug-build=1'
const debugWrapper = mountArrivalPanel()
const diagnostics = debugWrapper.get('[data-testid="arrival-build-diagnostics"]')
expect(diagnostics.text()).toContain('构建 IDtest-build')
expect(diagnostics.text()).toContain('Git committest-commit')
expect(diagnostics.text()).toContain('构建平台h5')
expect(diagnostics.text()).toContain('navigationModethird-party-providers')
expect(diagnostics.text()).toContain('location.href')
expect(diagnostics.text()).toContain('UA')
})
})

View File

@@ -1,230 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/hostEnvironment', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/hostEnvironment')>()
return {
...actual,
isNativeWechatMiniProgram: () => false
}
})
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildThirdPartyMapSearchUri,
openWechatMiniProgramLocation
openThirdPartyMapSearch
} from '@/services/ThirdPartyMapSearchService'
const target = {
latitude: 22.604321,
longitude: 114.058765,
name: '深圳自然博物馆',
address: '深圳市龙华区民治街道'
}
const createWindow = (overrides: Record<string, unknown> = {}) => ({
location: { search: '', hash: '' },
navigator: { userAgent: 'Mozilla/5.0 MicroMessenger/8.0.50' },
...overrides
})
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('document', undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('微信小程序 web-view 地图桥接', () => {
it('桥接延迟注入时快速调用两次也只跳转一次', async () => {
const navigateTo = vi.fn((options: { success?: () => void }) => options.success?.())
const testWindow = createWindow()
vi.stubGlobal('window', testWindow)
vi.stubGlobal('uni', { showToast: vi.fn() })
setTimeout(() => {
Object.assign(testWindow, {
__wxjs_environment: 'miniprogram',
wx: { miniProgram: { navigateTo } }
})
}, 100)
const first = openWechatMiniProgramLocation(target)
const second = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(150)
await expect(first).resolves.toEqual({ status: 'opened' })
await expect(second).resolves.toEqual({ status: 'opened' })
expect(navigateTo).toHaveBeenCalledTimes(1)
const url = navigateTo.mock.calls[0]?.[0].url
const params = new URLSearchParams(url.split('?')[1])
expect(Object.fromEntries(params)).toEqual({
latitude: String(target.latitude),
longitude: String(target.longitude),
name: target.name,
address: target.address
describe('H5 第三方地图服务', () => {
it('安全编码中文、空格和特殊字符', () => {
const uri = buildThirdPartyMapSearchUri('amap', {
keyword: '深圳自然博物馆 A&B',
region: '深圳 市'
})
expect(uri).toContain('keyword=%E6%B7%B1%E5%9C%B3%E8%87%AA%E7%84%B6%E5%8D%9A%E7%89%A9%E9%A6%86%20A%26B')
expect(uri).toContain('city=%E6%B7%B1%E5%9C%B3%20%E5%B8%82')
})
it('普通浏览器返回 H5 分支并保留第三方地图 URI', async () => {
const showToast = vi.fn()
vi.stubGlobal('window', createWindow({
navigator: { userAgent: 'Mozilla/5.0 Chrome/126.0' }
}))
vi.stubGlobal('uni', { showToast })
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'not-embedded' })
expect(showToast).not.toHaveBeenCalled()
expect(buildThirdPartyMapSearchUri('amap', {
it.each([
['amap', 'https://uri.amap.com/search?'],
['baidu', 'https://api.map.baidu.com/place/search?'],
['tencent', 'https://apis.map.qq.com/uri/v1/search?']
] as const)('%s 使用对应的地图网页入口', (provider, expectedPrefix) => {
expect(buildThirdPartyMapSearchUri(provider, {
keyword: '深圳自然博物馆',
region: '深圳市'
})).toContain('https://uri.amap.com/search?')
})).toMatch(expectedPrefix)
})
it('显式宿主环境缺少桥接时显示错误提示', async () => {
it('浏览器中直接跳转到选中的第三方地图', () => {
const location = { href: 'https://guide.example.com/' }
vi.stubGlobal('window', { location })
openThirdPartyMapSearch('tencent', {
keyword: '深圳自然博物馆',
region: '深圳市'
})
expect(location.href).toContain('https://apis.map.qq.com/uri/v1/search?')
})
it('缺少浏览器环境时提供可见提示', () => {
const showToast = vi.fn()
vi.stubGlobal('window', createWindow({
location: { search: '?embedded=weapp', hash: '' }
}))
vi.stubGlobal('window', undefined)
vi.stubGlobal('uni', { showToast })
const result = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(2600)
openThirdPartyMapSearch('baidu', {
keyword: '深圳自然博物馆',
region: '深圳市'
})
await expect(result).resolves.toEqual({ status: 'failed', reason: 'bridge-unavailable' })
expect(showToast).toHaveBeenCalledWith({
title: '小程序地图桥接加载失败',
icon: 'none'
})
})
it('微信 SDK 首次加载失败后再次点击会重新创建脚本', async () => {
const showToast = vi.fn()
let currentScript: Record<string, unknown> | null = null
const appendChild = vi.fn((script: Record<string, unknown>) => {
currentScript = script
setTimeout(() => (script.onerror as (() => void) | undefined)?.(), 0)
})
vi.stubGlobal('window', createWindow({
location: { search: '?embedded=weapp', hash: '' }
}))
vi.stubGlobal('document', {
querySelector: () => currentScript,
createElement: () => {
const script: Record<string, unknown> = { dataset: {} }
script.remove = () => {
if (currentScript === script) currentScript = null
}
return script
},
head: { appendChild }
})
vi.stubGlobal('uni', { showToast })
const first = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(2600)
await expect(first).resolves.toEqual({ status: 'failed', reason: 'bridge-unavailable' })
const second = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(2600)
await expect(second).resolves.toEqual({ status: 'failed', reason: 'bridge-unavailable' })
expect(appendChild).toHaveBeenCalledTimes(2)
})
it('宿主页跳转失败时给出缺页提示', async () => {
const showToast = vi.fn()
const navigateTo = vi.fn((options: { fail?: (error: unknown) => void }) => {
options.fail?.({ errMsg: 'navigateTo:fail page not found' })
})
vi.stubGlobal('window', createWindow({
location: { search: '?embedded=weapp', hash: '' },
wx: { miniProgram: { navigateTo } }
}))
vi.stubGlobal('uni', { showToast })
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({
status: 'failed',
reason: 'navigation-failed'
})
expect(showToast).toHaveBeenCalledWith({
title: '宿主小程序未配置地图页',
icon: 'none'
})
})
it('宿主页没有回调时超时返回并显示提示', async () => {
const showToast = vi.fn()
let navigateOptions: { fail?: (error: unknown) => void } | undefined
vi.stubGlobal('window', createWindow({
location: { search: '?embedded=weapp', hash: '' },
wx: {
miniProgram: {
navigateTo: vi.fn((options: { fail?: (error: unknown) => void }) => {
navigateOptions = options
})
}
}
}))
vi.stubGlobal('uni', { showToast })
const result = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(3100)
await expect(result).resolves.toEqual({ status: 'failed', reason: 'navigation-failed' })
expect(showToast).toHaveBeenCalledWith({
title: '宿主地图页响应超时',
icon: 'none'
})
navigateOptions?.fail?.({ errMsg: 'navigateTo:fail page not found' })
expect(showToast).toHaveBeenCalledTimes(1)
})
it('非缺页类宿主跳转失败使用通用提示', async () => {
const showToast = vi.fn()
vi.stubGlobal('window', createWindow({
location: { search: '?embedded=weapp', hash: '' },
wx: {
miniProgram: {
navigateTo: vi.fn((options: { fail?: (error: unknown) => void }) => {
options.fail?.({ errMsg: 'navigateTo:fail page stack full' })
})
}
}
}))
vi.stubGlobal('uni', { showToast })
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({
status: 'failed',
reason: 'navigation-failed'
})
expect(showToast).toHaveBeenCalledWith({
title: '宿主地图页跳转失败',
icon: 'none'
})
})
it('宿主桥接同步抛错时显示调用失败提示', async () => {
const showToast = vi.fn()
vi.stubGlobal('window', createWindow({
location: { search: '?embedded=weapp', hash: '' },
wx: {
miniProgram: {
navigateTo: vi.fn(() => {
throw new Error('bridge unavailable')
})
}
}
}))
vi.stubGlobal('uni', { showToast })
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({
status: 'failed',
reason: 'navigation-failed'
})
expect(showToast).toHaveBeenCalledWith({
title: '宿主地图桥接调用失败',
title: '请在 H5 浏览器中打开地图',
icon: 'none'
})
})

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
getArrivalNavigationActionText,
resolveArrivalNavigationMode
} from '@/components/navigation/arrivalNavigationPolicy'
describe('来馆导航入口策略', () => {
it('普通 H5 与小程序 web-view 都使用第三方地图选择层', () => {
const mode = resolveArrivalNavigationMode(false)
expect(mode).toBe('third-party-providers')
expect(getArrivalNavigationActionText(mode, false)).toBe('第三方导航')
})
it('只有原生微信小程序使用微信位置能力', () => {
const mode = resolveArrivalNavigationMode(true)
expect(mode).toBe('native-location')
expect(getArrivalNavigationActionText(mode, false)).toBe('打开地图导航')
expect(getArrivalNavigationActionText(mode, true)).toBe('正在打开...')
})
})

View File

@@ -1,3 +1,4 @@
import { execFileSync } from 'node:child_process'
import { defineConfig, loadEnv } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
@@ -6,8 +7,28 @@ const normalizeEnvUrl = (value: string | undefined, fallback: string) => {
return normalized || fallback
}
const resolveGitCommit = () => {
try {
return execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
} catch {
return 'unknown'
}
}
const createBuildTimestamp = () => (
new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
)
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const gitCommit = normalizeEnvUrl(env.VITE_APP_GIT_COMMIT, resolveGitCommit())
const buildId = normalizeEnvUrl(
env.VITE_APP_BUILD_ID,
`${gitCommit}-${createBuildTimestamp()}`
)
const buildPlatform = process.env.UNI_PLATFORM === 'mp-weixin' ? 'mp-weixin' : 'h5'
const appApiProxyTarget = normalizeEnvUrl(env.DEV_PROXY_APP_API_TARGET, 'http://localhost:3001')
const h5SdkProxyTarget = normalizeEnvUrl(env.DEV_PROXY_H5_SDK_TARGET, appApiProxyTarget)
const sdkProxyTarget = normalizeEnvUrl(env.DEV_PROXY_SDK_TARGET, appApiProxyTarget)
@@ -18,6 +39,11 @@ export default defineConfig(({ mode }) => {
return {
base: normalizeEnvUrl(env.VITE_APP_PUBLIC_BASE, '/'),
plugins: [uni()],
define: {
__APP_BUILD_ID__: JSON.stringify(buildId),
__APP_GIT_COMMIT__: JSON.stringify(gitCommit),
__APP_BUILD_PLATFORM__: JSON.stringify(buildPlatform)
},
server: {
host: '0.0.0.0',
proxy: {

View File

@@ -1,7 +1,22 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => ['view', 'text', 'scroll-view'].includes(tag)
}
}
})
],
define: {
__APP_BUILD_ID__: JSON.stringify('test-build'),
__APP_GIT_COMMIT__: JSON.stringify('test-commit'),
__APP_BUILD_PLATFORM__: JSON.stringify('h5')
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))