多平台地图选点功能实现教程

基于 UniApp + 天地图 API,实现一套逻辑适配 H5 / APP / 微信小程序 / 鸿蒙四大平台的地图选点功能。

在这里插入图片描述
在这里插入图片描述


目录

  1. 架构总览
  2. 坐标系基础
  3. 项目文件结构与职责
  4. 核心 Composable 设计
  5. H5 端实现
  6. APP 端实现(renderjs 桥接)
  7. 微信小程序端实现
  8. 鸿蒙端实现(花瓣地图原生组件)
  9. POI 搜索与逆地理编码
  10. 选点结果输出
  11. 踩坑记录与最佳实践
  12. 完整项目源码
  13. 使用手册

1. 架构总览

1.1 设计思路

地图选点的核心挑战在于:各平台地图组件不同、坐标系不同、通信机制不同。为避免在视图层堆砌大量平台判断逻辑,我们采用 逻辑-视图分层 架构:

┌─────────────────────────────────────────────────────┐
│                  视图层 (index.vue)                   │
│   ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
│   │   H5    │ │ APP-PLUS│ │MP-WEIXIN │ │  鸿蒙   │ │
│   │天地图JS │ │renderjs │ │原生map组件│ │花瓣地图 │ │
│   └────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘ │
│        │           │           │             │       │
│        └───── 坐标系转换 ──────┴─────────────┘       │
│                        │                             │
│              ┌─────────▼──────────┐                  │
│              │  useMapPicker Hook │                  │
│              │  (纯逻辑,不依赖   │                  │
│              │   任何地图组件)    │                  │
│              └─────────┬──────────┘                  │
│                        │                             │
│           ┌────────────▼────────────┐                │
│           │  coordTransform.ts      │                │
│           │  mapConfig.ts           │                │
│           └─────────────────────────┘                │
└─────────────────────────────────────────────────────┘

1.2 核心原则

原则 说明
逻辑-视图分离 useMapPicker 封装所有业务逻辑,视图层只负责地图组件交互和坐标系转换
坐标系统一约定 Hook 内部始终使用 WGS-84,各平台在视图层自行完成坐标系适配
结果双坐标系输出 最终结果同时返回 WGS-84 和 GCJ-02,调用方按需取用
条件编译隔离 通过 #ifdef / #ifndef 实现平台代码物理隔离,各端互不干扰

2. 坐标系基础

这是本功能最核心的背景知识,忽略坐标系差异会导致 100~600 米的位置偏移

2.1 三种坐标系对照

坐标系 全称 用途 本项目中的使用场景
WGS-84 World Geodetic System 1984 GPS 原始坐标,国际通用标准 uni.getLocation({ type: 'wgs84' })、天地图 API、后端存储
GCJ-02 国测局坐标(“火星坐标”) 中国法定加密偏移标准 微信小程序 map 组件、花瓣地图
CGCS2000 中国大地坐标2000系 国家大地坐标系 天地图底图(与 WGS-84 偏差 <1m,可等价使用)

2.2 坐标系转换需求

                ┌──────────────────────┐
                │   天地图 API (WGS-84)  │
                │  · 逆地理编码          │
                │  · POI 搜索           │
                └───────┬──────┬───────┘
                        │      │
            wgs84ToGcj02  │      │  gcj02ToWgs84
                        │      │
      ┌─────────────────▼──┐ ┌─▼──────────────────┐
      │ 微信小程序 map (GCJ-02)│ │ 鸿蒙花瓣地图 (GCJ-02) │
      └────────────────────┘ └────────────────────┘

      ┌─────────────────────────────────────────────┐
      │ H5 / APP-PLUS 天地图 JS (WGS-84) ← 无需转换  │
      └─────────────────────────────────────────────┘

2.3 转换算法实现

参考文件:coordTransform.ts

算法基于 Krasovsky 1940 椭球参数,对经纬度施加非线性偏移:

// 椭球参数
const A = 6378245.0                // 长半轴(米)
const EE = 0.00669342162296594323  // 第一偏心率的平方

// WGS-84 → GCJ-02(正向偏移)
export function wgs84ToGcj02(wgsLng, wgsLat): { lng, lat }

// GCJ-02 → WGS-84(近似逆运算,精度 <0.5m)
export function gcj02ToWgs84(gcjLng, gcjLat): { lng, lat }

// 境外坐标自动跳过偏移(中国境外无需加密)
function outOfChina(lng, lat): boolean

关键要点

  • GCJ-02 → WGS-84 是近似逆运算,但对于选点场景精度足够
  • 境外坐标直接返回原值,不施加偏移
  • 转换函数为纯计算,无副作用,可放心在计算属性中使用

3. 项目文件结构与职责

src/
├── utils/
│   ├── coordTransform.ts      # 坐标系转换工具(WGS-84 ↔ GCJ-02)
│   └── mapConfig.ts           # 天地图 API 凭证和接口地址配置
│
├── hooks/
│   └── useMapPicker.ts        # 核心 composable(定位/逆地理编码/POI搜索/选点状态)
│
├── pages-sub/map/
│   ├── index.vue              # 地图选点主页面(四端条件编译)
│   ├── test.vue               # 测试页面(选点结果展示 + 原生 API 对比)
│   └── components/
│       ├── SearchBar.vue      # 搜索栏组件(含输入框 + 清除按钮)
│       └── PoiList.vue        # POI 搜索结果列表组件
│
└── uni_modules/native-harmony-map/   # 鸿蒙花瓣地图 UTS 插件
    └── utssdk/app-harmony/
        ├── map.ets            # 华为 MapKit 原生组件封装
        └── index.uts          # 导出地图控制器

依赖关系图

