85
doc/test-reports/h5-third-party-navigation-2026-07-13.md
Normal file
85
doc/test-reports/h5-third-party-navigation-2026-07-13.md
Normal file
@@ -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`
|
||||||
|
|
||||||
@@ -77,41 +77,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- #ifndef MP-WEIXIN -->
|
|
||||||
<view
|
|
||||||
v-if="providerSheetVisible && selectedTarget"
|
|
||||||
class="provider-scrim"
|
|
||||||
@tap="closeProviderSheet"
|
|
||||||
></view>
|
|
||||||
<view
|
|
||||||
v-if="providerSheetVisible && selectedTarget"
|
|
||||||
class="provider-sheet"
|
|
||||||
@tap.stop
|
|
||||||
>
|
|
||||||
<view class="provider-header">
|
|
||||||
<view class="provider-title-group">
|
|
||||||
<text class="provider-kicker">选择地图</text>
|
|
||||||
<text class="provider-title">{{ selectedTarget.title }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="provider-close" @tap="closeProviderSheet">
|
|
||||||
<text class="provider-close-text">×</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-for="provider in mapProviders"
|
|
||||||
:key="provider.provider"
|
|
||||||
class="provider-row"
|
|
||||||
@tap="handleProviderSelect(provider)"
|
|
||||||
>
|
|
||||||
<view class="provider-mark">
|
|
||||||
<text class="provider-mark-text">{{ provider.label.slice(0, 1) }}</text>
|
|
||||||
</view>
|
|
||||||
<text class="provider-label">{{ provider.label }}</text>
|
|
||||||
<text class="provider-action">打开</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<!-- #endif -->
|
|
||||||
|
|
||||||
<!-- #ifdef H5 -->
|
<!-- #ifdef H5 -->
|
||||||
<view
|
<view
|
||||||
v-if="buildDiagnostics"
|
v-if="buildDiagnostics"
|
||||||
@@ -138,11 +103,7 @@ import {
|
|||||||
type ArrivalTargetType
|
type ArrivalTargetType
|
||||||
} from '@/data/arrivalSearchTargets'
|
} from '@/data/arrivalSearchTargets'
|
||||||
// #ifndef MP-WEIXIN
|
// #ifndef MP-WEIXIN
|
||||||
import {
|
import { navigateToMuseum } from '@/services/MapNavigationService'
|
||||||
openThirdPartyMapSearch,
|
|
||||||
THIRD_PARTY_MAP_PROVIDERS,
|
|
||||||
type ThirdPartyMapProviderOption
|
|
||||||
} from '@/services/ThirdPartyMapSearchService'
|
|
||||||
// #endif
|
// #endif
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
import { openWechatHostLocation } from '@/services/WechatOpenLocationService'
|
import { openWechatHostLocation } from '@/services/WechatOpenLocationService'
|
||||||
@@ -172,15 +133,11 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const typeOptions = computed(() => ARRIVAL_TARGET_TYPES)
|
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'
|
const navigationMode: ArrivalNavigationMode = __APP_BUILD_PLATFORM__ === 'mp-weixin'
|
||||||
? 'native-location'
|
? 'native-location'
|
||||||
: 'third-party-providers'
|
: 'third-party-providers'
|
||||||
const arrivalPanelRef = ref<unknown>(null)
|
const arrivalPanelRef = ref<unknown>(null)
|
||||||
const isCollapsed = ref(false)
|
const isCollapsed = ref(false)
|
||||||
const providerSheetVisible = ref(false)
|
|
||||||
const isOpeningNavigation = ref(false)
|
const isOpeningNavigation = ref(false)
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
@@ -236,12 +193,10 @@ const activeTypeLabel = computed(() => (
|
|||||||
|
|
||||||
const handleTypeChange = (type: ArrivalTargetType) => {
|
const handleTypeChange = (type: ArrivalTargetType) => {
|
||||||
if (type === props.activeType) return
|
if (type === props.activeType) return
|
||||||
closeProviderSheet()
|
|
||||||
emit('update:activeType', type)
|
emit('update:activeType', type)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTargetSelect = (target: ArrivalSearchTarget) => {
|
const handleTargetSelect = (target: ArrivalSearchTarget) => {
|
||||||
closeProviderSheet()
|
|
||||||
emit('selectTarget', target)
|
emit('selectTarget', target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,10 +210,6 @@ const expandPanel = () => {
|
|||||||
emit('collapsedChange', false)
|
emit('collapsedChange', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeProviderSheet = () => {
|
|
||||||
providerSheetVisible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const getArrivalPanelElement = () => {
|
const getArrivalPanelElement = () => {
|
||||||
const rawRef = arrivalPanelRef.value
|
const rawRef = arrivalPanelRef.value
|
||||||
if (rawRef instanceof HTMLElement) return rawRef
|
if (rawRef instanceof HTMLElement) return rawRef
|
||||||
@@ -325,7 +276,12 @@ const handleNavigateTap = async () => {
|
|||||||
|
|
||||||
// #ifndef MP-WEIXIN
|
// #ifndef MP-WEIXIN
|
||||||
if (__APP_BUILD_PLATFORM__ !== 'mp-weixin') {
|
if (__APP_BUILD_PLATFORM__ !== 'mp-weixin') {
|
||||||
providerSheetVisible.value = true
|
isOpeningNavigation.value = true
|
||||||
|
try {
|
||||||
|
await navigateToMuseum()
|
||||||
|
} finally {
|
||||||
|
isOpeningNavigation.value = false
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
@@ -360,18 +316,6 @@ const handleNavigateTap = async () => {
|
|||||||
// #endif
|
// #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(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
@@ -380,7 +324,6 @@ watch(
|
|||||||
emit('collapsedChange', false)
|
emit('collapsedChange', false)
|
||||||
startLayoutObserver()
|
startLayoutObserver()
|
||||||
} else {
|
} else {
|
||||||
closeProviderSheet()
|
|
||||||
isCollapsed.value = false
|
isCollapsed.value = false
|
||||||
disconnectLayoutObserver()
|
disconnectLayoutObserver()
|
||||||
emit('collapsedChange', false)
|
emit('collapsedChange', false)
|
||||||
@@ -389,13 +332,6 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.selectedTargetId,
|
|
||||||
() => {
|
|
||||||
closeProviderSheet()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.targets.length, isCollapsed.value] as const,
|
() => [props.targets.length, isCollapsed.value] as const,
|
||||||
() => {
|
() => {
|
||||||
@@ -701,116 +637,6 @@ defineExpose({
|
|||||||
color: #e0e100;
|
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 {
|
.arrival-build-diagnostics {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(env(safe-area-inset-top) + 8px);
|
top: calc(env(safe-area-inset-top) + 8px);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 地图导航调起服务
|
* 地图导航调起服务
|
||||||
* 使用 uni.openLocation 调起设备地图 App 进行导航
|
* 使用原生地图 Scheme 调起设备地图 App 进行导航
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MUSEUM_LOCATION } from '@/config/museum'
|
import { MUSEUM_LOCATION } from '@/config/museum'
|
||||||
@@ -18,6 +18,62 @@ export interface NavigationOptions {
|
|||||||
scale?: number
|
scale?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NavigationTarget = Pick<NavigationOptions, 'latitude' | 'longitude' | 'name'>
|
||||||
|
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 导航选项
|
* @param options 导航选项
|
||||||
@@ -25,42 +81,27 @@ export interface NavigationOptions {
|
|||||||
*/
|
*/
|
||||||
export function openExternalNavigation(options: NavigationOptions): Promise<boolean> {
|
export function openExternalNavigation(options: NavigationOptions): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
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({
|
resolve(openNativeMapScheme(url))
|
||||||
latitude: options.latitude,
|
|
||||||
longitude: options.longitude,
|
|
||||||
name: options.name,
|
|
||||||
address: options.address ?? '',
|
|
||||||
scale,
|
|
||||||
success: () => {
|
|
||||||
console.log('成功调起地图导航:', options.name)
|
|
||||||
resolve(true)
|
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
console.error('调起地图导航失败:', err)
|
if (err.errMsg?.includes('cancel')) {
|
||||||
|
|
||||||
// 根据错误类型提供友好的提示
|
|
||||||
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 {
|
|
||||||
resolve(false)
|
resolve(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.error('打开地图选择失败:', err)
|
||||||
|
uni.showToast({
|
||||||
|
title: '无法打开地图选择',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
resolve(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ beforeEach(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
vi.stubGlobal('uni', {
|
vi.stubGlobal('uni', {
|
||||||
showToast: vi.fn()
|
showToast: vi.fn(),
|
||||||
|
showActionSheet: vi.fn()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,8 +49,11 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('来馆面板 H5 实际渲染', () => {
|
describe('来馆面板 H5 实际渲染', () => {
|
||||||
it('主按钮显示第三方导航,点击后展示三家地图选择层', async () => {
|
it('主按钮显示第三方导航,点击后弹出百度和高德选择框', async () => {
|
||||||
const wrapper = mountArrivalPanel()
|
const wrapper = mountArrivalPanel()
|
||||||
|
const showActionSheet = (uni as unknown as {
|
||||||
|
showActionSheet: ReturnType<typeof vi.fn>
|
||||||
|
}).showActionSheet
|
||||||
const navigateTo = (
|
const navigateTo = (
|
||||||
window as Window & {
|
window as Window & {
|
||||||
wx?: { miniProgram?: { navigateTo?: ReturnType<typeof vi.fn> } }
|
wx?: { miniProgram?: { navigateTo?: ReturnType<typeof vi.fn> } }
|
||||||
@@ -60,11 +64,10 @@ describe('来馆面板 H5 实际渲染', () => {
|
|||||||
expect(wrapper.text()).not.toContain('打开地图导航')
|
expect(wrapper.text()).not.toContain('打开地图导航')
|
||||||
await wrapper.get('.arrival-primary').trigger('tap')
|
await wrapper.get('.arrival-primary').trigger('tap')
|
||||||
|
|
||||||
expect(wrapper.find('.provider-sheet').exists()).toBe(true)
|
expect(showActionSheet).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
expect(wrapper.findAll('.provider-row')).toHaveLength(3)
|
itemList: ['百度地图', '高德地图']
|
||||||
expect(wrapper.text()).toContain('高德地图')
|
}))
|
||||||
expect(wrapper.text()).toContain('百度地图')
|
expect(wrapper.find('.provider-sheet').exists()).toBe(false)
|
||||||
expect(wrapper.text()).toContain('腾讯地图')
|
|
||||||
expect(navigateTo).not.toHaveBeenCalled()
|
expect(navigateTo).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
133
tests/unit/MapNavigationService.spec.ts
Normal file
133
tests/unit/MapNavigationService.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user