修复小程序内来馆地图导航
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-11 17:24:45 +08:00
parent 12c0b5ede2
commit f9fefa11ef
12 changed files with 1161 additions and 146 deletions

View File

@@ -69,7 +69,7 @@
<view
class="arrival-primary"
:class="{ disabled: !selectedTarget }"
:class="{ disabled: !selectedTarget || isOpeningNavigation }"
@tap="handleNavigateTap"
>
<text class="arrival-primary-text">{{ primaryActionText }}</text>
@@ -124,10 +124,6 @@ import {
THIRD_PARTY_MAP_PROVIDERS,
type ThirdPartyMapProviderOption
} from '@/services/ThirdPartyMapSearchService'
import {
isNativeWechatMiniProgram,
isWechatMiniProgramRuntime
} from '@/utils/hostEnvironment'
const props = withDefaults(defineProps<{
visible?: boolean
@@ -151,13 +147,13 @@ const emit = defineEmits<{
const typeOptions = computed(() => ARRIVAL_TARGET_TYPES)
const mapProviders = computed(() => THIRD_PARTY_MAP_PROVIDERS)
const shouldUseMiniProgramLocation = computed(() => isWechatMiniProgramRuntime())
const primaryActionText = computed(() => (
shouldUseMiniProgramLocation.value ? '打开地图导航' : '第三方导航'
isOpeningNavigation.value ? '正在打开...' : '打开地图导航'
))
const arrivalPanelRef = ref<unknown>(null)
const isCollapsed = ref(false)
const providerSheetVisible = ref(false)
const isOpeningNavigation = ref(false)
let resizeObserver: ResizeObserver | null = null
const activeTypeLabel = computed(() => (
@@ -243,6 +239,7 @@ const startLayoutObserver = () => {
}
const handleNavigateTap = async () => {
if (isOpeningNavigation.value) return
if (!props.selectedTarget) {
uni.showToast({
title: '请先选择点位',
@@ -257,14 +254,15 @@ const handleNavigateTap = async () => {
name: props.selectedTarget.title,
address: props.selectedTarget.subtitle
}
const isNativeMiniProgram = isNativeWechatMiniProgram()
const openedInMiniProgram = await openWechatMiniProgramLocation(target)
if (openedInMiniProgram || isNativeMiniProgram || isWechatMiniProgramRuntime()) {
return
isOpeningNavigation.value = true
try {
const result = await openWechatMiniProgramLocation(target)
if (result.status === 'not-embedded') {
providerSheetVisible.value = true
}
} finally {
isOpeningNavigation.value = false
}
providerSheetVisible.value = true
}
const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => {

View File

@@ -16,15 +16,13 @@
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
interface OpenLocationOptions {
latitude?: string
longitude?: string
name?: string
address?: string
}
import { openWechatHostLocation } from '@/services/WechatOpenLocationService'
import {
parseWechatOpenLocationPageOptions,
type WechatOpenLocationPageOptions
} from '@/utils/wechatOpenLocationProtocol'
const latitude = ref<number | null>(null)
const longitude = ref<number | null>(null)
@@ -32,6 +30,10 @@ const locationName = ref('')
const locationAddress = ref('')
const hasOpened = ref(false)
const hasError = ref(false)
const isOpening = ref(false)
const errorMessage = ref('')
let isPageActive = true
let autoBackTimer: ReturnType<typeof setTimeout> | null = null
const title = computed(() => {
if (hasError.value) return '地图打开失败'
@@ -40,22 +42,18 @@ const title = computed(() => {
})
const description = computed(() => {
if (hasError.value) return '请返回导览后重试,或检查小程序位置权限。'
if (hasError.value) return errorMessage.value || '请返回导览后重试,或检查小程序位置权限。'
if (locationName.value) return locationName.value
return '正在为你唤起地图位置页。'
})
const safeDecode = (value?: string) => {
if (!value) return ''
try {
return decodeURIComponent(value)
} catch {
return value
}
}
const goBack = () => {
if (!isPageActive) return
isPageActive = false
if (autoBackTimer) {
clearTimeout(autoBackTimer)
autoBackTimer = null
}
uni.navigateBack({
delta: 1,
fail: () => {
@@ -66,9 +64,11 @@ const goBack = () => {
})
}
const openLocation = () => {
const openLocation = async () => {
if (isOpening.value) return
if (latitude.value === null || longitude.value === null) {
hasError.value = true
errorMessage.value = '地图参数无效,请返回导览后重试。'
uni.showToast({
title: '缺少地图坐标',
icon: 'none'
@@ -76,42 +76,57 @@ const openLocation = () => {
return
}
uni.openLocation({
isOpening.value = true
const result = await openWechatHostLocation({
latitude: latitude.value,
longitude: longitude.value,
name: locationName.value,
address: locationAddress.value,
scale: 18,
success: () => {
hasOpened.value = true
hasError.value = false
setTimeout(goBack, 300)
},
fail: (error) => {
console.error('打开地图位置失败:', error)
hasError.value = true
uni.showToast({
title: '无法打开地图',
icon: 'none'
})
}
address: locationAddress.value
})
}
if (!isPageActive) return
isOpening.value = false
onLoad((options?: OpenLocationOptions) => {
latitude.value = Number(options?.latitude)
longitude.value = Number(options?.longitude)
locationName.value = safeDecode(options?.name) || '深圳自然博物馆'
locationAddress.value = safeDecode(options?.address)
if (!Number.isFinite(latitude.value) || !Number.isFinite(longitude.value)) {
latitude.value = null
longitude.value = null
hasError.value = true
if (result.status === 'opened') {
hasOpened.value = true
hasError.value = false
autoBackTimer = setTimeout(goBack, 300)
return
}
openLocation()
hasError.value = true
errorMessage.value = result.reason === 'timeout'
? '微信地图响应超时,请重试或返回导览。'
: '微信地图能力调用失败,请检查权限后重试。'
uni.showToast({
title: result.reason === 'timeout' ? '微信地图响应超时' : '无法打开地图',
icon: 'none'
})
}
onLoad((options?: WechatOpenLocationPageOptions) => {
isPageActive = true
const result = parseWechatOpenLocationPageOptions(options)
if (!result.ok) {
hasError.value = true
errorMessage.value = result.message
uni.showToast({
title: result.message,
icon: 'none'
})
return
}
latitude.value = result.value.latitude
longitude.value = result.value.longitude
locationName.value = result.value.name
locationAddress.value = result.value.address || ''
void openLocation()
})
onUnload(() => {
isPageActive = false
if (autoBackTimer) clearTimeout(autoBackTimer)
autoBackTimer = null
})
</script>

View File

@@ -3,6 +3,11 @@ import {
isEmbeddedInWechatMiniProgram,
isNativeWechatMiniProgram
} from '@/utils/hostEnvironment'
import {
buildWechatOpenLocationPageUrl,
validateWechatOpenLocationTarget,
type WechatOpenLocationTarget
} from '@/utils/wechatOpenLocationProtocol'
export type ThirdPartyMapProvider = 'amap' | 'baidu' | 'tencent'
@@ -11,12 +16,7 @@ export interface ThirdPartyMapSearchTarget {
region: string
}
export interface WechatMiniProgramLocationTarget {
latitude: number
longitude: number
name: string
address?: string
}
export type WechatMiniProgramLocationTarget = WechatOpenLocationTarget
interface WechatMiniProgramBridge {
getEnv?: (callback: (result: { miniprogram?: boolean }) => void) => void
@@ -27,7 +27,17 @@ interface WechatMiniProgramBridge {
}) => void
}
let wechatJsSdkLoadPromise: Promise<boolean> | null = null
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
@@ -82,18 +92,7 @@ export const openThirdPartyMapSearch = (
export const buildWechatMiniProgramOpenLocationUrl = (
target: WechatMiniProgramLocationTarget
) => {
const params = [
['latitude', String(target.latitude)],
['longitude', String(target.longitude)],
['name', target.name],
['address', target.address || '']
]
.map(([key, value]) => `${key}=${encode(value)}`)
.join('&')
return `/pages/open-location/index?${params}`
}
) => buildWechatOpenLocationPageUrl(target)
const getWechatMiniProgramBridge = (): WechatMiniProgramBridge | null => {
if (typeof window === 'undefined') return null
@@ -105,29 +104,35 @@ const getWechatMiniProgramBridge = (): WechatMiniProgramBridge | null => {
}).wx?.miniProgram || null
}
const loadWechatJsSdk = () => {
if (typeof document === 'undefined') return Promise.resolve(false)
if (getWechatMiniProgramBridge()) return Promise.resolve(true)
if (wechatJsSdkLoadPromise) return wechatJsSdkLoadPromise
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()
}
wechatJsSdkLoadPromise = new Promise<boolean>((resolve) => {
const existingScript = document.querySelector<HTMLScriptElement>('script[data-wechat-js-sdk="true"]')
if (existingScript) {
existingScript.addEventListener('load', () => resolve(Boolean(getWechatMiniProgramBridge())), { once: true })
existingScript.addEventListener('error', () => resolve(false), { once: true })
return
}
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.onload = () => resolve(Boolean(getWechatMiniProgramBridge()))
script.onerror = () => resolve(false)
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)
})
return wechatJsSdkLoadPromise
} catch (error) {
console.error('加载微信 JS SDK 失败:', error)
}
}
const isWechatBrowser = () => (
@@ -135,6 +140,25 @@ const isWechatBrowser = () => (
&& /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
) => {
@@ -150,13 +174,13 @@ const resolveMiniProgramWebViewEnvironment = (
clearTimeout(timeout)
resolve(isMiniProgram)
}
const timeout = setTimeout(() => finish(false), 1500)
const timeout = setTimeout(() => finish(isEmbeddedInWechatMiniProgram()), GET_ENV_TIMEOUT)
try {
getEnv((result) => finish(Boolean(result.miniprogram)))
getEnv((result) => finish(Boolean(result?.miniprogram) || isEmbeddedInWechatMiniProgram()))
} catch (error) {
console.error('识别小程序 web-view 环境失败:', error)
finish(false)
// 保留超时重检,让延迟注入的宿主环境标识仍有机会生效。
}
})
}
@@ -164,50 +188,127 @@ const resolveMiniProgramWebViewEnvironment = (
const navigateToMiniProgramLocationPage = (
miniProgram: WechatMiniProgramBridge,
target: WechatMiniProgramLocationTarget
) => new Promise<boolean>((resolve) => {
) => 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) {
resolve(false)
uni.showToast({
title: '宿主地图桥接不可用',
icon: 'none'
})
finish({ status: 'failed', reason: 'bridge-unavailable' })
return
}
miniProgram.navigateTo({
url: buildWechatMiniProgramOpenLocationUrl(target),
success: () => resolve(true),
fail: (error) => {
console.error('跳转小程序地图页失败:', error)
uni.showToast({
title: '无法打开地图导航',
icon: 'none'
})
resolve(false)
}
})
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' })
}
})
export const openWechatMiniProgramLocation = async (
const openWechatMiniProgramLocationOnce = async (
target: WechatMiniProgramLocationTarget
) => {
if (isNativeWechatMiniProgram()) {
return openExternalNavigation(target)
): Promise<WechatMiniProgramLocationOpenResult> => {
const validation = validateWechatOpenLocationTarget(target)
if (!validation.ok) {
uni.showToast({
title: validation.message,
icon: 'none'
})
return { status: 'failed', reason: 'invalid-target' }
}
const synchronouslyEmbedded = isEmbeddedInWechatMiniProgram()
if (!synchronouslyEmbedded && !isWechatBrowser()) return false
await loadWechatJsSdk()
const miniProgram = getWechatMiniProgramBridge()
if (!miniProgram?.navigateTo) {
if (synchronouslyEmbedded) {
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: '请在小程序内打开地图',
title: '无法打开微信地图',
icon: 'none'
})
return { status: 'failed', reason: 'navigation-failed' }
}
return false
}
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) return false
if (!isMiniProgramWebView) {
if (isEmbeddedInWechatMiniProgram()) {
uni.showToast({
title: '当前页面未连接宿主小程序',
icon: 'none'
})
return { status: 'failed', reason: 'not-in-mini-program' }
}
return { status: 'not-embedded' }
}
return navigateToMiniProgramLocationPage(miniProgram, target)
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,40 @@
import type { WechatOpenLocationTarget } from '@/utils/wechatOpenLocationProtocol'
export type WechatOpenLocationResult =
| { status: 'opened' }
| { status: 'failed'; reason: 'api-failed' | 'timeout' }
const OPEN_LOCATION_TIMEOUT = 5000
export const openWechatHostLocation = (
target: WechatOpenLocationTarget
) => new Promise<WechatOpenLocationResult>((resolve) => {
let settled = false
const finish = (result: WechatOpenLocationResult) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(result)
}
const timeout = setTimeout(() => {
finish({ status: 'failed', reason: 'timeout' })
}, OPEN_LOCATION_TIMEOUT)
try {
uni.openLocation({
latitude: target.latitude,
longitude: target.longitude,
name: target.name,
address: target.address || '',
scale: 18,
success: () => finish({ status: 'opened' }),
fail: (error) => {
console.error('打开地图位置失败:', error)
finish({ status: 'failed', reason: 'api-failed' })
}
})
} catch (error) {
console.error('调用微信地图能力失败:', error)
finish({ status: 'failed', reason: 'api-failed' })
}
})

View File

@@ -0,0 +1,93 @@
export const WECHAT_OPEN_LOCATION_PAGE_PATH = '/pages/open-location/index'
export interface WechatOpenLocationTarget {
latitude: number
longitude: number
name: string
address?: string
}
export interface WechatOpenLocationPageOptions {
latitude?: unknown
longitude?: unknown
name?: unknown
address?: unknown
}
export type WechatOpenLocationValidationResult =
| { ok: true; value: WechatOpenLocationTarget }
| { ok: false; message: string }
const MAX_NAME_LENGTH = 100
const MAX_ADDRESS_LENGTH = 300
const firstValue = (value: unknown) => (
Array.isArray(value) ? value[0] : value
)
const normalizeText = (value: unknown) => {
const first = firstValue(value)
return typeof first === 'string' ? first.trim() : ''
}
const normalizeCoordinate = (value: unknown) => {
const first = firstValue(value)
if (typeof first === 'string' && !first.trim()) return null
if (typeof first !== 'string' && typeof first !== 'number') return null
const coordinate = Number(first)
return Number.isFinite(coordinate) ? coordinate : null
}
export const validateWechatOpenLocationTarget = (
target: WechatOpenLocationPageOptions
): WechatOpenLocationValidationResult => {
const latitude = normalizeCoordinate(target.latitude)
const longitude = normalizeCoordinate(target.longitude)
const name = normalizeText(target.name)
const address = normalizeText(target.address)
if (latitude === null || longitude === null) {
return { ok: false, message: '地图坐标缺失或格式错误' }
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return { ok: false, message: '地图坐标超出有效范围' }
}
if (!name) {
return { ok: false, message: '目的地名称不能为空' }
}
if (name.length > MAX_NAME_LENGTH || address.length > MAX_ADDRESS_LENGTH) {
return { ok: false, message: '目的地名称或地址过长' }
}
return {
ok: true,
value: {
latitude,
longitude,
name,
address
}
}
}
export const buildWechatOpenLocationPageUrl = (
target: WechatOpenLocationPageOptions
) => {
const result = validateWechatOpenLocationTarget(target)
if (!result.ok) throw new TypeError(result.message)
const params = new URLSearchParams()
params.set('latitude', String(result.value.latitude))
params.set('longitude', String(result.value.longitude))
params.set('name', result.value.name)
params.set('address', result.value.address || '')
// 微信页面路由对加号的解码行为不统一,空格固定使用百分号编码。
const query = params.toString().replace(/\+/g, '%20')
return `${WECHAT_OPEN_LOCATION_PAGE_PATH}?${query}`
}
export const parseWechatOpenLocationPageOptions = (
options: WechatOpenLocationPageOptions = {}
) => validateWechatOpenLocationTarget(options)