index.vue
  ├── useMapPicker (hook)
  │     ├── mapConfig.ts (API 密钥 + URL)
  │     └── coordTransform.ts (WGS-84 ↔ GCJ-02)
  ├── SearchBar.vue
  │     └── PoiList.vue
  └── native-harmony-map (UTS 插件, #ifdef APP-HARMONY)
        └── map.ets (花瓣地图组件)

4. 核心 Composable 设计

参考文件:useMapPicker.ts

4.1 设计目标

将业务逻辑与各平台视图层完全解耦——hook 不依赖任何特定地图组件,只负责:

  1. 定位:获取 WGS-84 坐标
  2. 逆地理编码:坐标 → 地址
  3. POI 搜索:关键词 → 地点列表
  4. 选中状态管理:当前位置/选中位置
  5. 结果输出:双坐标系 MapPickerResult

4.2 坐标语义约定

这是整个系统最关键的约定,违反此约定将导致位置偏移

变量 坐标系 说明
centerLat / centerLng 随平台变化 H5/APP-PLUS → WGS-84;微信/鸿蒙 → GCJ-02
selectedLocation.latitude/longitude 始终 WGS-84 所有平台调用 updateSelected() 前必须转为 WGS-84
天地图 API 输入输出 始终 WGS-84 逆地理编码和 POI 搜索均使用 WGS-84

4.3 导出接口一览

// 响应式状态
centerLat, centerLng     // 地图中心坐标(显示坐标系,随平台不同)
located, locating        // 定位状态
selectedLocation         // 选中位置信息(WGS-84)
keyword                  // 搜索关键词
poiList                  // POI 搜索结果
searchLoading            // 搜索加载态
showPoiList              // 是否显示搜索结果
hasSelection             // 是否已有有效选中

// 方法
initLocation()           // 初始化定位(WGS-84)
getCurrentLocation()     // 获取当前位置(WGS-84)
updateSelected(lng, lat) // 更新选中位置(必须 WGS-84)
reverseGeocode(lng, lat) // 逆地理编码(必须 WGS-84)
onSearchInput(value, mapBound?)  // 搜索输入(带防抖)
onSearchConfirm(mapBound?)       // 确认搜索
clearSearch()                    // 清空搜索
selectPoi(item)                  // 选中 POI 结果
getResult()                      // 获取双坐标系结果

4.4 类型定义

/** 位置信息(坐标为 WGS-84) */
interface LocationInfo {
  name: string          // 地点名称
  address: string       // 完整地址
  latitude: number      // 纬度(WGS-84)
  longitude: number     // 经度(WGS-84)
  province?: string     // 省
  city?: string         // 市
  district?: string     // 区/县
}

/** POI 搜索结果项(坐标为 WGS-84) */
interface PoiItem {
  key: string           // 唯一标识(城市统计项以 "city_" 开头)
  name: string          // 地点名称
  address: string       // 地址
  latitude: number      // 纬度(WGS-84)
  longitude: number     // 经度(WGS-84)
}

/** 选点最终结果(含双坐标系) */
interface MapPickerResult {
  name: string
  address: string
  latitude: number      // WGS-84 纬度
  longitude: number     // WGS-84 经度
  gcj02Latitude: number  // GCJ-02 纬度
  gcj02Longitude: number // GCJ-02 经度
}

5. H5 端实现

参考文件:index.vue#ifdef H5 分支

5.1 实现方案

H5 端直接在浏览器中加载天地图 JS API 4.0,操作 DOM 创建地图实例。

5.2 核心代码解析

<template>
  <!-- 天地图渲染容器 -->
  <view id="tianditu-container" class="map-container" />
  <!-- 中心定位针(CSS 固定在视口中心,拖拽地图时保持不动) -->
  <view class="center-pin">
    <image class="pin-icon" src="https://api.tianditu.gov.cn/img/map/markerA.png" />
  </view>
</template>

初始化流程

// 1. 动态加载天地图 JS
function initH5Map(lat, lng) {
  const TMap = (window as any).T
  if (!TMap) {
    const script = document.createElement('script')
    script.src = TIANDITU_JS_API_URL  // 含浏览器端 Token
    script.onload = () => createH5Map(lat, lng)
    document.head.appendChild(script)
  } else {
    createH5Map(lat, lng)
  }
}

// 2. 创建地图并绑定事件
function createH5Map(lat, lng) {
  h5Map = new TMap.Map('tianditu-container')
  h5Map.centerAndZoom(new TMap.LngLat(lng, lat), 15)
  
  // 拖拽结束 → 取中心坐标(WGS-84,无需转换)→ 逆地理编码
  h5Map.addEventListener('moveend', () => {
    const center = h5Map.getCenter()
    updateSelected(center.getLng(), center.getLat())
  })
  
  // 点击 → 平移 + 逆地理编码
  h5Map.addEventListener('click', (e) => {
    h5Map.panTo(new TMap.LngLat(lng, lat))
    updateSelected(e.lnglat.getLng(), e.lnglat.getLat())
  })
}

5.3 要点

  • H5 天地图使用 WGS-84 坐标系,与 hook 中 centerLat/centerLng 语义一致,无需坐标系转换
  • 使用"中心定位针"交互模式:地图拖动时针不动,取地图中心坐标即为选中点
  • 可通过 h5GetMapBound() 获取当前视野范围,传给搜索接口优化结果精准度

6. APP 端实现(renderjs 桥接)

参考文件:index.vue#ifdef APP-PLUS 分支

6.1 为什么需要 renderjs

APP 端的 WebView 分为逻辑层(运行 Vue 逻辑)和视图层(运行 DOM 渲染)。

天地图 JS API 需要操作 DOM,只能在视图层运行。UniApp 的 renderjs 正是为此设计的——它在视图层运行,可以直接操作 DOM,并通过 callMethod 与逻辑层通信。

6.2 通信架构

┌─ 逻辑层 (<script setup>) ──────────┐    ┌─ 视图层 (renderjs) ────────┐
│                                      │    │                             │
│  mapRenderProp ─── change:prop ───→  │    │  propChanged() 分发指令     │
│    { type: 'init', lat, lng, tk }   │    │    ├── initMap() 加载JS API  │
│    { type: 'moveTo', lat, lng }     │    │    └── moveToLocation()      │
│                                      │    │                             │
│  ←── callMethod('handleMapMoveEnd') ─┤    │  map.addEventListener(      │
│  ←── callMethod('handleMapReady') ───┤    │    'moveend', callback)     │
│                                      │    │                             │
└──────────────────────────────────────┘    └─────────────────────────────┘

6.3 桥接变量方案

callMethod 只能调用选项式 methods,无法访问 <script setup> 闭包。解决方案:

// 模块级桥接变量
let __mapMoveEndBridge: ((data) => void) | null = null
let __mapReadyBridge: (() => void) | null = null

// 选项式 methods 做转发
export default {
  methods: {
    handleMapMoveEnd(data) { __mapMoveEndBridge?.(data) },
    handleMapReady() { __mapReadyBridge?.() },
  },
}

// setup 侧赋值真正的回调
__mapMoveEndBridge = (data) => {
  updateSelected(data.lng, data.lat)
}
__mapReadyBridge = () => {
  updateSelected(wgsLng, wgsLat)
  __mapReadyBridge = null  // 一次性回调,用完清除
}

6.4 ⚠️ 关键坑:首次请求 403

问题:APP-PLUS 端天地图浏览器端 Key 依赖 JS API 加载后建立的会话 Cookie 鉴权。如果在天地图 JS 加载完成前发起 REST 请求(如逆地理编码),会返回 403: 不支持的key类型

解决方案:延迟首次逆地理编码到 handleMapReady 回调后执行:

// 等待 renderjs 中天地图 JS 加载完成
__mapReadyBridge = () => {
  updateSelected(wgsLng, wgsLat)  // 此时才安全发起 REST 请求
  __mapReadyBridge = null
}

7. 微信小程序端实现

参考文件:index.vue#ifdef MP-WEIXIN 分支

7.1 实现方案

使用微信小程序原生 <map> 组件,该组件坐标系为 GCJ-02

7.2 坐标系转换

所有与 hook 的数据交互都需要双向转换:

hook → 微信:WGS-84 → GCJ-02
  - 定位坐标 → map 组件的 latitude/longitude
  - POI 坐标 → moveTo 目标坐标
  - selectedLocation → markers 渲染坐标

微信 → hook:GCJ-02 → WGS-84
  - regionchange 返回的中心坐标
  - tap 返回的点击坐标

7.3 核心代码解析

<template>
  <map
    id="mp-map"
    :latitude="centerLat"     <!-- GCJ-02 纬度 -->
    :longitude="centerLng"    <!-- GCJ-02 经度 -->
    :markers="mpMarkers"      <!-- GCJ-02 markers -->
    :show-location="true"
    @regionchange="mpOnRegionChange"
    @tap="mpOnMapTap"
  />
</template>
// Markers 计算:WGS-84 → GCJ-02
const mpMarkers = computed(() => {
  if (!selectedLocation.value.latitude) return []
  const gcj = wgs84ToGcj02(selectedLocation.value.longitude, selectedLocation.value.latitude)
  return [{ id: 1, latitude: gcj.lat, longitude: gcj.lng, ... }]
})

// 拖拽结束:GCJ-02 → WGS-84 → 逆地理编码
function mpOnRegionChange(e) {
  if (e.type === 'end' && e.causedBy === 'gesture') {
    mpMapCtx.value?.getCenterLocation({
      success: (res) => {
        centerLat.value = res.latitude   // GCJ-02
        centerLng.value = res.longitude
        const wgs = gcj02ToWgs84(res.longitude, res.latitude)
        updateSelected(wgs.lng, wgs.lat) // WGS-84
      },
    })
  }
}

// 程序化移动:WGS-84 → GCJ-02
function mpMoveTo(wgsLat, wgsLng) {
  const gcj = wgs84ToGcj02(wgsLng, wgsLat)
  centerLat.value = gcj.lat
  centerLng.value = gcj.lng
  mpMapCtx.value?.moveToLocation({ latitude: gcj.lat, longitude: gcj.lng })
}

7.4 要点

  • 微信 getCenterLocation 返回 GCJ-02,tap 事件的 e.detail 也是 GCJ-02
  • 必须在 nextTick 中创建 uni.createMapContext,确保组件已挂载
  • regionchange 事件中 e.type === 'end' && e.causedBy === 'gesture' 过滤出用户拖拽结束

8. 鸿蒙端实现(花瓣地图原生组件)

参考文件:index.vue#ifdef APP-HARMONY 分支 + map.ets

8.1 实现方案

鸿蒙端使用华为 MapKit(花瓣地图)原生组件,通过 UTS 插件以 <embed tag="map"> 方式嵌入 Vue 页面。

8.2 UTS 插件架构

┌─ Vue 层 ──────────────────────────┐
│  <embed tag="map" :options="..." │
│    @mapclick="..."                │
│    @camerapositionchange="..."    │
│  />                               │
└──────────┬────────────────────────┘
           │ defineNativeEmbed('map')
┌──────────▼────────────────────────┐
│  map.ets (UTS 插件)               │
│  HuaweiMapComponent               │
│    ├── MapComponent (华为 MapKit)  │
│    ├── 事件监听 (mapClick等)       │
│    └── 稳定性轮询 (300ms)          │
└───────────────────────────────────┘

8.3 坐标系转换

花瓣地图使用 GCJ-02 坐标系,与微信小程序同理,需双向转换:

天地图 → 花瓣地图:WGS-84 → GCJ-02(移动地图、渲染标记)
花瓣地图 → 天地图:GCJ-02 → WGS-84(逆地理编码、存储坐标)

8.4 ⚠️ 关键坑:无 cameraPositionChangeEnd 事件

华为 MapKit API 不提供 cameraPositionChangeEnd 事件(即拖拽结束回调),只有持续的 cameraPositionChange

解决方案:稳定性轮询——每 300ms 读取相机位置,连续两次不变则判定为拖拽结束:

// map.ets 中
this.pollingTimer = setInterval(() => {
  let cameraPosition = this.mapController.getCameraPosition()
  let lat = cameraPosition.target.latitude
  let lng = cameraPosition.target.longitude
  let latChanged = Math.abs(lat - this.lastCameraLat) > 0.000001
  let lngChanged = Math.abs(lng - this.lastCameraLng) > 0.000001

  if (latChanged || lngChanged) {
    // 还在移动中
    this.lastCameraLat = lat
    this.lastCameraLng = lng
    this.isCameraStable = false
  } else if (!this.isCameraStable) {
    // 连续两次不变 → 拖拽结束,通知 Vue 层
    this.isCameraStable = true
    this.onCameraPositionChange?.({ type: 'camerapositionchange', detail: { latitude: lat, longitude: lng } })
  }
}, 300)

8.5 程序化移动(moveToTimestamp 模式)

UTS 组件属性变化不会自动触发行为,需通过 @Watch 装饰器手动监听:

// map.ets 中
@Prop @Watch('onMoveToTimestampChange') moveToTs: number = 0

onMoveToTimestampChange(): void {
  if (this.moveToTs > 0 && this.mapController) {
    this.isCameraStable = false  // 防止轮询误判
    let target = { latitude: this.moveToLat, longitude: this.moveToLng }
    let cameraUpdate = map.newLatLng(target)
    this.mapController.moveCamera(cameraUpdate)
  }
}

Vue 侧通过更新 moveToTimestamp 触发移动:

function harmonyMoveTo(wgsLat, wgsLng) {
  const gcj = wgs84ToGcj02(wgsLng, wgsLat)
  centerLat.value = gcj.lat
  centerLng.value = gcj.lng
  lastGeocodeTs = Date.now()         // 防止 moveCamera 触发重复逆地理编码
  harmonyMoveToTs.value = Date.now() // 变化触发 @Watch → moveCamera
}

8.6 防抖处理

mapclickcamerapositionchange 可能在短时间内同时触发(点击地图会先触发 click,随后相机移动触发 camera change),需防抖:

let lastGeocodeTs = 0
const GEOCODE_DEBOUNCE = 400  // 400ms 防抖窗口

function harmonyUpdateSelected(gcjLng, gcjLat) {
  const now = Date.now()
  if (now - lastGeocodeTs < GEOCODE_DEBOUNCE) return
  lastGeocodeTs = now
  // GCJ-02 → WGS-84 → 逆地理编码
}

8.7 地图点击自动移动相机

花瓣地图的 mapClick 事件只返回坐标,不自动移动相机。在 .ets 层手动处理:

this.mapEventManager.on('mapClick', (latLng) => {
  // 在 .ets 内部直接移动相机到点击位置
  if (this.mapController) {
    let target = { latitude: latLng.latitude, longitude: latLng.longitude }
    let cameraUpdate = map.newLatLng(target)
    this.mapController.moveCamera(cameraUpdate)
  }
  // 通知 Vue 侧
  this.onMapClick?.({ type: 'mapclick', detail: { ...latLng } })
})

9. POI 搜索与逆地理编码

9.1 天地图搜索 API

本项目的 POI 搜索和逆地理编码均使用天地图 REST API,不依赖任何第三方地图 SDK。

逆地理编码(坐标 → 地址):

GET https://api.tianditu.gov.cn/geocoder
  ?postStr={"lon":116.404,"lat":39.915,"ver":1}
  &type=geocode
  &tk=<TIANDITU_KEY>

POI 搜索(关键词 → 地点列表):

GET https://api.tianditu.gov.cn/v2/search
  ?postStr={"keyWord":"天安门","level":"12","mapBound":"73.66,3.86,135.05,53.55","queryType":"1","start":"0","count":"20"}
  &type=query
  &tk=<TIANDITU_KEY>

9.2 搜索结果类型处理

天地图搜索 API 根据结果类型返回不同结构,需分别处理:

resultType 含义 数据位置 处理方式
1 POI 列表 pois[]result.data[] 直接展示
2 当前范围无结果,按城市统计 statistics.priorityCitys[] 显示城市 + 计数,点击重新搜索
3 行政区划 area 对象 展示行政区信息

9.3 搜索策略

  • 联想搜索suggestPoi):用户输入过程中触发,10 条结果,轻量快速
  • 确认搜索searchPoi):用户按下搜索键时触发,20 条结果,更完整
  • 搜索范围:默认中国全境(73.66,3.86,135.05,53.55),H5 端可传入当前地图视野范围
  • 城市统计项:当 key 以 city_ 开头时,点击会以该城市为中心(15km 半径)重新搜索

9.4 防抖机制

function onSearchInput(value, mapBound?) {
  keyword.value = value
  if (searchTimer) clearTimeout(searchTimer)
  searchTimer = setTimeout(() => {
    suggestPoi(value, mapBound)  // 300ms 后发起联想搜索
  }, debounceMs)
}

10. 选点结果输出

10.1 eventChannel 通信模式

地图选点页面通过 uni.navigateToevents 机制与调用页面通信:

// 调用方(如 test.vue)
uni.navigateTo({
  url: '/pages-sub/map/index',
  events: {
    selectLocation: (data: MapPickerResult) => {
      // data 包含 name, address, latitude, longitude, gcj02Latitude, gcj02Longitude
      console.log('选点结果:', data)
    },
  },
})

// 选点页面(index.vue)
function confirmLocation() {
  const eventChannel = instance?.proxy?.getOpenerEventChannel?.()
  if (eventChannel) {
    eventChannel.emit('selectLocation', getResult())
  }
  uni.navigateBack()
}

10.2 结果数据结构

interface MapPickerResult {
  name: string           // "天安门"
  address: string        // "北京市东城区东长安街"
  latitude: number       // 39.915 (WGS-84)
  longitude: number      // 116.404 (WGS-84)
  gcj02Latitude: number  // 39.921 (GCJ-02)
  gcj02Longitude: number // 116.411 (GCJ-02)
}

为什么输出双坐标系?

  • WGS-84:通用存储格式,传给后端、天地图 API 等无偏移场景
  • GCJ-02:传给微信小程序/鸿蒙地图渲染,无需再转换

11. 踩坑记录与最佳实践

11.1 APP-PLUS 天地图首次请求 403

现象:APP-PLUS 端首次逆地理编码请求返回 403,提示"不支持的key类型"。

原因:浏览器端 Key 依赖天地图 JS API 加载后建立的会话 Cookie 鉴权。在 renderjs 中加载天地图 JS 之前发起 REST 请求,缺少 Cookie 上下文。

解决:延迟首次逆地理编码到 handleMapReady 回调后执行。

11.2 鸿蒙花瓣地图无拖拽结束事件

现象:华为 MapKit 没有 cameraPositionChangeEnd 事件,只有持续的 cameraPositionChange

解决:300ms 稳定性轮询,连续两次位置不变则判定拖拽结束。

11.3 renderjs callMethod 无法访问 setup 闭包

现象$ownerInstance.callMethod() 只能调用选项式 methods,无法访问 <script setup> 中的函数。

解决:模块级桥接变量,setup 侧赋值回调,methods 侧转发调用。

11.4 鸿蒙 mapclick 和 camerapositionchange 重复触发

现象:点击地图时,mapclickcamerapositionchange 短时间内都触发,导致逆地理编码请求两次。

解决:400ms 防抖窗口,同一时间段内只取最后一次。

11.5 微信小程序 map 组件坐标系

现象:微信 map 组件使用 GCJ-02,但天地图 API 使用 WGS-84,混用导致偏移。

