瀏覽代碼

先提个代码

tanlie 1 月之前
父節點
當前提交
f06de4ccda

+ 23 - 0
wishing-tree-h5/package-lock.json

@@ -9,8 +9,10 @@
       "version": "0.0.0",
       "dependencies": {
         "@amap/amap-jsapi-loader": "^1.0.1",
+        "@types/leaflet": "^1.9.21",
         "@vant/use": "^1.6.0",
         "axios": "^1.16.1",
+        "leaflet": "^1.9.4",
         "pinia": "^3.0.4",
         "vant": "^4.9.24",
         "vue": "^3.5.34",
@@ -494,6 +496,21 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@types/geojson": {
+      "version": "7946.0.16",
+      "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/leaflet": {
+      "version": "1.9.21",
+      "resolved": "https://registry.npmmirror.com/@types/leaflet/-/leaflet-1.9.21.tgz",
+      "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "*"
+      }
+    },
     "node_modules/@types/node": {
       "version": "24.12.4",
       "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.4.tgz",
@@ -1312,6 +1329,12 @@
         "graceful-fs": "^4.1.6"
       }
     },
+    "node_modules/leaflet": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
+      "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+      "license": "BSD-2-Clause"
+    },
     "node_modules/lightningcss": {
       "version": "1.32.0",
       "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",

+ 2 - 0
wishing-tree-h5/package.json

@@ -10,8 +10,10 @@
   },
   "dependencies": {
     "@amap/amap-jsapi-loader": "^1.0.1",
+    "@types/leaflet": "^1.9.21",
     "@vant/use": "^1.6.0",
     "axios": "^1.16.1",
+    "leaflet": "^1.9.4",
     "pinia": "^3.0.4",
     "vant": "^4.9.24",
     "vue": "^3.5.34",

+ 10 - 2
wishing-tree-h5/src/stores/location.ts

@@ -2,11 +2,18 @@ import { defineStore } from 'pinia'
 import { ref } from 'vue'
 import { getUserLocation } from '@/utils/geo'
 
+type LocationFn = () => Promise<{ lng: number; lat: number }>
+
 export const useLocationStore = defineStore('location', () => {
   const lng = ref<number | null>(null)
   const lat = ref<number | null>(null)
   const located = ref(false)
   let timer: ReturnType<typeof setInterval> | null = null
+  let overriddenFn: LocationFn | null = null
+
+  function setLocationFn(fn: LocationFn) {
+    overriddenFn = fn
+  }
 
   function setLocation(longitude: number, latitude: number) {
     lng.value = longitude
@@ -16,7 +23,8 @@ export const useLocationStore = defineStore('location', () => {
 
   async function refreshLocation() {
     try {
-      const pos = await getUserLocation()
+      const fn = overriddenFn || getUserLocation
+      const pos = await fn()
       setLocation(pos.lng, pos.lat)
     } catch {
       // 保留上次位置
@@ -36,5 +44,5 @@ export const useLocationStore = defineStore('location', () => {
     }
   }
 
-  return { lng, lat, located, setLocation, refreshLocation, startWatch, stopWatch }
+  return { lng, lat, located, setLocation, setLocationFn, refreshLocation, startWatch, stopWatch }
 })

+ 163 - 0
wishing-tree-h5/src/utils/mapTool.ts

@@ -0,0 +1,163 @@
+interface LngLat {
+  lng: number
+  lat: number
+}
+
+const PI = 3.14159265358979324
+const X_PI = (PI * 3000.0) / 180.0
+const A = 6378245.0
+const EE = 0.00669342162296594323
+
+const LLBAND = [75, 60, 45, 30, 15, 0] as const
+const LL2MC: readonly [number, number, number, number, number, number, number, number, number, number][] = [
+  [-0.0015702102444, 111320.7020616939, 1704480524535203, -10338987376042340, 26112667856603880, -35149669176653700, 26595700718403920, -10725012454188240, 1800819912950474, 82.5],
+  [0.0008277824516172526, 111320.7020463578, 647795574.6671607, -4082003173.641316, 10774905663.51142, -15171875531.51559, 12053065338.62167, -5124939663.577472, 913311935.9512032, 67.5],
+  [0.00337398766765, 111320.7020202162, 4481351.045890365, -23393751.19931662, 79682215.47186455, -115964993.2797253, 97236711.15602145, -43661946.33752821, 8477230.501135234, 52.5],
+  [0.00220636496208, 111320.7020209128, 51751.86112841131, 3796837.749470245, 992013.7397791013, -1221952.21711287, 1340652.697009075, -620943.6990984312, 144416.9293806241, 37.5],
+  [-0.0003441963504368392, 111320.7020576856, 278.2353980772752, 2485758.690035394, 6070.750963243378, 54821.18345352118, 9540.606633304236, -2710.55326746645, 1405.483844121726, 22.5],
+  [-0.0003218135878613132, 111320.7020701615, 0.00369383431289, 823725.6402795718, 0.46104986909093, 2351.343141331292, 1.58060784298199, 8.77738589078284, 0.37238884252424, 7.45],
+]
+
+const MCBAND = [12890594.86, 8362377.87, 5591021, 3481989.83, 1678043.12, 0]
+const MC2LL: readonly [number, number, number, number, number, number, number, number, number, number][] = [
+  [1.410526172116255e-8, 0.00000898305509648872, -1.9939833816331, 200.9824383106796, -187.2403703815547, 91.6087516669843, -23.38765649603339, 2.57121317296198, -0.03801003308653, 17337981.2],
+  [-7.435856389565537e-9, 0.000008983055097726239, -0.78625201886289, 96.32687599759846, -1.85204757529826, -59.36935905485877, 47.40033549296737, -16.50741931063887, 2.28786674699375, 10260144.86],
+  [-3.030883460898826e-8, 0.00000898305509983578, 0.30071316287616, 59.74293618442277, 7.357984074871, -25.38371002664745, 13.45380521110908, -3.29883767235584, 0.32710905363475, 6856817.37],
+  [-1.981981304930552e-8, 0.000008983055099779535, 0.03278182852591, 40.31678527705744, 0.65659298677277, -4.44255534477492, 0.85341911805263, 0.12923347998204, -0.04625736007561, 4482777.06],
+  [3.09191371068437e-9, 0.000008983055096812155, 0.00006995724062, 23.10934304144901, -0.00023663490511, -0.6321817810242, -0.00663494467273, 0.03430082397953, -0.00466043876332, 2555164.4],
+  [2.890871144776878e-9, 0.000008983055095805407, -3.068298e-8, 7.47137025468032, -0.00000353937994, -0.02145144861037, -0.00001234426596, 0.00010322952773, -0.00000323890364, 826088.5],
+]
+
+function getRange(cC: number, cB: number | null, T: number | null): number {
+  if (cB != null) cC = Math.max(cC, cB)
+  if (T != null) cC = Math.min(cC, T)
+  return cC
+}
+
+function getLoop(cC: number, cB: number, T: number): number {
+  while (cC > T) cC -= T - cB
+  while (cC < cB) cC += T - cB
+  return cC
+}
+
+function convertor(cC: { x: number; y: number }, cD: readonly number[]): [number, number] | null {
+  if (!cC || !cD) return null
+  let T = cD[0] + cD[1] * Math.abs(cC.x)
+  const cB = Math.abs(cC.y) / cD[9]
+  let cE = cD[2] + cD[3] * cB + cD[4] * cB * cB + cD[5] * cB * cB * cB + cD[6] * cB * cB * cB * cB + cD[7] * cB * cB * cB * cB * cB + cD[8] * cB * cB * cB * cB * cB * cB
+  T *= cC.x < 0 ? -1 : 1
+  cE *= cC.y < 0 ? -1 : 1
+  return [T, cE]
+}
+
+function isOutOfChina(lng: number, lat: number): boolean {
+  return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
+}
+
+function transformLat(x: number, y: number): number {
+  let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x))
+  ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0
+  ret += ((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0
+  ret += ((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) * 2.0) / 3.0
+  return ret
+}
+
+function transformLng(x: number, y: number): number {
+  let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x))
+  ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0
+  ret += ((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0
+  ret += ((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) * 2.0) / 3.0
+  return ret
+}
+
+function delta(lat: number, lng: number): { lat: number; lng: number } {
+  const dLat = transformLat(lng - 105.0, lat - 35.0)
+  const dLng = transformLng(lng - 105.0, lat - 35.0)
+  const radLat = (lat / 180.0) * PI
+  let magic = Math.sin(radLat)
+  magic = 1 - EE * magic * magic
+  const sqrtMagic = Math.sqrt(magic)
+  return {
+    lat: (dLat * 180.0) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI),
+    lng: (dLng * 180.0) / ((A / sqrtMagic) * Math.cos(radLat) * PI),
+  }
+}
+
+/** 百度墨卡托坐标 → 百度经纬度坐标 */
+export function convertBdMC2LL(lnglat: LngLat): LngLat {
+  const cC = { x: Math.abs(lnglat.lng), y: Math.abs(lnglat.lat) }
+  let cE = MC2LL[0]
+  for (let cD = 0; cD < MCBAND.length; cD++) {
+    if (cC.y >= MCBAND[cD]) {
+      cE = MC2LL[cD]
+      break
+    }
+  }
+  const [lng, lat] = convertor({ x: lnglat.lng, y: lnglat.lat }, cE)!
+  return { lng, lat }
+}
+
+/** 百度BD09经纬度坐标 → 百度墨卡托坐标 */
+export function convertBdLL2MC(lnglat: LngLat): LngLat {
+  const T = { x: getLoop(lnglat.lng, -180, 180), y: getRange(lnglat.lat, -74, 74) }
+  let cD: readonly number[] | undefined
+  for (let cC = 0; cC < LLBAND.length; cC++) {
+    if (T.y >= LLBAND[cC]) {
+      cD = LL2MC[cC]
+      break
+    }
+  }
+  if (!cD) {
+    for (let cC = LLBAND.length - 1; cC >= 0; cC--) {
+      if (T.y <= -LLBAND[cC]) {
+        cD = LL2MC[cC]
+        break
+      }
+    }
+  }
+  const [lng, lat] = convertor(T, cD!)!
+  return { lng, lat }
+}
+
+/** WGS-84 → GCJ-02(火星坐标系) */
+export function wgs84ToGcj02(lnglat: LngLat): LngLat {
+  const { lat: wgLat, lng: wgLng } = lnglat
+  if (isOutOfChina(wgLng, wgLat)) {
+    return { lng: wgLng, lat: wgLat }
+  }
+  const dLat = transformLat(wgLng - 105.0, wgLat - 35.0)
+  const dLng = transformLng(wgLng - 105.0, wgLat - 35.0)
+  const radLat = (wgLat / 180.0) * PI
+  let magic = Math.sin(radLat)
+  magic = 1 - EE * magic * magic
+  const sqrtMagic = Math.sqrt(magic)
+  return {
+    lat: wgLat + (dLat * 180.0) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI),
+    lng: wgLng + (dLng * 180.0) / ((A / sqrtMagic) * Math.cos(radLat) * PI),
+  }
+}
+
+/** GCJ-02(火星坐标系) → WGS-84 */
+export function gcj02ToWgs84(lnglat: LngLat): LngLat {
+  const { lat: gcjLat, lng: gcjLng } = lnglat
+  const d = delta(gcjLat, gcjLng)
+  return { lat: gcjLat - d.lat, lng: gcjLng - d.lng }
+}
+
+/** BD-09(百度坐标) → GCJ-02(火星坐标) */
+export function bd09ToGcj02(lnglat: LngLat): LngLat {
+  const x = lnglat.lng - 0.0065
+  const y = lnglat.lat - 0.006
+  const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI)
+  const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI)
+  return { lng: z * Math.cos(theta), lat: z * Math.sin(theta) }
+}
+
+/** GCJ-02(火星坐标) → BD-09(百度坐标) */
+export function gcj02ToBd09(lnglat: LngLat): LngLat {
+  const x = lnglat.lng
+  const y = lnglat.lat
+  const z = Math.sqrt(x * x + y * y) + 0.00002 * Math.sin(y * X_PI)
+  const theta = Math.atan2(y, x) + 0.000003 * Math.cos(x * X_PI)
+  return { lng: z * Math.cos(theta) + 0.0065, lat: z * Math.sin(theta) + 0.006 }
+}

+ 331 - 0
wishing-tree-h5/src/views/MapView-bak.vue

@@ -0,0 +1,331 @@
+<template>
+  <div class="map-page with-bottom-nav">
+    <div ref="mapContainer" class="map-container" />
+
+    <!-- 定位按钮 -->
+    <div class="map-controls">
+      <van-button
+        icon="aim"
+        round
+        type="primary"
+        size="small"
+        :loading="locating"
+        @click="locateUser"
+      >
+        定位
+      </van-button>
+    </div>
+
+    <!-- 许愿树信息弹出层 -->
+    <van-popup
+      v-model:show="showPopup"
+      position="bottom"
+      round
+      :style="{ maxHeight: '45%' }"
+      safe-area-inset-bottom
+    >
+      <div class="tree-popup" v-if="selectedTree">
+        <div class="popup-cover" :style="{ background: popupGradient }">
+          <span class="popup-emoji">{{ popupEmoji }}</span>
+        </div>
+        <h3 class="popup-name">{{ selectedTree.name }}</h3>
+        <p class="popup-addr">{{ selectedTree.address }}</p>
+        <div class="popup-meta">
+          <van-tag
+            :type="selectedTree.isInRange ? 'success' : 'warning'"
+            round
+            size="medium"
+          >
+            {{
+              selectedTree.isInRange
+                ? "可许愿"
+                : "距离 " + fmtDist(selectedTree.distance)
+            }}
+          </van-tag>
+          <span class="popup-count"
+            >💬 {{ selectedTree.totalWishes }} 个愿望</span
+          >
+        </div>
+        <div class="popup-actions">
+          <van-button
+            type="primary"
+            block
+            round
+            size="small"
+            @click="goTreeDetail"
+          >
+            查看详情
+          </van-button>
+          <van-button
+            type="success"
+            block
+            round
+            size="small"
+            :disabled="!selectedTree.isInRange"
+            @click="goMakeWish"
+          >
+            {{ selectedTree.isInRange ? "🙏 去许愿" : "需要靠近许愿树" }}
+          </van-button>
+        </div>
+      </div>
+    </van-popup>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
+import { useRouter } from "vue-router";
+import { showToast } from "vant";
+import { useLocationStore } from "@/stores/location";
+import { getUserLocation } from "@/utils/geo";
+import { getTreeGradient, getTreeEmoji } from "@/utils/theme";
+import { fetchNearbyTrees } from "@/api/tree";
+import markerImg from "@/assets/location-marker.png";
+import { wgs84ToGcj02 } from "@/utils/mapTool";
+import L from "leaflet";
+import "leaflet/dist/leaflet.css";
+
+const router = useRouter();
+const locationStore = useLocationStore();
+
+const mapContainer = ref<HTMLDivElement>();
+const showPopup = ref(false);
+const selectedTree = ref<any>(null);
+const locating = ref(false);
+
+const popupGradient = computed(() =>
+  getTreeGradient(selectedTree.value?.id || 1),
+);
+const popupEmoji = computed(() => getTreeEmoji(selectedTree.value?.id || 1));
+
+let map: any = null;
+let userMarker: any = null;
+let treeMarkers: L.Marker[] = [];
+
+function fmtDist(m: number) {
+  return m >= 1000 ? (m / 1000).toFixed(1) + "km" : m + "m";
+}
+
+function goTreeDetail() {
+  showPopup.value = false;
+  router.push(`/tree/${selectedTree.value.id}`);
+}
+
+function goMakeWish() {
+  showPopup.value = false;
+  router.push({ path: "/make-wish", query: { treeId: selectedTree.value.id } });
+}
+
+// 高德瓦片(GCJ-02 坐标系)
+const GAODE_TILE = "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}";
+
+function initMap() {
+  try {
+    map = L.map(mapContainer.value!, {
+      center: [39.9042, 116.4074],
+      zoom: 14,
+      attributionControl: false,
+      zoomControl: false,
+    });
+
+    L.tileLayer(GAODE_TILE, {
+      subdomains: ["1", "2", "3", "4"],
+      maxZoom: 18,
+    }).addTo(map);
+
+    L.control.scale({ metric: true, imperial: false }).addTo(map);
+    L.control.zoom({ position: "topright" }).addTo(map);
+
+    locateUser();
+  } catch (err) {
+    showToast("地图加载失败");
+    console.error(err);
+  }
+}
+
+// GPS 定位 → GCJ-02 转换(供 store 定时刷新使用)
+async function getGcjLocation(): Promise<{ lng: number; lat: number }> {
+  const gps = await getUserLocation()
+  const gcj = wgs84ToGcj02(gps)
+  console.log(`GPS → GCJ-02:${gps.lng},${gps.lat} → ${gcj.lng},${gcj.lat}`)
+  return gcj
+}
+
+async function locateUser() {
+  locating.value = true;
+  try {
+    const { lng, lat } = await getGcjLocation();
+    locationStore.setLocation(lng, lat);
+
+    if (map) {
+      map.setView([lat, lng], 16);
+
+      if (userMarker) map.removeLayer(userMarker);
+      const icon = L.icon({
+        iconUrl: markerImg,
+        iconSize: [25, 45],
+        iconAnchor: [12, 45],
+      });
+      userMarker = L.marker([lat, lng], { icon, zIndexOffset: 200 }).addTo(map);
+    }
+
+    await loadTrees();
+  } catch {
+    await loadTrees();
+  } finally {
+    locating.value = false;
+  }
+}
+
+async function loadTrees() {
+  if (!locationStore.lng) return;
+
+  const trees = await fetchNearbyTrees(
+    locationStore.lng,
+    locationStore.lat!,
+    50000,
+    100,
+  );
+
+  treeMarkers.forEach((m) => map.removeLayer(m));
+  treeMarkers = [];
+
+  trees.forEach((tree: any) => {
+    const isIn = tree.isInRange;
+    const color = isIn ? "#07c160" : "#ff976a";
+    const label = `${tree.name} ${fmtDist(tree.distance)}`;
+
+    const icon = L.divIcon({
+      className: "",
+      html: `
+        <div style="
+          position:relative;
+          text-align:center;
+          background:${color};
+          color:#fff;
+          padding:4px 10px;
+          border-radius:16px;
+          font-size:11px;
+          white-space:nowrap;
+          box-shadow:0 2px 8px rgba(0,0,0,0.3);
+          max-width:160px;
+          overflow:hidden;
+          text-overflow:ellipsis;
+        ">
+          ${label}
+          <div style="
+            position:absolute;
+            bottom:-6px;
+            left:50%;
+            transform:translateX(-50%);
+            width:0;
+            height:0;
+            border-left:6px solid transparent;
+            border-right:6px solid transparent;
+            border-top:6px solid ${color};
+          "></div>
+        </div>`,
+      iconSize: [0, 0] as any,
+      iconAnchor: [0, 0],
+    });
+
+    const marker = L.marker([tree.latitude, tree.longitude], { icon }).addTo(map);
+
+    marker.on("click", () => {
+      selectedTree.value = tree;
+      showPopup.value = true;
+    });
+
+    treeMarkers.push(marker);
+  });
+
+  if (trees.length > 0 && locationStore.lng) {
+    const bounds = L.latLngBounds(
+      trees.map((t: any) => [t.latitude, t.longitude] as L.LatLngTuple)
+    );
+    bounds.extend([locationStore.lat!, locationStore.lng]);
+    map.fitBounds(bounds, { padding: [50, 50] });
+  }
+}
+
+watch(
+  () => [locationStore.lng, locationStore.lat] as const,
+  ([lng, lat]) => {
+    if (lng != null && lat != null && userMarker) {
+      userMarker.setLatLng([lat, lng]);
+    }
+  },
+);
+
+onMounted(async () => {
+  await nextTick();
+  locationStore.setLocationFn(getGcjLocation);
+  initMap();
+  locationStore.startWatch(5000);
+});
+
+onUnmounted(() => {
+  locationStore.stopWatch();
+  if (map) map.remove();
+});
+</script>
+
+<style scoped>
+.map-page {
+  position: relative;
+  width: 100%;
+  height: calc(100vh - 50px);
+}
+.map-container {
+  width: 100%;
+  height: 100%;
+}
+.map-controls {
+  position: absolute;
+  bottom: 20px;
+  right: 16px;
+  z-index: 1000;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.tree-popup {
+  padding: 16px;
+}
+.popup-cover {
+  width: 100%;
+  height: 120px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 12px;
+}
+.popup-emoji {
+  font-size: 44px;
+  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
+}
+.popup-name {
+  font-size: 17px;
+  margin: 10px 0 4px;
+}
+.popup-addr {
+  font-size: 12px;
+  color: #999;
+  margin-bottom: 10px;
+}
+.popup-meta {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 14px;
+}
+.popup-count {
+  font-size: 12px;
+  color: #666;
+}
+.popup-actions {
+  display: flex;
+  gap: 8px;
+}
+</style>

+ 81 - 71
wishing-tree-h5/src/views/MapView.vue

@@ -78,10 +78,12 @@ import { useRouter } from "vue-router";
 import { showToast } from "vant";
 import { useLocationStore } from "@/stores/location";
 import { getUserLocation } from "@/utils/geo";
-import { loadAMap } from "@/utils/amap";
 import { getTreeGradient, getTreeEmoji } from "@/utils/theme";
 import { fetchNearbyTrees } from "@/api/tree";
 import markerImg from "@/assets/location-marker.png";
+import { wgs84ToGcj02 } from "@/utils/mapTool";
+import L from "leaflet";
+import "leaflet/dist/leaflet.css";
 
 const router = useRouter();
 const locationStore = useLocationStore();
@@ -98,8 +100,7 @@ const popupEmoji = computed(() => getTreeEmoji(selectedTree.value?.id || 1));
 
 let map: any = null;
 let userMarker: any = null;
-let treeMarkers: any[] = [];
-let AMap: any = null;
+let treeMarkers: L.Marker[] = [];
 
 function fmtDist(m: number) {
   return m >= 1000 ? (m / 1000).toFixed(1) + "km" : m + "m";
@@ -115,47 +116,57 @@ function goMakeWish() {
   router.push({ path: "/make-wish", query: { treeId: selectedTree.value.id } });
 }
 
-async function initMap() {
-  try {
-    AMap = await loadAMap();
+// 高德瓦片(GCJ-02 坐标系)
+const GAODE_TILE = "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}";
 
-    map = new AMap.Map(mapContainer.value!, {
+function initMap() {
+  try {
+    map = L.map(mapContainer.value!, {
+      center: [39.9042, 116.4074],
       zoom: 14,
-      center: [116.4074, 39.9042],
-      mapStyle: "amap://styles/light",
+      attributionControl: false,
+      zoomControl: false,
     });
 
-    map.addControl(new AMap.Scale());
-    map.addControl(new AMap.ToolBar({ position: "RT" }));
+    L.tileLayer(GAODE_TILE, {
+      subdomains: ["1", "2", "3", "4"],
+      maxZoom: 18,
+    }).addTo(map);
 
-    await locateUser();
+    L.control.scale({ metric: true, imperial: false }).addTo(map);
+    L.control.zoom({ position: "topright" }).addTo(map);
+
+    locateUser();
   } catch (err) {
     showToast("地图加载失败");
     console.error(err);
   }
 }
 
+// GPS 定位 → GCJ-02 转换(供 store 定时刷新使用)
+async function getGcjLocation(): Promise<{ lng: number; lat: number }> {
+  const gps = await getUserLocation()
+  const gcj = wgs84ToGcj02(gps)
+  console.log(`GPS → GCJ-02:${gps.lng},${gps.lat} → ${gcj.lng},${gcj.lat}`)
+  return gcj
+}
+
 async function locateUser() {
   locating.value = true;
   try {
-    const { lng, lat } = await getUserLocation();
+    const { lng, lat } = await getGcjLocation();
     locationStore.setLocation(lng, lat);
 
     if (map) {
-      map.setCenter([lng, lat]);
-
-      if (userMarker) map.remove(userMarker);
-      userMarker = new AMap.Marker({
-        position: [lng, lat],
-        icon: new AMap.Icon({
-          size: new (AMap as any).Size(25, 45),
-          image: markerImg,
-          imageSize: new (AMap as any).Size(25, 45),
-        }),
-        zIndex: 200,
-        anchor: "center",
+      map.setView([lat, lng], 16);
+
+      if (userMarker) map.removeLayer(userMarker);
+      const icon = L.icon({
+        iconUrl: markerImg,
+        iconSize: [25, 45],
+        iconAnchor: [12, 45],
       });
-      map.add(userMarker);
+      userMarker = L.marker([lat, lng], { icon, zIndexOffset: 200 }).addTo(map);
     }
 
     await loadTrees();
@@ -176,8 +187,7 @@ async function loadTrees() {
     100,
   );
 
-  // 清除旧标记
-  treeMarkers.forEach((m) => map.remove(m));
+  treeMarkers.forEach((m) => map.removeLayer(m));
   treeMarkers = [];
 
   trees.forEach((tree: any) => {
@@ -185,78 +195,78 @@ async function loadTrees() {
     const color = isIn ? "#07c160" : "#ff976a";
     const label = `${tree.name} ${fmtDist(tree.distance)}`;
 
-    const content = `
-      <div style="
-        position:relative;
-        text-align:center;
-        background:${color};
-        color:#fff;
-        padding:4px 10px;
-        border-radius:16px;
-        font-size:11px;
-        white-space:nowrap;
-        box-shadow:0 2px 8px rgba(0,0,0,0.3);
-        max-width:160px;
-        overflow:hidden;
-        text-overflow:ellipsis;
-      ">
-        ${label}
+    const icon = L.divIcon({
+      className: "",
+      html: `
         <div style="
-          position:absolute;
-          bottom:-6px;
-          left:50%;
-          transform:translateX(-50%);
-          width:0;
-          height:0;
-          border-left:6px solid transparent;
-          border-right:6px solid transparent;
-          border-top:6px solid ${color};
-        "></div>
-      </div>`;
-
-    const marker = new AMap.Marker({
-      position: [tree.longitude, tree.latitude],
-      content: content,
-      anchor: "bottom-center",
-      offset: new (AMap as any).Pixel(0, -6),
+          position:relative;
+          text-align:center;
+          background:${color};
+          color:#fff;
+          padding:4px 10px;
+          border-radius:16px;
+          font-size:11px;
+          white-space:nowrap;
+          box-shadow:0 2px 8px rgba(0,0,0,0.3);
+          max-width:160px;
+          overflow:hidden;
+          text-overflow:ellipsis;
+        ">
+          ${label}
+          <div style="
+            position:absolute;
+            bottom:-6px;
+            left:50%;
+            transform:translateX(-50%);
+            width:0;
+            height:0;
+            border-left:6px solid transparent;
+            border-right:6px solid transparent;
+            border-top:6px solid ${color};
+          "></div>
+        </div>`,
+      iconSize: [0, 0] as any,
+      iconAnchor: [0, 0],
     });
 
+    const marker = L.marker([tree.latitude, tree.longitude], { icon }).addTo(map);
+
     marker.on("click", () => {
       selectedTree.value = tree;
       showPopup.value = true;
     });
 
-    marker.setMap(map);
     treeMarkers.push(marker);
   });
 
-  // 适配视野
   if (trees.length > 0 && locationStore.lng) {
-    const points = trees.map((t: any) => [t.longitude, t.latitude]);
-    points.push([locationStore.lng, locationStore.lat]);
-    map.setFitView(points, true, [80, 80, 80, 200]);
+    const bounds = L.latLngBounds(
+      trees.map((t: any) => [t.latitude, t.longitude] as L.LatLngTuple)
+    );
+    bounds.extend([locationStore.lat!, locationStore.lng]);
+    map.fitBounds(bounds, { padding: [50, 50] });
   }
 }
 
 watch(
   () => [locationStore.lng, locationStore.lat] as const,
   ([lng, lat]) => {
-    if (lng != null && lat != null) {
-      console.log(`当前位置:经度 ${lng},纬度 ${lat}`);
+    if (lng != null && lat != null && userMarker) {
+      userMarker.setLatLng([lat, lng]);
     }
   },
-  { immediate: true },
 );
 
 onMounted(async () => {
   await nextTick();
+  locationStore.setLocationFn(getGcjLocation);
   initMap();
   locationStore.startWatch(5000);
 });
 
 onUnmounted(() => {
   locationStore.stopWatch();
-  if (map) map.destroy();
+  if (map) map.remove();
 });
 </script>
 
@@ -274,7 +284,7 @@ onUnmounted(() => {
   position: absolute;
   bottom: 20px;
   right: 16px;
-  z-index: 100;
+  z-index: 1000;
   display: flex;
   flex-direction: column;
   gap: 8px;