forked from zhouruizhe/gmTouringMiniApp
fix(map): 修正定位落到零坐标、旧详情页路由白屏与跳转期间地图可拖动
三个问题,前两个各有独立根因。
1) 首次打开定位到几内亚湾、底图变成 HERE
regionchange 处理里读的是 `event.detail.longitude`,但微信的 regionchange
载荷没有这个字段,`Number(undefined)` 恒为 NaN,于是每次都落到异步的
getCenterLocation。冷启动时地图 SDK 还没拿到有效中心点,它会回传 (0, 0),
而两个 store 的坐标校验只查 Number.isFinite,(0, 0) 直接通过。
(0, 0) 落在几内亚湾(Null Island),微信 <map> 一到境外坐标就切 HERE 底图。
全程不需要用户点过定位按钮。
数据集校验器本来就在拒绝零坐标,只是运行时 store 没套用同一条规则。现把它
提成共享守卫 isTrustworthyCoordinate,validateCoordinates 改为复用,并铺到
map store、location store 和打卡定位服务。regionchange 一侧改为优先读微信
真正给的 detail.centerLocation,回落路径校验后才写入,且 mapReady 为 false
时不接受任何回写。
2) 冷启动白屏、左上角只剩回首页的小房子
f8ad5e1 把详情页从 pages/poi/detail 移进 pages-poi 分包,源码调用点都改了,
但旧路由本身从 app.json 消失。任何还攥着旧路径的入口 —— 开发者工具模拟器上次
停留的路由、预览启动页、已发出的分享卡片或二维码 —— 冷启动会落到微信解析不出
的路由,渲染成白屏;因为不是 tabBar 页且栈深为 1,左上角只剩小房子。
旧路径补一个只做重定向的跳板页,带 poiId 就 redirectTo 新路径,不带就 reLaunch
回地图。用 redirectTo 是为了让跳板不留在页面栈里。主包 +1 KB。等所有入口都刷新
过可以删掉。
3) 跳转详情页时地图仍可拖动几毫秒
<map> 是渲染在 webview 之上的原生组件,销毁是异步的,页面转场开始了它还在。
navigating 之前只用于防重复点击,没接到地图上。现在接上 enable-scroll /
enable-zoom,并给 selectMarker、openCheckInRecords 补同一个守卫(后者原先完全
没有防护),regionchange 在跳转期间也不再回写 viewport。onShow 里直接复位
navigating,返回时立刻恢复交互,不等 500ms 兜底定时器。
原生组件的异步销毁消不掉,但那几毫秒里地图不再响应拖动和缩放。
验证:测试 100 passed(map store 新增零坐标、越界、resetViewport 兜底用例),
type-check 干净。build 主包 1187 KB / 分包 1841 KB,dev 1527 KB / 1847 KB,
均过 verify:mp-weixin。
This commit is contained in:
@@ -12,6 +12,9 @@ const expectedPages = [
|
||||
'pages/planner/index',
|
||||
'pages/check-in/index',
|
||||
'pages/check-in/records',
|
||||
// 旧详情页路由的兼容跳板,只做重定向。缺了它,还攥着旧路径的入口
|
||||
// (模拟器上次停留的路由、预览启动页、已发出的分享卡片)会白屏。
|
||||
'pages/poi/detail',
|
||||
]
|
||||
// 点位详情页放在 pages-poi 分包里,连带 800×600 大图一起按需下载,
|
||||
// 否则 30 张大图会把主包顶过 2 MB 上限。
|
||||
|
||||
@@ -28,6 +28,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时坐标可信判断,用于点位数据校验与地图/定位状态写入。
|
||||
*
|
||||
* 除了有限性和全球范围,还必须拒绝经纬度为 0 的坐标:
|
||||
* 地图 SDK 尚未拿到定位、或定位接口降级时会回传 (0, 0),
|
||||
* 它落在几内亚湾(Null Island)。微信 `<map>` 在境外坐标下会切到 HERE 底图,
|
||||
* 视觉上就是「地图跑到非洲的海里」,还会因为境外瓦片加载失败而留下大片空白。
|
||||
*/
|
||||
export function isTrustworthyCoordinate(longitude: unknown, latitude: unknown): boolean {
|
||||
return typeof longitude === 'number'
|
||||
&& typeof latitude === 'number'
|
||||
&& Number.isFinite(longitude)
|
||||
&& Number.isFinite(latitude)
|
||||
&& longitude !== 0
|
||||
&& latitude !== 0
|
||||
&& longitude >= -180
|
||||
&& longitude <= 180
|
||||
&& latitude >= -90
|
||||
&& latitude <= 90
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
@@ -194,14 +215,7 @@ function validateOpeningHours(openingHours: PoiOpeningHours, poiId: string, issu
|
||||
function validateCoordinates(poi: Poi, issues: PoiValidationIssue[]): void {
|
||||
const { longitude, latitude } = poi
|
||||
const id = isNonEmptyString(poi.id) ? poi.id : 'invalid_poi'
|
||||
if (!Number.isFinite(longitude)
|
||||
|| !Number.isFinite(latitude)
|
||||
|| longitude < -180
|
||||
|| longitude > 180
|
||||
|| latitude < -90
|
||||
|| latitude > 90
|
||||
|| longitude === 0
|
||||
|| latitude === 0) {
|
||||
if (!isTrustworthyCoordinate(longitude, latitude)) {
|
||||
addIssue(issues, 'COORDINATE_INVALID', `pois.${id}.coordinates`, '经纬度必须为非零的有限合法数值', id)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,6 +83,17 @@
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f4f8f6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/poi/detail",
|
||||
"type": "page",
|
||||
"layout": "map",
|
||||
"style": {
|
||||
"navigationBarTitleText": "点位详情",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f5f7f6"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
+51
-6
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { PoiCategory, PoiDatasetMeta, PoiSummary } from '@/domain/poi'
|
||||
import type { GeoPoint, PoiCategory, PoiDatasetMeta, PoiSummary } from '@/domain/poi'
|
||||
import type { PlanResponse } from '@/domain/travel'
|
||||
import type { PoiMapMarker } from '@/services/map'
|
||||
import MapFilterHeader from '@/components/map/MapFilterHeader.vue'
|
||||
import PoiSummaryCard from '@/components/map/PoiSummaryCard.vue'
|
||||
import PageState from '@/components/poi/PageState.vue'
|
||||
import { getPoiRepository } from '@/data/poi'
|
||||
import { isTrustworthyCoordinate } from '@/domain/poi'
|
||||
import { buildMarkerIdMap, createPoiMarkers } from '@/services/map'
|
||||
import { getSessionPlanningOrigin, loadPlan } from '@/services/travel-assistant'
|
||||
import { useLocationStore, useMapStore } from '@/stores'
|
||||
@@ -243,6 +244,9 @@ function selectCategory(categoryCode: string | null) {
|
||||
}
|
||||
|
||||
function selectMarker(event: { detail: { markerId: number } }) {
|
||||
// 跳转过程中原生地图还在,别再受理点选。
|
||||
if (navigating.value)
|
||||
return
|
||||
lastMarkerTapAt.value = Date.now()
|
||||
const poiId = markerIdToPoiId.value.get(Number(event.detail.markerId))
|
||||
if (!poiId)
|
||||
@@ -267,22 +271,49 @@ function clearSelection() {
|
||||
mapStore.selectPoi(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 尽量直接从 regionchange 事件里取中心点。
|
||||
* 微信把中心点放在 `detail.centerLocation`,老基础库才退化到 `detail.longitude`。
|
||||
* 两者都拿不到时才回落到异步的 getCenterLocation。
|
||||
*/
|
||||
function readEventCenter(detail: Record<string, unknown>): GeoPoint | null {
|
||||
const center = detail.centerLocation
|
||||
if (center && typeof center === 'object') {
|
||||
const { longitude, latitude } = center as Record<string, unknown>
|
||||
if (isTrustworthyCoordinate(Number(longitude), Number(latitude)))
|
||||
return { longitude: Number(longitude), latitude: Number(latitude) }
|
||||
}
|
||||
|
||||
const longitude = Number(detail.longitude)
|
||||
const latitude = Number(detail.latitude)
|
||||
if (isTrustworthyCoordinate(longitude, latitude))
|
||||
return { longitude, latitude }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function updateViewport(event: { type: string, detail: Record<string, unknown> }) {
|
||||
const changeType = String(event.detail.type ?? event.type ?? '')
|
||||
if (changeType !== 'end')
|
||||
return
|
||||
// 地图上下文还没建立、或正在跳转详情页时,地图回传的中心点不可信,直接丢弃。
|
||||
if (!mapReady.value || navigating.value)
|
||||
return
|
||||
|
||||
const longitude = Number(event.detail.longitude)
|
||||
const latitude = Number(event.detail.latitude)
|
||||
const eventScale = Number(event.detail.scale)
|
||||
const fallbackScale = Number.isFinite(eventScale) ? eventScale : viewport.value.scale
|
||||
if (Number.isFinite(longitude) && Number.isFinite(latitude)) {
|
||||
updateViewportWithScale(longitude, latitude, fallbackScale)
|
||||
|
||||
const center = readEventCenter(event.detail)
|
||||
if (center) {
|
||||
updateViewportWithScale(center.longitude, center.latitude, fallbackScale)
|
||||
return
|
||||
}
|
||||
|
||||
mapContext.value?.getCenterLocation({
|
||||
success: (location: { longitude: number, latitude: number }) => {
|
||||
// 地图 SDK 还没拿到有效中心点时会回传 (0, 0),写进 viewport 会把地图甩到几内亚湾。
|
||||
if (!isTrustworthyCoordinate(location.longitude, location.latitude))
|
||||
return
|
||||
updateViewportWithScale(location.longitude, location.latitude, fallbackScale)
|
||||
},
|
||||
})
|
||||
@@ -485,7 +516,17 @@ function openDetail(poiId: string) {
|
||||
}
|
||||
|
||||
function openCheckInRecords() {
|
||||
uni.navigateTo({ url: '/pages/check-in/records' })
|
||||
if (navigating.value)
|
||||
return
|
||||
navigating.value = true
|
||||
uni.navigateTo({
|
||||
url: '/pages/check-in/records',
|
||||
complete: () => {
|
||||
setTimeout(() => {
|
||||
navigating.value = false
|
||||
}, 500)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function initializeMap() {
|
||||
@@ -527,6 +568,8 @@ onReady(() => {
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
// 从详情页返回后立刻恢复地图交互,不等 navigateTo 的 500ms 兜底定时器。
|
||||
navigating.value = false
|
||||
loadActivePlan()
|
||||
if (activePlan.value)
|
||||
includePlannedRoute()
|
||||
@@ -597,6 +640,8 @@ onUnload(stopForegroundLocationUpdates)
|
||||
:enable-rotate="false"
|
||||
:enable-overlooking="false"
|
||||
:show-compass="false"
|
||||
:enable-scroll="!navigating"
|
||||
:enable-zoom="!navigating"
|
||||
@markertap="selectMarker"
|
||||
@tap="clearSelection"
|
||||
@regionchange="updateViewport"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import PageState from '@/components/poi/PageState.vue'
|
||||
|
||||
/**
|
||||
* 旧详情页路由的兼容跳板。
|
||||
*
|
||||
* 详情页已从 `pages/poi/detail` 移入 `pages-poi` 分包(见 f8ad5e1)。
|
||||
* 旧路径一旦从 app.json 消失,任何还攥着它的入口 —— 开发者工具模拟器上次停留的
|
||||
* 路由、预览时的启动页、已经发出去的分享卡片或二维码 —— 冷启动都会落到一个
|
||||
* 微信解析不出来的路由上,渲染成白屏,左上角只剩一个回首页的小房子。
|
||||
*
|
||||
* 这里保留一个只做重定向的空页面把流量接回新路径。等所有入口都刷新过之后可以删掉。
|
||||
*/
|
||||
const REDIRECT_FAILURE_TOAST = '点位详情已更新,正在返回地图'
|
||||
|
||||
function fallbackToMap() {
|
||||
uni.reLaunch({ url: '/pages/map/index' })
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
const rawPoiId = typeof query?.poiId === 'string' ? query.poiId : ''
|
||||
if (!rawPoiId.trim()) {
|
||||
fallbackToMap()
|
||||
return
|
||||
}
|
||||
|
||||
// redirectTo 而非 navigateTo:跳板本身不该留在页面栈里。
|
||||
uni.redirectTo({
|
||||
url: `/pages-poi/detail?poiId=${encodeURIComponent(rawPoiId)}`,
|
||||
fail: () => {
|
||||
uni.showToast({ title: REDIRECT_FAILURE_TOAST, icon: 'none' })
|
||||
fallbackToMap()
|
||||
},
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="poi-detail-redirect">
|
||||
<PageState state="loading" title="正在打开点位详情…" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
layout: map
|
||||
style:
|
||||
navigationBarTitleText: 点位详情
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
navigationBarTextStyle: black
|
||||
backgroundColor: '#f5f7f6'
|
||||
</route>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.poi-detail-redirect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #f5f7f6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CheckInLocationSnapshot } from '@/domain/check-in'
|
||||
import { isTrustworthyCoordinate } from '@/domain/poi'
|
||||
|
||||
interface CurrentLocationOptions {
|
||||
highAccuracyExpireTime?: number
|
||||
@@ -63,7 +64,7 @@ export function getCurrentGcj02Location(options: CurrentLocationOptions = {}): P
|
||||
const longitude = Number(result.longitude)
|
||||
const latitude = Number(result.latitude)
|
||||
const accuracy = Number(result.accuracy ?? result.horizontalAccuracy)
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
|
||||
if (!isTrustworthyCoordinate(longitude, latitude)) {
|
||||
finish(() => reject(new Error('定位结果无效,请稍后重试')))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GeoPoint } from '@/domain/poi'
|
||||
import { defineStore } from 'pinia'
|
||||
import { isTrustworthyCoordinate } from '@/domain/poi'
|
||||
|
||||
export type UserLocationStatus = 'idle' | 'locating' | 'ready' | 'denied' | 'unavailable' | 'error'
|
||||
|
||||
@@ -15,15 +16,6 @@ interface UserLocationState {
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
function isValidCoordinate(longitude: number, latitude: number): boolean {
|
||||
return Number.isFinite(longitude)
|
||||
&& Number.isFinite(latitude)
|
||||
&& longitude >= -180
|
||||
&& longitude <= 180
|
||||
&& latitude >= -90
|
||||
&& latitude <= 90
|
||||
}
|
||||
|
||||
export const useLocationStore = defineStore('user-location-session', {
|
||||
state: (): UserLocationState => ({
|
||||
status: 'idle',
|
||||
@@ -36,7 +28,7 @@ export const useLocationStore = defineStore('user-location-session', {
|
||||
this.errorMessage = ''
|
||||
},
|
||||
updateLocation(longitude: number, latitude: number, accuracy?: number | null) {
|
||||
if (!isValidCoordinate(longitude, latitude))
|
||||
if (!isTrustworthyCoordinate(longitude, latitude))
|
||||
return false
|
||||
|
||||
this.snapshot = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GeoPoint, MapViewport } from '@/domain/poi'
|
||||
import type { MapViewport } from '@/domain/poi'
|
||||
import { defineStore } from 'pinia'
|
||||
import { isTrustworthyCoordinate } from '@/domain/poi'
|
||||
|
||||
export const DEFAULT_GUANGMING_VIEWPORT: MapViewport = {
|
||||
longitude: 113.935,
|
||||
@@ -14,8 +15,10 @@ interface MapSessionState {
|
||||
plannedRouteVisible: boolean
|
||||
}
|
||||
|
||||
function isFinitePoint(point: GeoPoint): boolean {
|
||||
return Number.isFinite(point.longitude) && Number.isFinite(point.latitude)
|
||||
function isUsableViewport(viewport: MapViewport): boolean {
|
||||
return isTrustworthyCoordinate(viewport.longitude, viewport.latitude)
|
||||
&& Number.isFinite(viewport.scale)
|
||||
&& viewport.scale > 0
|
||||
}
|
||||
|
||||
export const useMapStore = defineStore('map-session', {
|
||||
@@ -33,13 +36,15 @@ export const useMapStore = defineStore('map-session', {
|
||||
this.selectedPoiId = poiId
|
||||
},
|
||||
updateViewport(viewport: MapViewport) {
|
||||
if (!isFinitePoint(viewport) || !Number.isFinite(viewport.scale) || viewport.scale <= 0)
|
||||
if (!isUsableViewport(viewport))
|
||||
return
|
||||
|
||||
this.viewport = { ...viewport }
|
||||
},
|
||||
resetViewport(viewport: MapViewport = DEFAULT_GUANGMING_VIEWPORT) {
|
||||
this.viewport = { ...viewport }
|
||||
this.viewport = isUsableViewport(viewport)
|
||||
? { ...viewport }
|
||||
: { ...DEFAULT_GUANGMING_VIEWPORT }
|
||||
},
|
||||
showPlannedRoute() {
|
||||
this.plannedRouteVisible = true
|
||||
@@ -50,7 +55,9 @@ export const useMapStore = defineStore('map-session', {
|
||||
resetSession(viewport: MapViewport = DEFAULT_GUANGMING_VIEWPORT) {
|
||||
this.categoryCode = null
|
||||
this.selectedPoiId = null
|
||||
this.viewport = { ...viewport }
|
||||
this.viewport = isUsableViewport(viewport)
|
||||
? { ...viewport }
|
||||
: { ...DEFAULT_GUANGMING_VIEWPORT }
|
||||
this.plannedRouteVisible = false
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,4 +53,38 @@ describe('map session store', () => {
|
||||
expect(store.updateLocation(181, 22.76, 10)).toBe(false)
|
||||
expect(store.snapshot).toBeNull()
|
||||
})
|
||||
|
||||
// 定位接口降级 / 地图 SDK 还没拿到定位时会回传 (0, 0),
|
||||
// 它落在几内亚湾,会把地图甩到非洲外海并触发境外底图。
|
||||
it('rejects null-island coordinates for the map viewport', () => {
|
||||
const store = useMapStore()
|
||||
store.updateViewport({ longitude: 0, latitude: 0, scale: 15 })
|
||||
expect(store.viewport).toEqual(DEFAULT_GUANGMING_VIEWPORT)
|
||||
|
||||
store.updateViewport({ longitude: 113.94, latitude: 0, scale: 15 })
|
||||
expect(store.viewport).toEqual(DEFAULT_GUANGMING_VIEWPORT)
|
||||
|
||||
store.updateViewport({ longitude: 0, latitude: 22.76, scale: 15 })
|
||||
expect(store.viewport).toEqual(DEFAULT_GUANGMING_VIEWPORT)
|
||||
})
|
||||
|
||||
it('rejects null-island coordinates for the user location', () => {
|
||||
const store = useLocationStore()
|
||||
expect(store.updateLocation(0, 0, 10)).toBe(false)
|
||||
expect(store.updateLocation(113.94, 0, 10)).toBe(false)
|
||||
expect(store.updateLocation(0, 22.76, 10)).toBe(false)
|
||||
expect(store.snapshot).toBeNull()
|
||||
expect(store.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('falls back to the default viewport when reset is given an unusable one', () => {
|
||||
const store = useMapStore()
|
||||
store.updateViewport({ longitude: 113.94, latitude: 22.76, scale: 13 })
|
||||
|
||||
store.resetViewport({ longitude: 0, latitude: 0, scale: 11 })
|
||||
expect(store.viewport).toEqual(DEFAULT_GUANGMING_VIEWPORT)
|
||||
|
||||
store.resetSession({ longitude: 0, latitude: 0, scale: 11 })
|
||||
expect(store.viewport).toEqual(DEFAULT_GUANGMING_VIEWPORT)
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+1
@@ -10,6 +10,7 @@ interface NavigateToOptions {
|
||||
"/pages/check-in/records" |
|
||||
"/pages/itinerary/index" |
|
||||
"/pages/planner/index" |
|
||||
"/pages/poi/detail" |
|
||||
"/pages-poi/detail";
|
||||
}
|
||||
interface RedirectToOptions extends NavigateToOptions {}
|
||||
|
||||
Reference in New Issue
Block a user