解决:严格遵循坐标语义约定,hook 内部统一 WGS-84,视图层负责双向转换。

11.6 最佳实践总结

实践 说明
统一坐标系约定 hook 内部始终 WGS-84,视图层负责适配
逻辑-视图分层 业务逻辑在 hook,平台差异在视图层
双坐标系输出 结果同时返回 WGS-84 和 GCJ-02
防抖/节流 搜索输入 300ms 防抖,鸿蒙逆地理编码 400ms 防抖
条件编译物理隔离 各平台代码块互不干扰,减少运行时判断
兜底策略 定位失败回退北京坐标,逆地理编码失败显示坐标字符串

12. 完整项目源码

以下为地图选点功能涉及的全部源文件,天地图 API Token 已脱敏(<YOUR_TIANDITU_KEY>),请前往 天地图开发者平台 申请后替换。

12.1 坐标系转换工具 — src/utils/coordTransform.ts

/**
 * 坐标系转换工具
 *
 * 本模块实现 WGS-84 与 GCJ-02 两种坐标系的双向转换。
 * 这是中国地图开发中最基础也最关键的适配层——
 * 若忽略坐标系差异,位置偏移可达 100~600 米。
 *
 * ──────────────────────────────────────────────
 * 坐标系速查表:
 * ──────────────────────────────────────────────
 * WGS-84    GPS 原始坐标,国际通用标准
 *           → 天地图 API、uni.getLocation({ type: 'wgs84' })、后端存储
 *
 * GCJ-02    国测局加密坐标(俗称"火星坐标"),中国法定偏移标准
 *           → 微信小程序 map 组件、高德地图、腾讯地图、华为花瓣地图
 *
 * CGCS2000  中国大地坐标2000系,与 WGS-84 偏差 <1m,可等价使用
 *           → 天地图底图和 API 实质使用此坐标系
 * ──────────────────────────────────────────────
 *
 * 算法原理:
 * 基于 Krasovsky 1940 椭球参数,对经纬度施加非线性偏移。
 * GCJ-02 → WGS-02 为近似逆运算(误差 <0.5m),满足业务精度要求。
 * 境外坐标不走偏移,直接原值返回。
 */

// Krasovsky 1940 椭球参数
const PI = Math.PI
const A = 6378245.0                // 长半轴(米)
const EE = 0.00669342162296594323  // 第一偏心率的平方

/**
 * 判定坐标是否在中国境内
 * 境外坐标无需加密偏移,直接使用原值
 */
function outOfChina(lng: number, lat: number): boolean {
  return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
}

/**
 * 纬度偏移量计算
 * 输入:经度 - 105°,纬度 - 35°(归一化基准点)
 */
