From 140f13633ee29f489b3bbd43e63392704c112b47 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Mon, 13 Jul 2026 16:56:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E9=80=A0=20H5=20=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E6=96=B9=E5=9C=B0=E5=9B=BE=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../h5-third-party-navigation-2026-07-13.md | 85 ++++++++ src/components/navigation/ArrivalPanel.vue | 188 +----------------- src/services/MapNavigationService.ts | 105 +++++++--- tests/unit/ArrivalPanel.spec.ts | 17 +- tests/unit/MapNavigationService.spec.ts | 133 +++++++++++++ 5 files changed, 308 insertions(+), 220 deletions(-) create mode 100644 doc/test-reports/h5-third-party-navigation-2026-07-13.md create mode 100644 tests/unit/MapNavigationService.spec.ts diff --git a/doc/test-reports/h5-third-party-navigation-2026-07-13.md b/doc/test-reports/h5-third-party-navigation-2026-07-13.md new file mode 100644 index 0000000..c0be5e2 --- /dev/null +++ b/doc/test-reports/h5-third-party-navigation-2026-07-13.md @@ -0,0 +1,85 @@ +# H5 第三方导航改造测试报告 + +- 日期:2026-07-13 +- 范围:H5“来馆 -> 第三方导航”百度/高德原生 Scheme 调起 +- 目的地:深圳自然博物馆 +- 坐标:GCJ-02 `22.692763, 114.363572`,直接复用 `MUSEUM_LOCATION` + +## 实施结果 + +1. `navigateToMuseum` 与页面现有调用接口保持不变。 +2. 点击“第三方导航”后调用 `uni.showActionSheet`,选项精确为“百度地图”“高德地图”。 +3. 百度使用 `baidumap://`;高德按平台使用 iOS `iosamap://`、Android `androidamap://`。 +4. 百度 URL 显式携带 `coord_type=gcj02`;高德 URL 使用 `dev=0` 表示已偏移的 GCJ-02 坐标。 +5. 中文目的地名称经过 URL 编码。 +6. 用户取消选择时静默返回 `false`,不显示错误提示。 +7. 未使用 `uni.openLocation`,未接入腾讯路线规划插件,未使用或新增腾讯位置服务 Key,未增加中转页面,未修改 `index.vue` 或宿主小程序代码。 +8. 宿主小程序原有 `openWechatHostLocation` 分支未改动。 + +## TDD 证据 + +RED 阶段按行为逐项确认失败: + +- 百度 URL 测试:`buildBaiduNavigationUrl is not a function`。 +- iOS 高德 URL 测试:`buildAmapNavigationUrl is not a function`。 +- Android 高德 URL 测试:返回空字符串,不匹配 `androidamap://route?`。 +- Action Sheet 测试:旧实现调用 `uni.openLocation`,失败为 `uni.openLocation is not a function`。 +- 百度标准目的地编码测试:旧 URL 未把 `latlng:...|name:...` 合并编码到 `destination` 参数。 + +GREEN 阶段: + +```powershell +pnpm exec vitest run tests/unit/MapNavigationService.spec.ts +``` + +结果:`1 file / 7 tests passed`。覆盖百度 GCJ-02、iOS/Android 高德 Scheme、中文目的地编码、百度选择跳转、iPhone 高德选择跳转、取消静默返回。 + +## 已自动验证 + +| 验证项 | 命令或方式 | 结果 | +| --- | --- | --- | +| 导航服务与页面接线单测 | `pnpm exec vitest run tests/unit/MapNavigationService.spec.ts tests/unit/ArrivalPanel.spec.ts` | 通过,`2 files / 9 tests` | +| 全量单元测试 | `pnpm test:unit` | 通过,`14 files / 81 tests` | +| TypeScript 检查 | `pnpm type-check` | 通过,退出码 0,零错误 | +| ESLint | `pnpm lint` | 通过,0 errors;1 个既有无关 warning:`src/pages/explain/list.vue:72` 的 `explainBusinessUnitItems` 未使用 | +| H5 生产构建 | `pnpm build:h5` | 通过,`DONE Build complete`,静态资源复制成功 | +| 浏览器交互 | 生产构建静态服务 `http://127.0.0.1:4173/` | 真实点击“来馆 -> 第三方导航”后显示“百度地图”“高德地图” | +| 旧选择层清理 | 浏览器 DOM 检查 | `.provider-sheet` 数量为 0 | +| 取消行为 | Vitest | 返回 `false` 且未调用 `showToast` | + +构建存在既有 HarmonyOS 字体解析提示和 Sass deprecation warnings,均非错误。浏览器静态服务缺少 API 代理,出现既有 SGS 404 和腾讯地图鉴权日志,但“来馆”按钮与 Action Sheet 正常,不影响本次验证。 + +桌面浏览器仅验证选择框,没有选择原生 Scheme,避免在没有地图 App 的环境中把调起结果误判为真机结果。 + +## 需要宿主小程序真机验证 + +以下项目必须在承载 H5 的宿主小程序 web-view 中验证。每次测试先进入“来馆”,点击“第三方导航”,确认只显示百度和高德两项。 + +| 设备 | 地图 App 状态 | 操作 | 期望结果 | +| --- | --- | --- | --- | +| Android | 已安装百度地图 | 选择“百度地图” | 通过 `baidumap://` 打开百度地图,目的地为深圳自然博物馆,坐标落点为 `22.692763,114.363572` | +| Android | 未安装百度地图 | 选择“百度地图” | 系统/宿主提示无法打开或停留在 H5;返回后页面可继续操作,不应白屏或卡死 | +| Android | 已安装高德地图 | 选择“高德地图” | 通过 `androidamap://` 打开高德地图,显示到深圳自然博物馆的驾车路线 | +| Android | 未安装高德地图 | 选择“高德地图” | 系统/宿主提示无法打开或停留在 H5;返回后可改选百度地图 | +| iPhone | 已安装百度地图 | 选择“百度地图” | 通过 `baidumap://` 打开百度地图,目的地名称和 GCJ-02 落点正确 | +| iPhone | 未安装百度地图 | 选择“百度地图” | iOS/宿主提示无法打开或停留在 H5;返回后页面状态正常 | +| iPhone | 已安装高德地图 | 选择“高德地图” | 通过 `iosamap://` 打开高德地图,显示到深圳自然博物馆的驾车路线 | +| iPhone | 未安装高德地图 | 选择“高德地图” | iOS/宿主提示无法打开或停留在 H5;返回后可改选百度地图 | + +每个平台还需补充验证: + +1. 在 Action Sheet 上直接取消,页面无 toast、无跳转、可再次点击。 +2. 从地图 App 返回宿主小程序后,H5 页面没有重载异常,来馆面板仍可操作。 +3. 宿主 web-view 安全策略允许对应自定义 Scheme;若被宿主拦截,应记录手机系统、宿主版本、微信版本和拦截表现。 +4. 目的地名称显示为“深圳自然博物馆”,地图落点与现有 GCJ-02 坐标一致,没有发生二次坐标转换偏移。 + +未安装 App 时的具体系统提示由 Android/iOS、微信和宿主 web-view 决定。本次批准范围不包含 App 安装检测、应用商店回退或中转页,因此真机验收以“页面不白屏、不锁死、返回后可重试”为最低通过条件。 + +## 最终修改文件 + +- `src/services/MapNavigationService.ts` +- `tests/unit/MapNavigationService.spec.ts` +- `src/components/navigation/ArrivalPanel.vue` +- `tests/unit/ArrivalPanel.spec.ts` +- `doc/test-reports/h5-third-party-navigation-2026-07-13.md` + diff --git a/src/components/navigation/ArrivalPanel.vue b/src/components/navigation/ArrivalPanel.vue index 5f9cf25..b6de32e 100644 --- a/src/components/navigation/ArrivalPanel.vue +++ b/src/components/navigation/ArrivalPanel.vue @@ -77,41 +77,6 @@ - - - - - - 选择地图 - {{ selectedTarget.title }} - - - × - - - - - {{ provider.label.slice(0, 1) }} - - {{ provider.label }} - 打开 - - - - () const typeOptions = computed(() => ARRIVAL_TARGET_TYPES) -// #ifndef MP-WEIXIN -const mapProviders = computed(() => THIRD_PARTY_MAP_PROVIDERS) -// #endif const navigationMode: ArrivalNavigationMode = __APP_BUILD_PLATFORM__ === 'mp-weixin' ? 'native-location' : 'third-party-providers' const arrivalPanelRef = ref(null) const isCollapsed = ref(false) -const providerSheetVisible = ref(false) const isOpeningNavigation = ref(false) let resizeObserver: ResizeObserver | null = null @@ -236,12 +193,10 @@ const activeTypeLabel = computed(() => ( const handleTypeChange = (type: ArrivalTargetType) => { if (type === props.activeType) return - closeProviderSheet() emit('update:activeType', type) } const handleTargetSelect = (target: ArrivalSearchTarget) => { - closeProviderSheet() emit('selectTarget', target) } @@ -255,10 +210,6 @@ const expandPanel = () => { emit('collapsedChange', false) } -const closeProviderSheet = () => { - providerSheetVisible.value = false -} - const getArrivalPanelElement = () => { const rawRef = arrivalPanelRef.value if (rawRef instanceof HTMLElement) return rawRef @@ -325,7 +276,12 @@ const handleNavigateTap = async () => { // #ifndef MP-WEIXIN if (__APP_BUILD_PLATFORM__ !== 'mp-weixin') { - providerSheetVisible.value = true + isOpeningNavigation.value = true + try { + await navigateToMuseum() + } finally { + isOpeningNavigation.value = false + } return } // #endif @@ -360,18 +316,6 @@ const handleNavigateTap = async () => { // #endif } -// #ifndef MP-WEIXIN -const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => { - const target = props.selectedTarget - if (!target) return - - openThirdPartyMapSearch(provider.provider, { - keyword: target.keyword, - region: target.region - }) -} -// #endif - watch( () => props.visible, (visible) => { @@ -380,7 +324,6 @@ watch( emit('collapsedChange', false) startLayoutObserver() } else { - closeProviderSheet() isCollapsed.value = false disconnectLayoutObserver() emit('collapsedChange', false) @@ -389,13 +332,6 @@ watch( } ) -watch( - () => props.selectedTargetId, - () => { - closeProviderSheet() - } -) - watch( () => [props.targets.length, isCollapsed.value] as const, () => { @@ -701,116 +637,6 @@ defineExpose({ color: #e0e100; } -.provider-scrim { - position: fixed; - inset: 0; - z-index: 2190; - background: rgba(0, 0, 0, 0.28); -} - -.provider-sheet { - position: fixed; - left: 50%; - bottom: calc(env(safe-area-inset-bottom) + 12px); - z-index: 2200; - width: min(430px, calc(100vw - 24px)); - padding: 14px; - box-sizing: border-box; - background: #ffffff; - border-radius: 8px; - box-shadow: 0 12px 36px rgba(31, 35, 41, 0.22); - transform: translateX(-50%); -} - -.provider-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; -} - -.provider-title-group { - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} - -.provider-kicker { - font-size: 12px; - line-height: 16px; - font-weight: 600; - color: #696962; -} - -.provider-title { - font-size: 16px; - line-height: 22px; - font-weight: 700; - color: #262421; -} - -.provider-close { - width: 32px; - height: 32px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - background: #f3f3f3; - border-radius: 8px; -} - -.provider-close-text { - font-size: 22px; - line-height: 26px; - color: #424754; -} - -.provider-row { - min-height: 52px; - display: flex; - align-items: center; - gap: 10px; - padding: 9px 0; - border-top: 1px solid #eceee7; -} - -.provider-mark { - width: 30px; - height: 30px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - background: #262421; - border-radius: 8px; -} - -.provider-mark-text { - font-size: 13px; - line-height: 18px; - font-weight: 700; - color: #e0e100; -} - -.provider-label { - flex: 1; - font-size: 15px; - line-height: 21px; - font-weight: 600; - color: #262421; -} - -.provider-action { - flex-shrink: 0; - font-size: 12px; - line-height: 17px; - font-weight: 600; - color: #696962; -} - .arrival-build-diagnostics { position: fixed; top: calc(env(safe-area-inset-top) + 8px); diff --git a/src/services/MapNavigationService.ts b/src/services/MapNavigationService.ts index 207b919..4e543ab 100644 --- a/src/services/MapNavigationService.ts +++ b/src/services/MapNavigationService.ts @@ -1,6 +1,6 @@ /** * 地图导航调起服务 - * 使用 uni.openLocation 调起设备地图 App 进行导航 + * 使用原生地图 Scheme 调起设备地图 App 进行导航 */ import { MUSEUM_LOCATION } from '@/config/museum' @@ -18,6 +18,62 @@ export interface NavigationOptions { scale?: number } +type NavigationTarget = Pick +export type MobilePlatform = 'ios' | 'android' + +const encode = (value: string) => encodeURIComponent(value) + +export function buildBaiduNavigationUrl(target: NavigationTarget): string { + const destination = encode( + `latlng:${target.latitude},${target.longitude}|name:${target.name}` + ) + + return `baidumap://map/direction?destination=${destination}&coord_type=gcj02&mode=driving&src=museum-guide` +} + +export function buildAmapNavigationUrl( + target: NavigationTarget, + platform: MobilePlatform +): string { + const scheme = platform === 'ios' ? 'iosamap://path' : 'androidamap://route' + + return `${scheme}?sourceApplication=${encode('深圳自然博物馆')}&dlat=${target.latitude}&dlon=${target.longitude}&dname=${encode(target.name)}&dev=0&t=0` +} + +const detectMobilePlatform = (): MobilePlatform => { + if (typeof window === 'undefined') { + return 'android' + } + + const { userAgent, platform, maxTouchPoints } = window.navigator + const isIos = /iPhone|iPad|iPod/i.test(userAgent) + || (platform === 'MacIntel' && maxTouchPoints > 1) + + return isIos ? 'ios' : 'android' +} + +const openNativeMapScheme = (url: string): boolean => { + if (typeof window === 'undefined' || !window.location) { + uni.showToast({ + title: '请在 H5 浏览器中打开地图', + icon: 'none' + }) + return false + } + + try { + window.location.href = url + return true + } catch (error) { + console.error('调起地图应用失败:', error) + uni.showToast({ + title: '无法调起地图应用', + icon: 'none' + }) + return false + } +} + /** * 调起外部地图应用进行导航 * @param options 导航选项 @@ -25,42 +81,27 @@ export interface NavigationOptions { */ export function openExternalNavigation(options: NavigationOptions): Promise { return new Promise((resolve) => { - const scale = options.scale ?? 18 + uni.showActionSheet({ + itemList: ['百度地图', '高德地图'], + success: ({ tapIndex }) => { + const url = tapIndex === 0 + ? buildBaiduNavigationUrl(options) + : buildAmapNavigationUrl(options, detectMobilePlatform()) - uni.openLocation({ - latitude: options.latitude, - longitude: options.longitude, - name: options.name, - address: options.address ?? '', - scale, - success: () => { - console.log('成功调起地图导航:', options.name) - resolve(true) + resolve(openNativeMapScheme(url)) }, fail: (err) => { - console.error('调起地图导航失败:', err) - - // 根据错误类型提供友好的提示 - if (err.errMsg) { - if (err.errMsg.includes('cancel')) { - // 用户取消 - resolve(false) - } else if (err.errMsg.includes('auth deny') || err.errMsg.includes('auth reject')) { - uni.showToast({ - title: '需要位置权限才能导航', - icon: 'none' - }) - resolve(false) - } else { - uni.showToast({ - title: '无法调起地图应用', - icon: 'none' - }) - resolve(false) - } - } else { + if (err.errMsg?.includes('cancel')) { resolve(false) + return } + + console.error('打开地图选择失败:', err) + uni.showToast({ + title: '无法打开地图选择', + icon: 'none' + }) + resolve(false) } }) }) diff --git a/tests/unit/ArrivalPanel.spec.ts b/tests/unit/ArrivalPanel.spec.ts index 01fb2e2..63fb990 100644 --- a/tests/unit/ArrivalPanel.spec.ts +++ b/tests/unit/ArrivalPanel.spec.ts @@ -33,7 +33,8 @@ beforeEach(() => { } }) vi.stubGlobal('uni', { - showToast: vi.fn() + showToast: vi.fn(), + showActionSheet: vi.fn() }) }) @@ -48,8 +49,11 @@ afterEach(() => { }) describe('来馆面板 H5 实际渲染', () => { - it('主按钮显示第三方导航,点击后展示三家地图选择层', async () => { + it('主按钮显示第三方导航,点击后弹出百度和高德选择框', async () => { const wrapper = mountArrivalPanel() + const showActionSheet = (uni as unknown as { + showActionSheet: ReturnType + }).showActionSheet const navigateTo = ( window as Window & { wx?: { miniProgram?: { navigateTo?: ReturnType } } @@ -60,11 +64,10 @@ describe('来馆面板 H5 实际渲染', () => { 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(showActionSheet).toHaveBeenCalledWith(expect.objectContaining({ + itemList: ['百度地图', '高德地图'] + })) + expect(wrapper.find('.provider-sheet').exists()).toBe(false) expect(navigateTo).not.toHaveBeenCalled() }) diff --git a/tests/unit/MapNavigationService.spec.ts b/tests/unit/MapNavigationService.spec.ts new file mode 100644 index 0000000..d7f99f0 --- /dev/null +++ b/tests/unit/MapNavigationService.spec.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildAmapNavigationUrl, + buildBaiduNavigationUrl, + openExternalNavigation +} from '@/services/MapNavigationService' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('H5 原生地图导航 URL', () => { + it('百度地图使用 GCJ-02 坐标参数', () => { + const url = buildBaiduNavigationUrl({ + latitude: 22.692763, + longitude: 114.363572, + name: '深圳自然博物馆' + }) + + expect(url).toContain('baidumap://map/direction?') + expect(url).toContain('destination=latlng%3A22.692763%2C114.363572') + expect(url).toContain('coord_type=gcj02') + }) + + it('iOS 高德地图使用 iosamap Scheme', () => { + const url = buildAmapNavigationUrl({ + latitude: 22.692763, + longitude: 114.363572, + name: '深圳自然博物馆' + }, 'ios') + + expect(url).toMatch(/^iosamap:\/\/path\?/) + expect(url).toContain('dlat=22.692763') + expect(url).toContain('dlon=114.363572') + expect(url).toContain('dev=0') + }) + + it('Android 高德地图使用 androidamap Scheme', () => { + const url = buildAmapNavigationUrl({ + latitude: 22.692763, + longitude: 114.363572, + name: '深圳自然博物馆' + }, 'android') + + expect(url).toMatch(/^androidamap:\/\/route\?/) + expect(url).toContain('dlat=22.692763') + expect(url).toContain('dlon=114.363572') + expect(url).toContain('dev=0') + }) + + it('中文目的地名称会进行 URL 编码', () => { + const encodedName = '%E6%B7%B1%E5%9C%B3%E8%87%AA%E7%84%B6%E5%8D%9A%E7%89%A9%E9%A6%86' + + expect(buildBaiduNavigationUrl({ + latitude: 22.692763, + longitude: 114.363572, + name: '深圳自然博物馆' + })).toContain(`destination=latlng%3A22.692763%2C114.363572%7Cname%3A${encodedName}`) + expect(buildAmapNavigationUrl({ + latitude: 22.692763, + longitude: 114.363572, + name: '深圳自然博物馆' + }, 'android')).toContain(`dname=${encodedName}`) + }) + + it('选择百度地图后直接打开原生 Scheme', async () => { + const location = { href: 'https://guide.example.com/' } + const showActionSheet = vi.fn(({ success }) => success({ tapIndex: 0 })) + vi.stubGlobal('window', { + location, + navigator: { userAgent: 'Mozilla/5.0 (Linux; Android 14)' } + }) + vi.stubGlobal('uni', { showActionSheet, showToast: vi.fn() }) + + const result = await openExternalNavigation({ + latitude: 22.692763, + longitude: 114.363572, + name: '深圳自然博物馆' + }) + + expect(showActionSheet).toHaveBeenCalledWith(expect.objectContaining({ + itemList: ['百度地图', '高德地图'] + })) + expect(location.href).toMatch(/^baidumap:\/\/map\/direction\?/) + expect(result).toBe(true) + }) + + it('iPhone 选择高德地图后打开 iosamap Scheme', async () => { + const location = { href: 'https://guide.example.com/' } + const showActionSheet = vi.fn(({ success }) => success({ tapIndex: 1 })) + vi.stubGlobal('window', { + location, + navigator: { + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', + platform: 'iPhone', + maxTouchPoints: 5 + } + }) + vi.stubGlobal('uni', { showActionSheet, showToast: vi.fn() }) + + const result = await openExternalNavigation({ + latitude: 22.692763, + longitude: 114.363572, + name: '深圳自然博物馆' + }) + + expect(location.href).toMatch(/^iosamap:\/\/path\?/) + expect(result).toBe(true) + }) + + it('取消地图选择时静默返回 false', async () => { + const location = { href: 'https://guide.example.com/' } + const showToast = vi.fn() + const showActionSheet = vi.fn(({ fail }) => fail({ + errMsg: 'showActionSheet:fail cancel' + })) + vi.stubGlobal('window', { + location, + navigator: { userAgent: 'Mozilla/5.0 (Linux; Android 14)' } + }) + vi.stubGlobal('uni', { showActionSheet, showToast }) + + const result = await openExternalNavigation({ + latitude: 22.692763, + longitude: 114.363572, + name: '深圳自然博物馆' + }) + + expect(result).toBe(false) + expect(location.href).toBe('https://guide.example.com/') + expect(showToast).not.toHaveBeenCalled() + }) +})