function transformLat(lng: number, lat: number): number {
  let ret =
    -100.0 +
    2.0 * lng +
    3.0 * lat +
    0.2 * lat * lat +
    0.1 * lng * lat +
    0.2 * Math.sqrt(Math.abs(lng))
  ret += ((20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0) / 3.0
  ret += ((20.0 * Math.sin(lat * PI) + 40.0 * Math.sin((lat / 3.0) * PI)) * 2.0) / 3.0
  ret +=
    ((160.0 * Math.sin((lat / 12.0) * PI) + 320 * Math.sin((lat * PI) / 30.0)) * 2.0) / 3.0
  return ret
}

/**
 * 经度偏移量计算
 * 输入:经度 - 105°,纬度 - 35°(归一化基准点)
 */
function transformLng(lng: number, lat: number): number {
  let ret =
    300.0 +
    lng +
    2.0 * lat +
    0.1 * lng * lng +
    0.1 * lng * lat +
    0.1 * Math.sqrt(Math.abs(lng))
  ret += ((20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0) / 3.0
  ret += ((20.0 * Math.sin(lng * PI) + 40.0 * Math.sin((lng / 3.0) * PI)) * 2.0) / 3.0
  ret +=
    ((150.0 * Math.sin((lng / 12.0) * PI) + 300.0 * Math.sin((lng / 30.0) * PI)) * 2.0) /
    3.0
  return ret
}

/**
 * WGS-84 → GCJ-02
 *
 * 将 GPS 原始坐标转换为国产地图所需的加密坐标。
 * 典型调用场景:
 *   - 拿到 uni.getLocation({ type: 'wgs84' }) 的定位后,转为 GCJ-02 传入微信 map 组件
 *   - 拿到天地图 POI 的 WGS-84 坐标后,转为 GCJ-02 传入花瓣地图
 *
 * @param wgsLng  WGS-84 经度
 * @param wgsLat  WGS-84 纬度
 * @returns GCJ-02 坐标 { lng, lat }
 */
export function wgs84ToGcj02(wgsLng: number, wgsLat: number): { lng: number; lat: number } {
  if (outOfChina(wgsLng, wgsLat)) {
    return { lng: wgsLng, lat: wgsLat }
  }
  let dlat = transformLat(wgsLng - 105.0, wgsLat - 35.0)
  let dlng = transformLng(wgsLng - 105.0, wgsLat - 35.0)
  const radlat = (wgsLat / 180.0) * PI
  let magic = Math.sin(radlat)
  magic = 1 - EE * magic * magic
  const sqrtmagic = Math.sqrt(magic)
  dlat = (dlat * 180.0) / (((A * (1 - EE)) / (magic * sqrtmagic)) * PI)
  dlng = (dlng * 180.0) / ((A / sqrtmagic) * Math.cos(radlat) * PI)
  return { lng: wgsLng + dlng, lat: wgsLat + dlat }
}

/**
 * GCJ-02 → WGS-84(近似逆运算)
 *
 * 将国产地图加密坐标还原为 GPS 标准坐标。
 * 典型调用场景:
 *   - 微信 map 的 regionchange 回调返回 GCJ-02,转为 WGS-84 后调天地图逆地理编码
 *   - 花瓣地图的 camerapositionchange 回调返回 GCJ-02,同理需转为 WGS-84
 *
 * 精度说明:结果与精确迭代法偏差 <0.5m,满足业务场景需求。
 *
 * @param gcjLng  GCJ-02 经度
 * @param gcjLat  GCJ-02 纬度
 * @returns WGS-84 坐标 { lng, lat }
 */
export function gcj02ToWgs84(gcjLng: number, gcjLat: number): { lng: number; lat: number } {
  if (outOfChina(gcjLng, gcjLat)) {
    return { lng: gcjLng, lat: gcjLat }
  }
  let dlat = transformLat(gcjLng - 105.0, gcjLat - 35.0)
  let dlng = transformLng(gcjLng - 105.0, gcjLat - 35.0)
  const radlat = (gcjLat / 180.0) * PI
  let magic = Math.sin(radlat)
  magic = 1 - EE * magic * magic
  const sqrtmagic = Math.sqrt(magic)
  dlat = (dlat * 180.0) / (((A * (1 - EE)) / (magic * sqrtmagic)) * PI)
  dlng = (dlng * 180.0) / ((A / sqrtmagic) * Math.cos(radlat) * PI)
  return { lng: gcjLng - dlng, lat: gcjLat - dlat }
}

12.2 地图服务配置 — src/utils/mapConfig.ts

/**
 * 地图服务配置
 *
 * 集中管理天地图(Tianditu)的 API 凭证和接口地址。
 * 所有地图相关模块统一从此处导入,避免硬编码散落在各文件中。
 *
 * 天地图开发者平台:https://cloudcenter.tianditu.gov.cn/center/development/myApp
 * - 浏览器端 Key:用于 JS API 加载和前端 REST 请求(依赖浏览器 Cookie 会话鉴权)
 * - 服务端 Key:用于后端直接调用 REST API(无会话依赖,适合 Node/云函数)
 */

/** 天地图 API Token — 浏览器端(JS API + 前端 REST 请求) */
export const TIANDITU_KEY = '<YOUR_TIANDITU_BROWSER_KEY>'

/** 天地图 API Token — 服务端(后端 REST 请求,暂未使用,预留扩展) */
export const TIANDITU_SERVER_KEY = '<YOUR_TIANDITU_SERVER_KEY>'

/** 天地图逆地理编码接口 — 坐标 → 地址 */
export const TIANDITU_GEOCODER_URL = 'https://api.tianditu.gov.cn/geocoder'

/** 天地图搜索接口 — POI检索 / 建议搜索 / 周边搜索 */
export const TIANDITU_SEARCH_URL = 'https://api.tianditu.gov.cn/v2/search'

/** 天地图 JS API 4.0 加载地址(含浏览器端 Token) */
export const TIANDITU_JS_API_URL = `https://api.tianditu.gov.cn/api?v=4.0&tk=${TIANDITU_KEY}`

12.3 核心逻辑 Composable — src/hooks/useMapPicker.ts

/**
 * 地图选点核心逻辑 composable
 *
 * ═══════════════════════════════════════════════════════════════
 * 设计目标:将业务逻辑与各平台视图层完全解耦
 * ═══════════════════════════════════════════════════════════════
 * 本 hook 不依赖任何特定地图组件(天地图 JS / 微信 map / 花瓣地图),
 * 只负责:定位、逆地理编码、POI 搜索、选中状态管理、结果输出。
 * 各平台视图层(index.vue)通过导入本 hook 的函数并传入坐标来驱动交互。
 *
 * ──────────────────────────────────────────────
 * 坐标语义约定(务必遵守):
 * ──────────────────────────────────────────────
 * centerLat / centerLng  — 地图中心点的「显示坐标」
 *   ├── H5 / APP-PLUS   → WGS-84(天地图 JS 直接渲染)
 *   ├── MP-WEIXIN        → GCJ-02(微信 map 原生组件)
 *   └── APP-HARMONY      → GCJ-02(花瓣地图原生组件)
 *
 * selectedLocation       — 选中的位置信息,坐标字段始终为 WGS-84
 *   └── 所有平台调用 updateSelected() 前必须将坐标转为 WGS-84
 *
 * 天地图 API              — 输入输出均为 WGS-84 ≈ CGCS2000
 * ──────────────────────────────────────────────
 */
import { ref, computed } from 'vue'
import { TIANDITU_KEY, TIANDITU_GEOCODER_URL, TIANDITU_SEARCH_URL } from '@/utils/mapConfig'
import { wgs84ToGcj02 } from '@/utils/coordTransform'

// ══════════════════════════════════════════════════
// 类型定义
// ══════════════════════════════════════════════════

/** 选中的位置信息(坐标字段统一为 WGS-84) */
export interface LocationInfo {
  /** 地点名称(如"天安门") */
  name: string
  /** 完整地址(如"北京市东城区东长安街") */
  address: string
  /** 纬度(WGS-84) */
  latitude: number
  /** 经度(WGS-84) */
  longitude: number
  /** 省 */
  province?: string
  /** 市 */
  city?: string
  /** 区/县 */
  district?: string
}

/** POI 搜索结果项(天地图返回的坐标为 WGS-84) */
export interface PoiItem {
  /** 唯一标识(用于列表渲染 key 和城市统计项判断前缀) */
  key: string
  /** 地点名称 */
  name: string
  /** 地址 */
  address: string
  /** 纬度(WGS-84) */
  latitude: number
  /** 经度(WGS-84) */
  longitude: number
}

/** 地图选点最终返回结果(含双坐标系,方便调用方按需取用) */
export interface MapPickerResult {
  /** 地点名称 */
  name: string
  /** 完整地址 */
  address: string
  /** WGS-84 纬度 — 天地图/后端存储/GPS 通用 */
  latitude: number
  /** WGS-84 经度 */
  longitude: number
  /** GCJ-02 纬度 — 供微信小程序/鸿蒙地图渲染用 */
  gcj02Latitude: number
  /** GCJ-02 经度 */
  gcj02Longitude: number
}

// ══════════════════════════════════════════════════
// Composable 主函数
// ══════════════════════════════════════════════════

export function useMapPicker(debounceMs = 300) {
  // ── 响应式状态 ──────────────────────────────

  /** 地图中心纬度(显示坐标系,随平台不同) */
  const centerLat = ref(0)
  /** 地图中心经度(显示坐标系,随平台不同) */
  const centerLng = ref(0)
  /** 是否已完成首次定位 */
  const located = ref(false)
  /** 是否正在定位中 */
  const locating = ref(false)

  /** 当前选中的位置信息(坐标为 WGS-84) */
  const selectedLocation = ref<LocationInfo>({
    name: '',
    address: '',
    latitude: 0,
    longitude: 0,
  })

  /** 搜索关键词 */
  const keyword = ref('')
  /** POI 搜索结果列表 */
  const poiList = ref<PoiItem[]>([])
  /** 搜索加载态 */
  const searchLoading = ref(false)
  /** 是否显示 POI 结果列表 */
  const showPoiList = ref(false)

  /** 搜索防抖定时器 */
  let searchTimer: ReturnType<typeof setTimeout> | null = null

  // ── 计算属性 ────────────────────────────────

  /** 是否已有有效选中(以地址非空为判断依据) */
  const hasSelection = computed(() => !!selectedLocation.value.address)

  // ══════════════════════════════════════════════
  // 定位模块
  // ══════════════════════════════════════════════

  /**
   * 获取当前位置的 WGS-84 坐标
   *
   * 调用 uni.getLocation 并指定 type: 'wgs84',确保返回 GPS 原始坐标。
   * 开启 isHighAccuracy 可融合 GPS + Wi-Fi + 基站,在开阔区域可达米级精度。
   * highAccuracyExpireTime 设置 5 秒超时,避免弱信号下无限等待。
   */
  function getCurrentLocation(): Promise<{ latitude: number; longitude: number }> {
    return new Promise((resolve, reject) => {
      uni.getLocation({
        type: 'wgs84',
        isHighAccuracy: true,
        highAccuracyExpireTime: 5000,
        success: (res) => {
          resolve({ latitude: res.latitude, longitude: res.longitude })
        },
        fail: (err) => {
          console.warn('[useMapPicker] 定位失败:', err.errMsg)
          reject(err)
        },
      })
    })
  }

  /**
   * 初始化定位
   *
   * 尝试获取当前位置,成功后将 WGS-84 坐标存入 centerLat/centerLng。
   * 各平台在 onLoad 中会按需将此 WGS-84 坐标转为对应的显示坐标系。
   * 定位失败时回退到北京天安门坐标(39.915, 116.404)。
   */
  async function initLocation() {
    locating.value = true
    try {
      const pos = await getCurrentLocation()
      centerLat.value = pos.latitude
      centerLng.value = pos.longitude
      located.value = true
    } catch {
      // 定位失败 → 默认北京天安门
      centerLat.value = 39.915
      centerLng.value = 116.404
      located.value = false
    } finally {
      locating.value = false
    }
  }

  // ══════════════════════════════════════════════
  // 逆地理编码模块
  // ══════════════════════════════════════════════

  /**
   * 根据经纬度获取地址信息(逆地理编码)
   *
   * 调用天地图 geocoder REST API,将 WGS-84 坐标转为结构化地址。
   * 返回的 LocationInfo 中坐标字段原样保留输入值。
   * 请求失败时返回坐标字符串作为兜底地址。
   *
   * @param lng  WGS-84 经度
   * @param lat  WGS-84 纬度
   */
  async function reverseGeocode(lng: number, lat: number): Promise<LocationInfo> {
    try {
      const res = await new Promise<any>((resolve, reject) => {
        uni.request({
          url: TIANDITU_GEOCODER_URL,
          data: {
            postStr: JSON.stringify({ lon: lng, lat, ver: 1 }),
            type: 'geocode',
            tk: TIANDITU_KEY,
          },
          success: (r) => resolve(r),
          fail: (e) => reject(e),
        })
      })

      // status === '0' 表示天地图接口调用成功
      if (res.statusCode === 200 && res.data?.status === '0') {
        const result = res.data.result
        const comp = result.addressComponent || {}
        return {
          name: comp.poiName || result.formatted_address || '',
          address: result.formatted_address || '',
          latitude: lat,
          longitude: lng,
          province: comp.province || '',
          city: comp.city || '',
          district: comp.district || '',
        }
      }
    } catch (e) {
      console.warn('[useMapPicker] 逆地理编码失败:', e)
    }

    // 兜底:返回坐标字符串作为地址
    return {
      name: '',
      address: `${lng.toFixed(6)}, ${lat.toFixed(6)}`,
      latitude: lat,
      longitude: lng,
    }
  }

  /**
   * 更新选中位置并触发逆地理编码
   *
   * 此函数是各平台视图层与业务逻辑层的关键桥梁。
   * 调用方必须在传入坐标前完成坐标系转换,确保参数为 WGS-84。
   *
   * @param wgsLng  WGS-84 经度
   * @param wgsLat  WGS-84 纬度
   */
  async function updateSelected(wgsLng: number, wgsLat: number) {
    // 先清空旧数据,让 UI 进入加载态
    selectedLocation.value = { name: '', address: '', latitude: wgsLat, longitude: wgsLng }
    const info = await reverseGeocode(wgsLng, wgsLat)
    // 保留原始坐标,用逆地理编码结果填充名称和地址
    selectedLocation.value = { ...info, latitude: wgsLat, longitude: wgsLng }
  }

  // ══════════════════════════════════════════════
  // POI 搜索模块
  // ══════════════════════════════════════════════

  /**
   * 解析天地图搜索 API 的响应数据
   *
   * 天地图搜索接口根据结果类型返回不同结构,需分别处理:
   *
   * | resultType | 含义               | 数据位置                      |
   * |------------|--------------------|-------------------------------|
   * | 1          | POI 列表           | pois[] 或 result.data[]       |
   * | 2          | 当前范围无结果     | statistics.priorityCitys[]    |
   * |            | → 按城市统计摘要   | statistics.allAdmins[]        |
   * | 3          | 行政区划           | area 对象                     |
   *
   * 天地图 POI 坐标字段为 lonlat("lng,lat" 格式),坐标系为 WGS-84
   */
  function parseSearchResult(resData: any): PoiItem[] {
    if (resData?.status?.infocode !== 1000) return []

    const resultType = resData.resultType

    // resultType=1:POI 列表(常规搜索结果)
    if (resultType === 1) {
      const data = resData.pois || resData.result?.data || []
      return data.map((item: any) => {
        const [lng, lat] = (item.lonlat || '').split(',').map(Number)
        return {
          key: item.hotPointID || item.uid || item.lonlat || `${lng}_${lat}`,
          name: item.name || '',
          address: item.address || item.adminName || '',
          latitude: lat || Number(item.lat) || 0,
          longitude: lng || Number(item.lon) || 0,
        }
      })
    }

    // resultType=2:当前 mapBound 内无结果,返回按城市统计
    // 用户点击某城市后,会以该城市为中心重新搜索
    if (resultType === 2 && resData.statistics) {
      const cities = resData.statistics.priorityCitys || resData.statistics.allAdmins || []
      return cities.map((item: any) => {
        const [lng, lat] = (item.lonlat || '').split(',').map(Number)
        return {
          key: `city_${item.adminCode || item.lonlat}`,
          name: `${item.adminName}${item.count}个结果)`,
          address: `点击在${item.adminName}中搜索`,
          latitude: lat,
          longitude: lng,
        }
      })
    }

    // resultType=3:行政区划(如搜索"福州市"直接匹配到行政区)
    if (resultType === 3 && resData.area) {
      const area = resData.area
      const [lng, lat] = (area.lonlat || '').split(',').map(Number)
      if (lng && lat) {
        return [
          {
            key: `area_${area.adminCode || area.lonlat}`,
            name: area.name || '',
            address: area.name || '',
            latitude: lat,
            longitude: lng,
          },
        ]
      }
    }

    return []
  }

  /**
   * POI 关键词搜索(完整结果)
   *
   * 使用 queryType=1 普通检索,返回最多 20 条结果。
   * 搜索范围默认覆盖中国全境(CHINA_BOUNDS),避免因当前视野过小搜不到远距离地点。
   *
   * @param kw        搜索关键词
   * @param mapBound  搜索范围经纬度边界(可选,默认中国全境)
   */
  async function searchPoi(kw: string, mapBound?: string) {
    if (!kw.trim()) {
      poiList.value = []
      showPoiList.value = false
      return
    }

    searchLoading.value = true
    showPoiList.value = true
    const bound = mapBound || CHINA_BOUNDS

    try {
      const res = await new Promise<any>((resolve, reject) => {
        uni.request({
          url: TIANDITU_SEARCH_URL,
          data: {
            postStr: JSON.stringify({
              keyWord: kw,
              level: '12',
              mapBound: bound,
              queryType: '1',
              start: '0',
              count: '20',
            }),
            type: 'query',
            tk: TIANDITU_KEY,
          },
          success: (r) => resolve(r),
          fail: (e) => reject(e),
        })
      })

      if (res.statusCode === 200 && res.data?.status?.infocode === 1000) {
        poiList.value = parseSearchResult(res.data)
      } else {
        poiList.value = []
      }
    } catch (e) {
      console.warn('[useMapPicker] POI搜索失败:', e)
      poiList.value = []
    } finally {
      searchLoading.value = false
    }
  }

  /**
   * POI 搜索建议(联想词)
   *
   * 与 searchPoi 共用同一接口,但仅请求 10 条,
   * 用于用户输入过程中的即时联想,提供更轻量的响应。
   *
   * @param kw        搜索关键词
   * @param mapBound  搜索范围经纬度边界(可选,默认中国全境)
   */
  async function suggestPoi(kw: string, mapBound?: string) {
    if (!kw.trim()) {
      poiList.value = []
      showPoiList.value = false
      return
    }

    const bound = mapBound || CHINA_BOUNDS

    try {
      const res = await new Promise<any>((resolve, reject) => {
        uni.request({
          url: TIANDITU_SEARCH_URL,
          data: {
            postStr: JSON.stringify({
              keyWord: kw,
              level: '12',
              mapBound: bound,
              queryType: '1',
              start: '0',
              count: '10',
            }),
            type: 'query',
            tk: TIANDITU_KEY,
          },
          success: (r) => resolve(r),
          fail: (e) => reject(e),
        })
      })

      if (res.statusCode === 200 && res.data?.status?.infocode === 1000) {
        poiList.value = parseSearchResult(res.data)
        showPoiList.value = poiList.value.length > 0
      }
    } catch (e) {
      console.warn('[useMapPicker] 建议搜索失败:', e)
    }
  }

  /**
   * 带防抖的搜索输入处理
   *
   * 用户每输入一个字符都会触发,通过 debounceMs(默认 300ms)延迟发送请求,
   * 避免高频调用搜索 API。
   *
   * @param value     当前输入值
   * @param mapBound  搜索范围经纬度边界(可选,仅 H5 端传入当前视野范围)
   */
  function onSearchInput(value: string, mapBound?: string) {
    keyword.value = value
    if (searchTimer) clearTimeout(searchTimer)
    searchTimer = setTimeout(() => {
      suggestPoi(value, mapBound)
    }, debounceMs)
  }

  /**
   * 回车确认搜索
   *
   * 用户按下键盘搜索键时触发,清除防抖等待,立即发起更精确的 20 条搜索。
   *
   * @param mapBound  搜索范围经纬度边界(可选,仅 H5 端传入当前视野范围)
   */
  function onSearchConfirm(mapBound?: string) {
    if (searchTimer) clearTimeout(searchTimer)
    searchPoi(keyword.value, mapBound)
  }

  /** 清空搜索关键词和结果,恢复初始状态 */
  function clearSearch() {
    keyword.value = ''
    poiList.value = []
    showPoiList.value = false
  }

  /**
   * 选中某条 POI 搜索结果
   *
   * 更新 selectedLocation 并移动地图中心。
   * 特殊处理:如果 key 以 "city_" 开头(城市统计项),
   * 则以该城市为中心重新搜索,展开该城市下的 POI 结果。
   *
   * @param item  POI 搜索结果项(坐标为 WGS-84)
   */
  async function selectPoi(item: PoiItem) {
    // 先更新选中位置(WGS-84)
    selectedLocation.value = {
      name: item.name,
      address: item.address,
      latitude: item.latitude,
      longitude: item.longitude,
    }
    // 临时写入 WGS-84 到中心坐标,
    // 各平台 handleSelectPoi 会覆盖为对应显示坐标系
    centerLat.value = item.latitude
    centerLng.value = item.longitude
    showPoiList.value = false

    // 城市统计项 → 移动到该城市后自动重新搜索
    if (item.key.startsWith('city_')) {
      const cityBound = estimateMapBounds(item.longitude, item.latitude, 15)
      searchPoi(keyword.value, cityBound)
      return
    }

    // POI 地址为空时,通过逆地理编码补充
    if (!item.address && item.latitude && item.longitude) {
      const info = await reverseGeocode(item.longitude, item.latitude)
      selectedLocation.value = { ...selectedLocation.value, ...info }
    }
  }

  // ══════════════════════════════════════════════
  // 结果输出模块
  // ══════════════════════════════════════════════

  /**
   * 获取最终选点结果
   *
   * 将 selectedLocation(WGS-84)转换为双坐标系的 MapPickerResult。
   * 调用方通过 eventChannel.emit('selectLocation', getResult()) 传出。
   */
  function getResult(): MapPickerResult {
    const gcj = wgs84ToGcj02(selectedLocation.value.longitude, selectedLocation.value.latitude)
    return {
      name: selectedLocation.value.name,
      address: selectedLocation.value.address,
      latitude: selectedLocation.value.latitude,
      longitude: selectedLocation.value.longitude,
      gcj02Latitude: gcj.lat,
      gcj02Longitude: gcj.lng,
    }
  }

  // ── 统一导出 ────────────────────────────────
  return {
    // 状态
    centerLat,
    centerLng,
    located,
    locating,
    selectedLocation,
    keyword,
    poiList,
    searchLoading,
    showPoiList,
    hasSelection,
    // 方法
    initLocation,
    getCurrentLocation,
    updateSelected,
    reverseGeocode,
    onSearchInput,
    onSearchConfirm,
    clearSearch,
    selectPoi,
    getResult,
  }
}

// ══════════════════════════════════════════════════
// 常量与工具函数
// ══════════════════════════════════════════════════

/** 中国全境经纬度范围,用于 POI 搜索时不限制区域 */
const CHINA_BOUNDS = '73.66,3.86,135.05,53.55'

/**
 * 根据中心点和半径估算地图边界
 *
 * 将经纬度视为平面近似计算,适用于 15km 以内的小范围搜索。
 * 纬度方向 1° ≈ 111km,经度方向 1° ≈ 111km / cos(lat)。
 *
 * @param lng       中心经度
 * @param lat       中心纬度
 * @param radiusKm  半径(千米)
 * @returns "左下经度,左下纬度,右上经度,右上纬度" 格式的边界字符串
 */
function estimateMapBounds(lng: number, lat: number, radiusKm: number): string {
  const dLat = radiusKm / 111
  const dLng = (radiusKm / 111) * (1 / Math.cos((lat * Math.PI) / 180))
  return `${(lng - dLng).toFixed(6)},${(lat - dLat).toFixed(6)},${(lng + dLng).toFixed(6)},${(lat + dLat).toFixed(6)}`
}

12.4 地图选点主页面 — src/pages-sub/map/index.vue

<route lang="json5">
{
  style: {
    navigationStyle: 'custom',
    navigationBarTitleText: '选择位置',
  },
}
</route>

<template>
  <m-page>
    <m-header title="选择位置" :hasBack="true"></m-header>
    <m-body padding="0">
      <view
        class="map-picker"
        :style="{
          width: '100%',
          height: `calc(100vh - ${rpxToPx(88)}px - ${safeAreaInsets?.top || 0}px)`,
        }"
      >
        <!-- 搜索栏(内含搜索结果列表) -->
        <SearchBar
          :model-value="keyword"
          :loading="searchLoading"
          :poi-list="poiList"
          :show-poi-list="showPoiList"
          @update:model-value="(v: string) => (keyword = v)"
          @search="handleSearchInput"
          @clear="clearSearch"
          @select="handleSelectPoi"
        />

        <!-- ═══════ H5 端:天地图 JS API 直引模式 ═══════ -->
        <!-- #ifdef H5 -->
        <view id="tianditu-container" class="map-container" />
        <view class="center-pin">
          <image class="pin-icon" src="https://api.tianditu.gov.cn/img/map/markerA.png" mode="widthFix" />
        </view>
        <!-- #endif -->

        <!-- ═══════ APP 端:renderjs 天地图桥接模式 ═══════ -->
        <!-- #ifdef APP-PLUS -->
        <view
          id="tianditu-container"
          class="map-container"
          :change:prop="mapRender.propChanged"
          :prop="mapRenderProp"
        />
        <!-- #endif -->

        <!-- ═══════ 微信小程序端:原生 map 组件 ═══════ -->
        <!-- #ifdef MP-WEIXIN -->
        <map
          id="mp-map"
          class="map-container"
          :latitude="centerLat"
          :longitude="centerLng"
          :markers="mpMarkers"
          :show-location="true"
          @regionchange="mpOnRegionChange"
          @tap="mpOnMapTap"
        />
        <!-- #endif -->

        <!-- ═══════ 鸿蒙端:花瓣地图原生组件 ═══════ -->
        <!-- #ifdef APP-HARMONY -->
        <embed
          v-if="centerLat > 0"
          class="map-container"
          tag="map"
          :options="harmonyMapOptions"
          @mapclick="onHarmonyMapClick"
          @camerapositionchange="onHarmonyCameraPositionChange"
        />
        <view v-if="centerLat > 0" class="center-pin">
          <image class="pin-icon" src="https://api.tianditu.gov.cn/img/map/markerA.png" mode="widthFix" />
        </view>
        <!-- #endif -->

        <!-- 底部信息栏:展示选中位置 + 确认按钮 -->
        <view class="bottom-bar">
          <view v-if="hasSelection" class="selected-info">
            <text class="selected-name">{{ selectedLocation.name || '当前位置' }}</text>
            <text class="selected-address">{{ selectedLocation.address }}</text>
          </view>
          <view v-else class="selected-info">
            <text class="selected-name loading-text">正在获取位置信息...</text>
          </view>
          <view class="confirm-btn" :class="{ disabled: !hasSelection }" @tap="confirmLocation">
            <text>确定选择</text>
          </view>
        </view>
      </view>
    </m-body>
  </m-page>
</template>

<!-- ════════ renderjs 回调桥接(选项式 API) ════════ -->
<script lang="ts">
let __mapMoveEndBridge: ((data: { lat: number; lng: number }) => void) | null = null
let __mapReadyBridge: (() => void) | null = null

export default {
  methods: {
    /** 拖拽结束回调 — 转发给 setup 侧 */
    handleMapMoveEnd(data: { lat: number; lng: number }) {
      __mapMoveEndBridge?.(data)
    },
    /** 天地图 JS API 加载就绪回调 — 转发给 setup 侧 */
    handleMapReady() {
      __mapReadyBridge?.()
    },
  },
}
</script>

<script lang="ts" setup>
import { rpxToPx, getSystemInfoSyncCompat } from '@/utils/tools'
import { TIANDITU_JS_API_URL, TIANDITU_KEY } from '@/utils/mapConfig'
import { useMapPicker, type PoiItem } from '@/hooks/useMapPicker'
import { gcj02ToWgs84, wgs84ToGcj02 } from '@/utils/coordTransform'
import SearchBar from './components/SearchBar.vue'
// #ifdef APP-HARMONY
import '@/uni_modules/native-harmony-map'
// #endif

const { safeAreaInsets } = getSystemInfoSyncCompat()

const {
  centerLat,
  centerLng,
  located,
  locating,
  selectedLocation,
  keyword,
  poiList,
  searchLoading,
  showPoiList,
  hasSelection,
  initLocation,
  updateSelected,
  onSearchInput,
  onSearchConfirm,
  clearSearch,
  selectPoi,
  getResult,
} = useMapPicker()

/** 传给 renderjs 的指令数据 */
const mapRenderProp = ref<Record<string, any>>({})

// ════════════════════════════════════════════
// H5 端:直接操作 DOM 加载天地图
// ════════════════════════════════════════════
// #ifdef H5
let h5Map: any = null
let h5Marker: any = null

function initH5Map(lat: number, lng: number) {
  const TMap = (window as any).T
  if (!TMap) {
    const script = document.createElement('script')
    script.src = TIANDITU_JS_API_URL
    script.onload = () => createH5Map(lat, lng)
    document.head.appendChild(script)
  } else {
    createH5Map(lat, lng)
  }
}

function createH5Map(lat: number, lng: number) {
  const TMap = (window as any).T
  h5Map = new TMap.Map('tianditu-container')
  h5Map.centerAndZoom(new TMap.LngLat(lng, lat), 15)

  h5Marker = new TMap.Marker(new TMap.LngLat(lng, lat))
  h5Map.addOverLay(h5Marker)

  h5Map.addEventListener('moveend', () => {
    const center = h5Map.getCenter()
    updateSelected(center.getLng(), center.getLat())
  })

  h5Map.addEventListener('click', (e: any) => {
    const lng = e.lnglat.getLng()
    const lat = e.lnglat.getLat()
    h5Map.panTo(new TMap.LngLat(lng, lat))
    h5Marker.setLngLat(new TMap.LngLat(lng, lat))
    updateSelected(lng, lat)
  })
}

function h5MoveTo(lat: number, lng: number) {
  if (!h5Map) return
  const TMap = (window as any).T
  h5Map.panTo(new TMap.LngLat(lng, lat))
  if (h5Marker) h5Marker.setLngLat(new TMap.LngLat(lng, lat))
}

function h5GetMapBound(): string | undefined {
  if (!h5Map) return undefined
  const b = h5Map.getBounds()
  const sw = b.getSouthWest()
  const ne = b.getNorthEast()
  return `${sw.getLng()},${sw.getLat()},${ne.getLng()},${ne.getLat()}`
}
// #endif

// ════════════════════════════════════════════
// 微信小程序端:原生 map 组件(GCJ-02 坐标系)
// ════════════════════════════════════════════
// #ifdef MP-WEIXIN
const mpMapCtx = ref<UniApp.MapContext | null>(null)

const mpMarkers = computed(() => {
  if (!selectedLocation.value.latitude) return []
  const gcj = wgs84ToGcj02(selectedLocation.value.longitude, selectedLocation.value.latitude)
  return [
    {
      id: 1,
      latitude: gcj.lat,
      longitude: gcj.lng,
      width: 32,
      height: 42,
      anchor: { x: 0.5, y: 1 },
    },
  ]
})

function mpOnRegionChange(e: any) {
  if (e.type === 'end' && e.causedBy === 'gesture') {
    mpMapCtx.value?.getCenterLocation({
      success: (res) => {
        centerLat.value = res.latitude
        centerLng.value = res.longitude
        const wgs = gcj02ToWgs84(res.longitude, res.latitude)
        updateSelected(wgs.lng, wgs.lat)
      },
    })
  }
}

function mpOnMapTap(e: any) {
  const gcjLng = e.detail.longitude
  const gcjLat = e.detail.latitude
  centerLat.value = gcjLat
  centerLng.value = gcjLng
  const wgs = gcj02ToWgs84(gcjLng, gcjLat)
  updateSelected(wgs.lng, wgs.lat)
}

function mpMoveTo(wgsLat: number, wgsLng: number) {
  const gcj = wgs84ToGcj02(wgsLng, wgsLat)
  centerLat.value = gcj.lat
  centerLng.value = gcj.lng
  mpMapCtx.value?.moveToLocation({
    latitude: gcj.lat,
    longitude: gcj.lng,
  })
}
// #endif

// ════════════════════════════════════════════
// 页面初始化
// ════════════════════════════════════════════
onLoad(async () => {
  await initLocation()
  const wgsLat = centerLat.value
  const wgsLng = centerLng.value

  // #ifdef H5
  initH5Map(wgsLat, wgsLng)
  updateSelected(wgsLng, wgsLat)
  // #endif

  // #ifdef APP-PLUS
  mapRenderProp.value = {
    type: 'init',
    latitude: wgsLat,
    longitude: wgsLng,
    tk: TIANDITU_KEY,
    timestamp: Date.now(),
  }
  __mapReadyBridge = () => {
    updateSelected(wgsLng, wgsLat)
    __mapReadyBridge = null
  }
  // #endif

  // #ifndef APP-PLUS
  // #ifdef MP-WEIXIN
  nextTick(() => {
    mpMapCtx.value = uni.createMapContext('mp-map', instance?.proxy)
  })
  const mpGcj = wgs84ToGcj02(wgsLng, wgsLat)
  centerLat.value = mpGcj.lat
  centerLng.value = mpGcj.lng
  // #endif

  // #ifdef APP-HARMONY
  const hmGcj = wgs84ToGcj02(wgsLng, wgsLat)
  centerLat.value = hmGcj.lat
  centerLng.value = hmGcj.lng
  // #endif

  updateSelected(wgsLng, wgsLat)
  // #endif
})

// ════════════════════════════════════════════
// 搜索事件处理
// ════════════════════════════════════════════

function handleSearchInput(value: string) {
  // #ifdef H5
  onSearchInput(value, h5GetMapBound())
  // #endif
  // #ifndef H5
  onSearchInput(value)
  // #endif
}

function handleSearchConfirm() {
  // #ifdef H5
  onSearchConfirm(h5GetMapBound())
  // #endif
  // #ifndef H5
  onSearchConfirm()
  // #endif
}

function handleSelectPoi(item: PoiItem) {
  selectPoi(item)
  // #ifdef H5
  h5MoveTo(item.latitude, item.longitude)
  // #endif
  // #ifdef APP-PLUS
  mapRenderProp.value = {
    type: 'moveTo',
    latitude: item.latitude,
    longitude: item.longitude,
    timestamp: Date.now(),
  }
  // #endif
  // #ifdef MP-WEIXIN
  mpMoveTo(item.latitude, item.longitude)
  // #endif
  // #ifdef APP-HARMONY
  harmonyMoveTo(item.latitude, item.longitude)
  // #endif
}

// ════════════════════════════════════════════
// 确认选择
// ════════════════════════════════════════════

const instance = getCurrentInstance()

function confirmLocation() {
  const eventChannel = instance?.proxy?.getOpenerEventChannel?.()
  if (eventChannel) {
    eventChannel.emit('selectLocation', getResult())
  }
  uni.navigateBack()
}

// ════════════════════════════════════════════
// APP renderjs 通信回调
// ════════════════════════════════════════════
// #ifdef APP-PLUS
__mapMoveEndBridge = (data: { lat: number; lng: number }) => {
  console.log('handleMapMoveEnd', data)
  updateSelected(data.lng, data.lat)
}
// #endif

// ════════════════════════════════════════════
// 鸿蒙端:花瓣地图原生组件交互
// ════════════════════════════════════════════
// #ifdef APP-HARMONY

const harmonyMoveToTs = ref(0)
let lastGeocodeTs = 0
const GEOCODE_DEBOUNCE = 400

const harmonyMapOptions = computed(() => ({
  latitude: centerLat.value,
  longitude: centerLng.value,
  scale: 15,
  showCompass: false,
  moveToLatitude: centerLat.value,
  moveToLongitude: centerLng.value,
  moveToTimestamp: harmonyMoveToTs.value,
}))

function harmonyUpdateSelected(gcjLng: number, gcjLat: number) {
  const now = Date.now()
  if (now - lastGeocodeTs < GEOCODE_DEBOUNCE) return
  lastGeocodeTs = now
  const wgs = gcj02ToWgs84(gcjLng, gcjLat)
  centerLat.value = gcjLat
  centerLng.value = gcjLng
  updateSelected(wgs.lng, wgs.lat)
}

function onHarmonyMapClick(e: any) {
  const detail = e.detail as { latitude: number; longitude: number }
  harmonyUpdateSelected(detail.longitude, detail.latitude)
}

function onHarmonyCameraPositionChange(e: any) {
  const detail = e.detail as { latitude: number; longitude: number }
  harmonyUpdateSelected(detail.longitude, detail.latitude)
}

function harmonyMoveTo(wgsLat: number, wgsLng: number) {
  const gcj = wgs84ToGcj02(wgsLng, wgsLat)
  centerLat.value = gcj.lat
  centerLng.value = gcj.lng
  lastGeocodeTs = Date.now()
  harmonyMoveToTs.value = Date.now()
}
// #endif
</script>

<!-- ════════ APP 端 renderjs 模块 ════════ -->
<!-- #ifdef APP-PLUS -->
<script module="mapRender" lang="renderjs">
export default {
  data() {
    return {
      map: null,
    }
  },
  methods: {
    propChanged(newVal) {
      if (!newVal || !newVal.type) return
      if (newVal.type === 'init') {
        this.initMap(newVal.latitude, newVal.longitude, newVal.tk)
      } else if (newVal.type === 'moveTo') {
        this.moveToLocation(newVal.latitude, newVal.longitude)
      }
    },
    initMap(lat, lng, tk) {
      var style = document.createElement('style')
      style.textContent =
        '.renderjs-center-pin{position:fixed;top:50%;left:50%;z-index:900;width:32px;height:42px;pointer-events:none;transform:translate(-50%,-100%)}.renderjs-center-pin img{width:100%;height:100%}'
      document.head.appendChild(style)

      var pin = document.createElement('div')
      pin.className = 'renderjs-center-pin'
      pin.innerHTML = '<img src="https://api.tianditu.gov.cn/img/map/markerA.png" />'
      document.body.appendChild(pin)

      if (typeof T === 'undefined') {
        const script = document.createElement('script')
        script.src = 'https://api.tianditu.gov.cn/api?v=4.0&tk=' + tk
        script.onload = () => this.createMap(lat, lng)
        document.head.appendChild(script)
      } else {
        this.createMap(lat, lng)
      }
    },
    createMap(lat, lng) {
      this.map = new T.Map('tianditu-container')
      this.map.centerAndZoom(new T.LngLat(lng, lat), 15)

      this.map.addEventListener('moveend', () => {
        var center = this.map.getCenter()
        console.log('map moveend', JSON.stringify(center))
        this.$ownerInstance.callMethod('handleMapMoveEnd', {
          lat: center.getLat(),
          lng: center.getLng(),
        })
      })

      this.$ownerInstance.callMethod('handleMapReady')
    },
    moveToLocation(lat, lng) {
      if (!this.map) return
      this.map.panTo(new T.LngLat(lng, lat))
    },
  },
}
</script>
<!-- #endif -->

<style lang="less" scoped>
.map-picker {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  background: #f5f5f5;
}

.map-container {
  flex: 1;
  width: 100%;
  min-height: 0;
}

.center-pin {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -100%);
  width: 32px;
  height: 42px;
  z-index: 10;
  pointer-events: none;

  .pin-icon {
    width: 32px;
    height: 42px;
  }
}

.bottom-bar {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 20rpx 32rpx;
  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
  background: #fff;
  box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.08);
  z-index: 1000;
}

.selected-info {
  margin-bottom: 20rpx;

  .selected-name {
    display: block;
    font-size: 30rpx;
    font-weight: bold;
    color: #333;
    margin-bottom: 6rpx;
  }

  .selected-address {
    display: block;
    font-size: 24rpx;
    color: #999;
  }

  .loading-text {
    color: #999;
    font-weight: normal;
  }
}

.confirm-btn {
  width: 100%;
  height: 80rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--theme-color, #1890ff);
  color: #fff;
  font-size: 30rpx;
  font-weight: bold;
  border-radius: 40rpx;

  &.disabled {
    opacity: 0.5;
    pointer-events: none;
  }
}
</style>

12.5 搜索栏组件 — src/pages-sub/map/components/SearchBar.vue

<template>
  <view class="search-bar">
    <view class="search-inner">
      <text class="iconfont icon-search search-icon" />
      <input
        class="search-input"
        :value="modelValue"
        placeholder="搜索地点"
        confirm-type="search"
        @confirm="onConfirm"
      />
      <view v-if="loading" class="search-action">
        <wd-loading :size="16" />
      </view>
      <view v-else-if="modelValue" class="search-action" @tap="onClear">
        <text class="iconfont icon-close" />
      </view>
    </view>

    <PoiList
      v-show="showPoiList"
      :list="poiList"
      :loading="loading"
      @select="(item: PoiItem) => emit('select', item)"
    />
  </view>
</template>

<script lang="ts" setup>
import type { PoiItem } from '@/hooks/useMapPicker'
import PoiList from './PoiList.vue'

defineProps<{
  modelValue: string
  loading?: boolean
  poiList?: PoiItem[]
  showPoiList?: boolean
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
  search: [keyword: string]
  clear: []
  select: [item: PoiItem]
}>()

function onConfirm(e: any) {
  const value = e.detail?.value ?? e.target?.value ?? ''
  console.log('onConfirm', value)
  emit('update:modelValue', value)
  emit('search', value)
}

function onClear() {
  emit('update:modelValue', '')
  emit('clear')
}
</script>

<style lang="less" scoped>
.search-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 998;
  padding: 16rpx 24rpx;
  padding-top: calc(16rpx + var(--status-bar-height, 0px));
}

.search-inner {
  display: flex;
  align-items: center;
  height: 72rpx;
  padding: 0 20rpx;
  background: #fff;
  border-radius: 36rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.12);
}

.search-icon {
  font-size: 28rpx;
  color: #999;
  margin-right: 12rpx;
}

.search-input {
  flex: 1;
  height: 72rpx;
  font-size: 28rpx;
  color: #333;
}

.search-action {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40rpx;
  height: 40rpx;

  .iconfont {
    font-size: 24rpx;
    color: #ccc;
  }
}
</style>

12.6 POI 列表组件 — src/pages-sub/map/components/PoiList.vue

<script lang="ts" setup>
import type { PoiItem } from '@/hooks/useMapPicker'

defineProps<{
  list: PoiItem[]
  loading?: boolean
}>()

const emit = defineEmits<{
  select: [item: PoiItem]
}>()
</script>

<template>
  <scroll-view class="poi-list" scroll-y>
    <view v-if="loading" class="poi-status">
      <wd-loading :size="32" />
      <text class="poi-status-text">搜索中...</text>
    </view>

    <view v-else-if="list.length === 0" class="poi-status">
      <text class="poi-status-text">未找到相关地点</text>
    </view>

    <view v-for="item in list" :key="item.key" class="poi-item" @tap="emit('select', item)">
      <view class="poi-info">
        <text class="poi-name">{{ item.name }}</text>
        <text v-if="item.address" class="poi-address">{{ item.address }}</text>
      </view>
      <text class="iconfont icon-down poi-arrow rotate-270" />
    </view>
  </scroll-view>
</template>

<style lang="less" scoped>
.poi-list {
  position: relative;
  margin-top: 12rpx;
  max-height: 60vh;
  background: #fff;
  border-radius: 16rpx;
  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
  overflow: hidden;
  box-sizing: border-box;
}

.poi-status {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 48rpx 0;

  .poi-status-text {
    font-size: 26rpx;
    color: #999;
    margin-top: 12rpx;
  }
}

.poi-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 24rpx 28rpx;
  border-bottom: 1rpx solid #f0f0f0;

  &:last-child {
    border-bottom: none;
  }

  &:active {
    background: #f8f8f8;
  }
}

.poi-info {
  flex: 1;
  min-width: 0;
  overflow: hidden;
}

.poi-name {
  display: block;
  font-size: 28rpx;
  color: #333;
  font-weight: 500;
  margin-bottom: 6rpx;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.poi-address {
  display: block;
  font-size: 22rpx;
  color: #999;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.poi-arrow {
  flex-shrink: 0;
  font-size: 24rpx;
  color: #ccc;
  margin-left: 16rpx;
}
</style>

12.7 鸿蒙花瓣地图 UTS 插件 — src/uni_modules/native-harmony-map/utssdk/app-harmony/map.ets

import { map, mapCommon, MapComponent } from '@kit.MapKit';
import { AsyncCallback } from '@kit.BasicServicesKit';
import { defineNativeEmbed, NativeEmbedBuilderOptions } from '@dcloudio/uni-app-runtime';
import { NodeRenderType, } from '@ohos.arkui.node';

interface IMarkerItem {
  id: number
  latitude: number
  longitude: number
  iconPath?: string
  rotate?: number
  zIndex?: number
  visible?: boolean
  alpha?: number
}

interface ICircleItem {
  latitude: number
  longitude: number
  color?: number
  fillColor?: number
  radius: number
  strokeWidth?: number
}

interface MapBuilderOptions extends NativeEmbedBuilderOptions {
  longitude: number
  latitude: number
  scale?: number
  rotate?: number
  skew?: number
  minScale?: number
  maxScale?: number
  markers?: IMarkerItem[]
  circles?: ICircleItem[]
  showCompass?: boolean
  enableZoom?: boolean
  moveToLatitude?: number
  moveToLongitude?: number
  moveToTimestamp?: number
}

interface IMarkerTapDetailEvent {
  markerId: string
}

interface IMarkerTapEvent {
  type: string
  detail: IMarkerTapDetailEvent
}

interface IMapClickDetailEvent {
  latitude: number
  longitude: number
}

interface IMapClickEvent {
  type: string
  detail: IMapClickDetailEvent
}

interface ICameraPositionChangeEvent {
  type: string
  detail: IMapClickDetailEvent
}

export let mapControllerExportd: map.MapComponentController | null = null

@Component
struct HuaweiMapComponent {
  @Prop longitude: number
  @Prop latitude: number
  @Prop scaleVal: number
  @Prop skew: number
  @Prop rotateVal: number
  @Prop minScale: number
  @Prop maxScale: number
  @Prop showCompass: boolean = false
  @Prop markers: IMarkerItem[] = []
  @Prop circles: ICircleItem[] = []
  @Prop moveToLat: number = 0
  @Prop moveToLng: number = 0
  @Prop @Watch('onMoveToTimestampChange') moveToTs: number = 0
  onMarkerTap?: Function
  onMapClick?: Function
  onCameraPositionChange?: Function
  private TAG = "HuaweiMapDemo";
  private mapOptions?: mapCommon.MapOptions;
  private callback?: AsyncCallback<map.MapComponentController>;
  private mapController?: map.MapComponentController;
  private mapEventManager?: map.MapEventManager;
  private pollingTimer: number = -1;
  private lastCameraLat: number = 0;
  private lastCameraLng: number = 0;
  private isCameraStable: boolean = true;

  async setMarker(): Promise<void> {
    this.markers.forEach(async (marker) => {
      let markerOptions: mapCommon.MarkerOptions = {
        position: {
          latitude: marker.latitude,
          longitude: marker.longitude
        },
        icon: marker.iconPath,
        rotation: marker.rotate,
        alpha: marker.alpha ?? 1,
        clickable: true
      }
      let markerBoy = await this.mapController!.addMarker(markerOptions);
    })
  }

  async setCircle(): Promise<void> {
    this.circles.forEach(async (circle) => {
      let mapCircleOptions: mapCommon.MapCircleOptions = {
        center: {
          latitude: circle.latitude,
          longitude: circle.longitude
        },
        radius: circle.radius,
        strokeColor: circle.color,
        fillColor: circle.fillColor,
        strokeWidth: circle.strokeWidth,
        visible: true,
        zIndex: 15
      }
      let mapCircle: map.MapCircle = await this.mapController!.addCircle(mapCircleOptions);
    })
  }

  aboutToAppear(): void {
    this.mapOptions = {
      mapType: mapCommon.MapType.STANDARD,
      position: {
        target: {
          latitude: this.latitude,
          longitude: this.longitude,
        },
        zoom: this.scaleVal,
        tilt: this.skew,
        bearing: this.rotateVal
      },
      minZoom: this.minScale,
      maxZoom: this.maxScale,
    };

    this.callback = async (err, mapController) => {
      if (!err) {
        this.mapController = mapController;
        mapControllerExportd = mapController;
        this.mapEventManager = this.mapController.getEventManager();
        let callback = () => {
          console.info(this.TAG, `on-mapLoad`);
        }
        this.mapEventManager.on("mapLoad", callback);
        await this.setMarker()
        await this.setCircle()

        this.mapEventManager.on("markerClick", (marker: map.Marker) => {
          console.log('markerClick click', marker);
          if (this.onMarkerTap) {
            let res: IMarkerTapEvent = {
              type: "markertap",
              detail: {
                markerId: marker.getId()
              }
            }
            this.onMarkerTap(res)
          }
        })

        // 地图点击 → 内部移动相机 + 通知 Vue
        this.mapEventManager.on("mapClick", (latLng: mapCommon.LatLng) => {
          if (this.mapController) {
            let target: mapCommon.LatLng = { latitude: latLng.latitude, longitude: latLng.longitude }
            let cameraUpdate = map.newLatLng(target)
            this.mapController.moveCamera(cameraUpdate)
          }
          if (this.onMapClick) {
            let res: IMapClickEvent = {
              type: "mapclick",
              detail: {
                latitude: latLng.latitude,
                longitude: latLng.longitude
              }
            }
            this.onMapClick(res)
          }
        })

        // 拖拽/缩放结束 → 稳定性轮询检测
        this.lastCameraLat = this.latitude
        this.lastCameraLng = this.longitude
        this.pollingTimer = setInterval(() => {
          if (!this.mapController) return
          let cameraPosition = this.mapController.getCameraPosition()
          let lat = cameraPosition.target.latitude
          let lng = cameraPosition.target.longitude
          let latChanged = Math.abs(lat - this.lastCameraLat) > 0.000001
          let lngChanged = Math.abs(lng - this.lastCameraLng) > 0.000001

          if (latChanged || lngChanged) {
            this.lastCameraLat = lat
            this.lastCameraLng = lng
            this.isCameraStable = false
          } else if (!this.isCameraStable) {
            this.isCameraStable = true
            if (this.onCameraPositionChange) {
              let res: ICameraPositionChangeEvent = {
                type: "camerapositionchange",
                detail: {
                  latitude: lat,
                  longitude: lng
                }
              }
              this.onCameraPositionChange(res)
            }
          }
        }, 300)
      }
    };
  }

  onMoveToTimestampChange(): void {
    if (this.moveToTs > 0 && this.mapController) {
      this.isCameraStable = false
      let target: mapCommon.LatLng = { latitude: this.moveToLat, longitude: this.moveToLng }
      let cameraUpdate = map.newLatLng(target)
      this.mapController.moveCamera(cameraUpdate)
    }
  }

  aboutToDisappear(): void {
    if (this.pollingTimer !== -1) {
      clearInterval(this.pollingTimer)
      this.pollingTimer = -1
    }
  }

  build() {
    Stack() {
      MapComponent({
        mapOptions: this.mapOptions,
        mapCallback: this.callback
      })
        .width('100%')
        .height('100%')
    }
    .height('100%')
  }
}

@Builder
function MapBuilder(options: MapBuilderOptions) {
  HuaweiMapComponent({
    latitude: options.latitude,
    longitude: options.longitude,
    scaleVal: options.scale ?? 16,
    rotateVal: options.rotate ?? 0,
    skew: options.skew ?? 0,
    minScale: options.minScale ?? 2,
    maxScale: options.maxScale ?? 20,
    showCompass: options.showCompass ?? false,
    markers: options.markers ?? [],
    circles: options.circles ?? [],
    moveToLat: options.moveToLatitude ?? 0,
    moveToLng: options.moveToLongitude ?? 0,
    moveToTs: options.moveToTimestamp ?? 0,
    onMarkerTap: options?.on?.get('markertap'),
    onMapClick: options?.on?.get('mapclick'),
    onCameraPositionChange: options?.on?.get('camerapositionchange'),
  })
    .width(options.width)
    .height(options.height)
}

defineNativeEmbed('map', {
  builder: MapBuilder,
})

12.8 鸿蒙插件导出 — src/uni_modules/native-harmony-map/utssdk/app-harmony/index.uts

export { mapControllerExportd } from './map.ets'

12.9 测试页面 — src/pages-sub/map/test.vue

<route lang="json5">
{
  style: {
    navigationStyle: 'custom',
    navigationBarTitleText: '地图选点测试',
  },
}
</route>

<template>
  <m-page ref="pageRef">
    <m-header title="地图选点测试" :hasBack="true"></m-header>
    <m-body padding="32rpx">
      <view class="section">
        <text class="section-title">天地图选点 Demo</text>
        <text class="section-desc">基于天地图 JS API 4.0 实现,兼容 H5 / APP / 鸿蒙 / 微信小程序</text>

        <view class="action-card" @tap="openMapPicker">
          <text class="action-icon">📍</text>
          <view class="action-info">
            <text class="action-name">打开地图选点</text>
            <text class="action-desc">点击选择一个位置</text>
          </view>
          <text class="iconfont icon-down rotate-270 action-arrow" />
        </view>
      </view>

      <view v-if="selectedResult" class="result-section">
        <text class="section-title">选择结果</text>
        <view class="result-card">
          <view class="result-row">
            <text class="result-label">名称</text>
            <text class="result-value">{{ selectedResult.name || '(无)' }}</text>
          </view>
          <view class="result-row">
            <text class="result-label">地址</text>
            <text class="result-value">{{ selectedResult.address || '(无)' }}</text>
          </view>
          <view class="result-row">
            <text class="result-label">WGS-84</text>
            <text class="result-value">{{ selectedResult.latitude }}, {{ selectedResult.longitude }}</text>
          </view>
          <view class="result-row">
            <text class="result-label">GCJ-02</text>
            <text class="result-value">{{ selectedResult.gcj02Latitude }}, {{ selectedResult.gcj02Longitude }}</text>
          </view>
        </view>
      </view>

      <view class="section" style="margin-top: 40rpx">
        <text class="section-title">原生 API 对比</text>
        <text class="section-desc">uni.chooseLocation() 各平台内置选点</text>

        <view class="action-card" @tap="openNativePicker">
          <text class="action-icon">🗺️</text>
          <view class="action-info">
            <text class="action-name">原生 chooseLocation</text>
            <text class="action-desc">使用系统/SDK内置选点页面</text>
          </view>
          <text class="iconfont icon-down rotate-270 action-arrow" />
        </view>
      </view>

      <view v-if="nativeResult" class="result-section">
        <text class="section-title">原生选择结果</text>
        <view class="result-card">
          <view class="result-row">
            <text class="result-label">名称</text>
            <text class="result-value">{{ nativeResult.name || '(无)' }}</text>
          </view>
          <view class="result-row">
            <text class="result-label">地址</text>
            <text class="result-value">{{ nativeResult.address || '(无)' }}</text>
          </view>
          <view class="result-row">
            <text class="result-label">坐标</text>
            <text class="result-value">{{ nativeResult.latitude }}, {{ nativeResult.longitude }}</text>
          </view>
        </view>
      </view>
    </m-body>
  </m-page>
</template>

<script lang="ts" setup>
import type { MapPickerResult } from '@/hooks/useMapPicker'

const pageRef = ref(null)
const selectedResult = ref<MapPickerResult | null>(null)
const nativeResult = ref<{ name: string; address: string; latitude: number; longitude: number } | null>(null)

function openMapPicker() {
  uni.navigateTo({
    url: '/pages-sub/map/index',
    events: {
      selectLocation: (data: MapPickerResult) => {
        selectedResult.value = data
        console.log('[测试入口] 天地图选点结果:', data)
      },
    },
  })
}

function openNativePicker() {
  uni.chooseLocation({
    success: (res) => {
      nativeResult.value = {
        name: res.name,
        address: res.address,
        latitude: res.latitude,
        longitude: res.longitude,
      }
      console.log('[测试入口] 原生chooseLocation结果:', res)
    },
    fail: (err) => {
      console.warn('[测试入口] 原生chooseLocation失败:', err)
      uni.showToast({ title: '选择取消或失败', icon: 'none' })
    },
  })
}
</script>

<style lang="less" scoped>
.section { margin-bottom: 24rpx; }
.section-title {
  display: block; font-size: 30rpx; font-weight: bold;
  color: #333; margin-bottom: 12rpx;
}
.section-desc {
  display: block; font-size: 24rpx; color: #999; margin-bottom: 20rpx;
}
.action-card {
  display: flex; align-items: center; padding: 28rpx 24rpx;
  background: #fff; border-radius: 16rpx;
  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
  &:active { background: #f8f8f8; }
}
.action-icon { font-size: 40rpx; margin-right: 20rpx; }
.action-info { flex: 1; min-width: 0; }
.action-name {
  display: block; font-size: 28rpx; font-weight: 500;
  color: #333; margin-bottom: 4rpx;
}
.action-desc { display: block; font-size: 22rpx; color: #999; }
.action-arrow { font-size: 24rpx; color: #ccc; margin-left: 12rpx; }
.result-section { margin-top: 24rpx; }
.result-card {
  background: #fff; border-radius: 16rpx; padding: 24rpx;
  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.result-row {
  display: flex; align-items: flex-start; padding: 12rpx 0;
  border-bottom: 1rpx solid #f5f5f5;
  &:last-child { border-bottom: none; }
}
.result-label { width: 130rpx; min-width: 130rpx; font-size: 24rpx; color: #999; }
.result-value { flex: 1; font-size: 24rpx; color: #333; word-break: break-all; }
</style>

13. 使用手册

本手册面向使用地图选点功能的开发者,介绍如何调用、配置和扩展该功能。

13.1 功能概述

地图选点功能提供以下能力:

能力 说明
🗺️ 地图浏览 拖拽/缩放地图,实时获取中心点位置
📍 定位 自动获取当前位置并居中显示
🔍 POI 搜索 关键词搜索地点,支持联想和确认搜索
🏠 逆地理编码 地图中心点实时逆地理编码,显示地址信息
✅ 选点确认 确认选择后返回双坐标系结果(WGS-84 + GCJ-02)
支持平台
平台 地图引擎 坐标系 交互方式
H5 天地图 JS API 4.0 WGS-84 拖拽 + 点击
APP-Android/iOS 天地图 JS API(renderjs) WGS-84 拖拽 + 点击
微信小程序 原生 <map> 组件 GCJ-02 拖拽 + 点击
鸿蒙 (HarmonyOS) 华为花瓣地图 (MapKit) GCJ-02 拖拽 + 点击

13.2 快速开始

最简调用
// 在任意页面中,导航到地图选点页并接收结果
uni.navigateTo({
  url: '/pages-sub/map/index',
  events: {
    selectLocation: (data) => {
      console.log('选中地点:', data.name)
      console.log('地址:', data.address)
      console.log('WGS-84:', data.latitude, data.longitude)
      console.log('GCJ-02:', data.gcj02Latitude, data.gcj02Longitude)
    },
  },
})
完整示例

参考测试页面:test.vue

<template>
  <view>
    <button @tap="openMapPicker">打开地图选点</button>
    <view v-if="result">
      <text>名称:{{ result.name }}</text>
      <text>地址:{{ result.address }}</text>
      <text>WGS-84:{{ result.latitude }}, {{ result.longitude }}</text>
      <text>GCJ-02:{{ result.gcj02Latitude }}, {{ result.gcj02Longitude }}</text>
    </view>
  </view>
</template>

<script lang="ts" setup>
import type { MapPickerResult } from '@/hooks/useMapPicker'

const result = ref<MapPickerResult | null>(null)

function openMapPicker() {
  uni.navigateTo({
    url: '/pages-sub/map/index',
    events: {
      selectLocation: (data: MapPickerResult) => {
        result.value = data
      },
    },
  })
}
</script>

13.3 调用方式

页面导航 + eventChannel

这是推荐的调用方式,通过 UniApp 的页面事件通道传递选点结果。

uni.navigateTo({
  url: '/pages-sub/map/index',
  events: {
    // 监听选点确认事件
    selectLocation: (data: MapPickerResult) => {
      // 处理选点结果
    },
  },
})

执行流程

调用页面                  地图选点页面
   │                          │
   ├── navigateTo ──────────→ │  打开选点页
   │                          │  └─ initLocation() 自动定位
   │                          │  └─ 用户拖拽/搜索/点击
   │                          │  └─ 逆地理编码实时更新
   │                          │
   │ ←── emit('selectLocation', result) ──┤  点击"确定选择"
   │                          │
   │  navigateBack()         │  页面关闭
传递初始位置(扩展)

当前版本自动获取用户当前位置作为初始中心。如需指定初始位置,可通过 URL 参数扩展:

// 扩展方式(需自行实现参数解析)
uni.navigateTo({
  url: `/pages-sub/map/index?lat=39.915&lng=116.404`,
})

13.4 返回结果说明

数据结构
interface MapPickerResult {
  /** 地点名称(如"天安门") */
  name: string
  /** 完整地址(如"北京市东城区东长安街") */
  address: string
  /** WGS-84 纬度 — 通用存储格式,传给后端/天地图 API */
  latitude: number
  /** WGS-84 经度 */
  longitude: number
  /** GCJ-02 纬度 — 供微信小程序/鸿蒙地图渲染使用 */
  gcj02Latitude: number
  /** GCJ-02 经度 */
  gcj02Longitude: number
}
坐标系选择指南
使用场景 选用坐标系 对应字段
传给后端 API 存储 WGS-84 latitude, longitude
调用天地图 API(逆地理编码等) WGS-84 latitude, longitude
渲染到微信小程序 map 组件 GCJ-02 gcj02Latitude, gcj02Longitude
渲染到鸿蒙花瓣地图 GCJ-02 gcj02Latitude, gcj02Longitude
渲染到 H5/APP 天地图 WGS-84 latitude, longitude
GPS 导航 WGS-84 latitude, longitude
结果示例
{
  "name": "天安门",
  "address": "北京市东城区东长安街",
  "latitude": 39.9087,
  "longitude": 116.3975,
  "gcj02Latitude": 39.9152,
  "gcj02Longitude": 116.4041
}

注意:同一地点的 WGS-84 和 GCJ-02 坐标大约偏移 100~600 米,这是中国国测局加密算法的正常现象。

13.5 配置项

天地图 API 凭证

配置文件:mapConfig.ts

/** 浏览器端 Token — 用于 JS API 加载和前端 REST 请求 */
export const TIANDITU_KEY = '<YOUR_TIANDITU_BROWSER_KEY>'

/** 服务端 Token — 用于后端 REST 请求(暂未使用,预留扩展) */
export const TIANDITU_SERVER_KEY = '<YOUR_TIANDITU_SERVER_KEY>'

如何更换 Token

  1. 访问 天地图开发者平台 注册/登录
  2. 创建应用,获取浏览器端和服务端 Key
  3. 修改 mapConfig.ts 中的 TIANDITU_KEYTIANDITU_SERVER_KEY
防抖时间

在调用 useMapPicker 时可传入防抖时间(默认 300ms):

// 默认 300ms
const { ... } = useMapPicker()

// 自定义 500ms
const { ... } = useMapPicker(500)

13.6 平台差异说明

交互差异
特性 H5 APP-PLUS 微信小程序 鸿蒙
地图引擎 天地图 JS 天地图 JS (renderjs) 微信原生 map 花瓣地图 (MapKit)
拖拽结束检测 moveend 事件 moveend 事件 regionchange (type=end) 稳定性轮询 300ms
中心定位针 CSS 覆盖物 renderjs 注入 DOM markers 渲染 CSS 覆盖物
搜索范围限定 支持当前视野范围 不支持 不支持 不支持
定位精度 取决于浏览器 高精度模式 高精度模式 高精度模式
坐标系差异
平台 地图中心坐标 (centerLat/Lng) 天地图 API 交互
H5 WGS-84 无需转换
APP-PLUS WGS-84 无需转换
微信小程序 GCJ-02 需双向转换
鸿蒙 GCJ-02 需双向转换
首次加载行为
平台 首次逆地理编码时机
H5 地图创建后立即执行
APP-PLUS 等待天地图 JS 加载完成后执行(避免 403)
微信小程序 地图组件挂载后立即执行
鸿蒙 花瓣地图初始化后立即执行

13.7 权限配置

微信小程序

manifest.jsonmp-weixin 中配置:

{
  "mp-weixin": {
    "requiredPrivateInfos": ["getLocation", "chooseLocation"],
    "permission": {
      "scope.userLocation": {
        "desc": "你的位置信息将用于选择位置"
      }
    }
  }
}
APP-iOS

manifest.jsonapp-plusdistributeios 中配置:

{
  "ios": {
    "privacyDescription": {
      "NSLocationWhenInUseUsageDescription": "用于选择位置",
      "NSLocationAlwaysUsageDescription": "用于选择位置",
      "NSLocationAlwaysAndWhenInUseUsageDescription": "用于选择位置"
    }
  }
}
APP-Android

依赖 uni-app 运行时自动处理定位权限,无需额外配置。

鸿蒙

module.json5 中配置定位权限(由 UTS 插件自动处理)。

13.8 路由注册

地图选点页面路由在 pages.json 中注册:

{
  "subPackages": [
    {
    "root": "pages-sub",
    "pages": [
      {
        "path": "map/index",
        "style": {
          "navigationStyle": "custom",
          "navigationBarTitleText": "选择位置"
        }
      },
      {
        "path": "map/test",
        "style": {
          "navigationStyle": "custom",
          "navigationBarTitleText": "地图选点测试"
        }
      }
    ]
  }
  ]
}

测试页面 /pages-sub/map/test 仅用于开发调试,正式发布时可移除。

13.9 常见问题

Q1: 选点位置与实际位置有偏移

原因:坐标系混淆。WGS-84 坐标直接用于 GCJ-02 地图(或反之)会产生 100~600m 偏移。

解决

  • 后端存储使用 latitude/longitude(WGS-84)
  • 微信/鸿蒙地图渲染使用 gcj02Latitude/gcj02Longitude
  • 如需在其他场景使用,参考 coordTransform.ts 进行转换
Q2: APP 端首次打开逆地理编码失败

原因:浏览器端 Key 依赖天地图 JS API 加载后的 Cookie 会话鉴权。

解决:此问题已在代码中处理(延迟到 handleMapReady 回调后执行),如仍出现请检查 Token 是否正确。

Q3: 微信小程序定位被拒绝

解决

  1. 确认 manifest.json 中已配置 requiredPrivateInfospermission
  2. 确认小程序后台已开通定位权限
  3. 用户需在设置中授权位置权限
Q4: 鸿蒙端拖拽结束后逆地理编码延迟

原因:花瓣地图无 cameraPositionChangeEnd 事件,采用 300ms 稳定性轮询检测。

解决:这是平台限制,最多 300ms 延迟,对用户体验影响可忽略。

Q5: 搜索结果不完整

原因:搜索范围默认覆盖中国全境,但天地图 API 每次最多返回 20 条。

解决

  • H5 端支持传入当前地图视野范围缩小搜索区域
  • 可通过修改 searchPoi 中的 count 参数增加返回数量
Q6: 定位失败

现象:地图默认显示北京天安门位置。

解决

  1. 检查设备是否开启定位权限
  2. 检查网络连接
  3. 室内 GPS 信号弱,可移动到窗边
Q7: 如何自定义地图样式或交互

修改点

  • 底部信息栏样式:修改 index.vue<style> 部分
  • 搜索栏样式:修改 SearchBar.vue
  • POI 列表样式:修改 PoiList.vue
  • 地图缩放级别:修改 centerAndZoom 的第二个参数(默认 15)

13.10 API 参考

useMapPicker Hook
function useMapPicker(debounceMs?: number): {
  // 响应式状态
  centerLat: Ref<number>              // 地图中心纬度(显示坐标系)
  centerLng: Ref<number>              // 地图中心经度(显示坐标系)
  located: Ref<boolean>               // 是否已定位
  locating: Ref<boolean>              // 是否正在定位
  selectedLocation: Ref<LocationInfo>  // 选中位置(WGS-84)
  keyword: Ref<string>                // 搜索关键词
  poiList: Ref<PoiItem[]>             // 搜索结果列表
  searchLoading: Ref<boolean>         // 搜索加载态
  showPoiList: Ref<boolean>           // 是否显示搜索结果
  hasSelection: Ref<boolean>          // 是否有有效选中

  // 方法
  initLocation(): Promise<void>                    // 初始化定位
  getCurrentLocation(): Promise<{latitude, longitude}>  // 获取当前位置
  updateSelected(wgsLng, wgsLat): Promise<void>    // 更新选中位置(WGS-84)
  reverseGeocode(lng, lat): Promise<LocationInfo>  // 逆地理编码(WGS-84)
  onSearchInput(value, mapBound?): void            // 搜索输入(防抖)
  onSearchConfirm(mapBound?): void                 // 确认搜索
  clearSearch(): void                              // 清空搜索
  selectPoi(item: PoiItem): Promise<void>          // 选中 POI
  getResult(): MapPickerResult                      // 获取最终结果
}
坐标转换工具

参考文件:coordTransform.ts

// WGS-84 → GCJ-02
function wgs84ToGcj02(wgsLng: number, wgsLat: number): { lng: number; lat: number }

// GCJ-02 → WGS-84(近似,精度 <0.5m)
function gcj02ToWgs84(gcjLng: number, gcjLat: number): { lng: number; lat: number }
地图配置

参考文件:mapConfig.ts

const TIANDITU_KEY: string          // 浏览器端 Token
const TIANDITU_SERVER_KEY: string   // 服务端 Token
const TIANDITU_GEOCODER_URL: string // 逆地理编码接口
const TIANDITU_SEARCH_URL: string   // 搜索接口
const TIANDITU_JS_API_URL: string   // JS API 加载地址
类型定义
// 位置信息
interface LocationInfo {
  name: string
  address: string
  latitude: number       // WGS-84
  longitude: number      // WGS-84
  province?: string
  city?: string
  district?: string
}

// POI 搜索结果
interface PoiItem {
  key: string            // 唯一标识(城市统计项以 "city_" 开头)
  name: string
  address: string
  latitude: number       // WGS-84
  longitude: number      // WGS-84
}

// 选点最终结果
interface MapPickerResult {
  name: string
  address: string
  latitude: number       // WGS-84 纬度
  longitude: number      // WGS-84 经度
  gcj02Latitude: number  // GCJ-02 纬度
  gcj02Longitude: number // GCJ-02 经度
}

13.11 文件索引

文件 职责
index.vue 地图选点主页面,四端条件编译
useMapPicker.ts 核心 composable,定位/搜索/选点逻辑
coordTransform.ts 坐标系转换(WGS-84 ↔ GCJ-02)
mapConfig.ts 天地图 API 凭证和接口地址
SearchBar.vue 搜索栏组件
PoiList.vue POI 搜索结果列表组件
map.ets 鸿蒙花瓣地图 UTS 插件
test.vue 测试页面
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