chenlei 1 هفته پیش
والد
کامیت
f82c989285
69فایلهای تغییر یافته به همراه1040 افزوده شده و 873 حذف شده
  1. 1 1
      index.html
  2. 571 571
      public/data.json
  3. BIN
      public/images/resource/1.jpg
  4. BIN
      public/images/resource/10.jpg
  5. BIN
      public/images/resource/11.jpg
  6. BIN
      public/images/resource/13.jpg
  7. BIN
      public/images/resource/14.jpg
  8. BIN
      public/images/resource/15.jpg
  9. BIN
      public/images/resource/16.jpg
  10. BIN
      public/images/resource/17.jpg
  11. BIN
      public/images/resource/18.jpg
  12. BIN
      public/images/resource/19.jpg
  13. BIN
      public/images/resource/2.jpg
  14. BIN
      public/images/resource/20.jpg
  15. BIN
      public/images/resource/21.jpg
  16. BIN
      public/images/resource/22.jpg
  17. BIN
      public/images/resource/23.jpg
  18. BIN
      public/images/resource/24.jpg
  19. BIN
      public/images/resource/25.jpg
  20. BIN
      public/images/resource/26.jpg
  21. BIN
      public/images/resource/27.jpg
  22. BIN
      public/images/resource/3.jpg
  23. BIN
      public/images/resource/38.jpg
  24. BIN
      public/images/resource/4.jpg
  25. BIN
      public/images/resource/40.jpg
  26. BIN
      public/images/resource/44.jpg
  27. BIN
      public/images/resource/5.jpg
  28. BIN
      public/images/resource/6.jpg
  29. BIN
      public/images/resource/67.jpg
  30. BIN
      public/images/resource/7.jpg
  31. BIN
      public/images/resource/8.jpg
  32. BIN
      public/images/resource/87.jpg
  33. BIN
      public/images/resource/91.jpg
  34. BIN
      public/images/thumb/1.jpg
  35. BIN
      public/images/thumb/10.jpg
  36. BIN
      public/images/thumb/11.jpg
  37. BIN
      public/images/thumb/13.jpg
  38. BIN
      public/images/thumb/14.jpg
  39. BIN
      public/images/thumb/15.jpg
  40. BIN
      public/images/thumb/16.jpg
  41. BIN
      public/images/thumb/17.jpg
  42. BIN
      public/images/thumb/18.jpg
  43. BIN
      public/images/thumb/19.jpg
  44. BIN
      public/images/thumb/2.jpg
  45. BIN
      public/images/thumb/20.jpg
  46. BIN
      public/images/thumb/21.jpg
  47. BIN
      public/images/thumb/22.jpg
  48. BIN
      public/images/thumb/23.jpg
  49. BIN
      public/images/thumb/24.jpg
  50. BIN
      public/images/thumb/25.jpg
  51. BIN
      public/images/thumb/26.jpg
  52. BIN
      public/images/thumb/27.jpg
  53. BIN
      public/images/thumb/3.jpg
  54. BIN
      public/images/thumb/38.jpg
  55. BIN
      public/images/thumb/4.jpg
  56. BIN
      public/images/thumb/40.jpg
  57. BIN
      public/images/thumb/44.jpg
  58. BIN
      public/images/thumb/5.jpg
  59. BIN
      public/images/thumb/6.jpg
  60. BIN
      public/images/thumb/67.jpg
  61. BIN
      public/images/thumb/7.jpg
  62. BIN
      public/images/thumb/8.jpg
  63. BIN
      public/images/thumb/87.jpg
  64. BIN
      public/images/thumb/91.jpg
  65. 9 5
      src/components/Sidebar/index.vue
  66. 144 113
      src/pages/Home/index.vue
  67. 292 176
      src/pages/Map/index.vue
  68. 21 5
      src/pages/Picture/index.vue
  69. 2 2
      src/router/index.js

+ 1 - 1
index.html

@@ -8,7 +8,7 @@
   </head>
   <body>
     <div id="app"></div>
-    <!-- 天地图 API,需在 https://lbs.tianditu.gov.cn/ 申请 tk 密钥 -->
+    <!-- 天地图 API,需在 https://lbs.tianditu.gov.cn/ -->
     <script
       src="https://api.tianditu.gov.cn/api?v=4.0&tk=85cd0cdb9cea33df08557f0af24063d1"
       type="text/javascript"

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 571 - 571
public/data.json


BIN
public/images/resource/1.jpg


BIN
public/images/resource/10.jpg


BIN
public/images/resource/11.jpg


BIN
public/images/resource/13.jpg


BIN
public/images/resource/14.jpg


BIN
public/images/resource/15.jpg


BIN
public/images/resource/16.jpg


BIN
public/images/resource/17.jpg


BIN
public/images/resource/18.jpg


BIN
public/images/resource/19.jpg


BIN
public/images/resource/2.jpg


BIN
public/images/resource/20.jpg


BIN
public/images/resource/21.jpg


BIN
public/images/resource/22.jpg


BIN
public/images/resource/23.jpg


BIN
public/images/resource/24.jpg


BIN
public/images/resource/25.jpg


BIN
public/images/resource/26.jpg


BIN
public/images/resource/27.jpg


BIN
public/images/resource/3.jpg


BIN
public/images/resource/38.jpg


BIN
public/images/resource/4.jpg


BIN
public/images/resource/40.jpg


BIN
public/images/resource/44.jpg


BIN
public/images/resource/5.jpg


BIN
public/images/resource/6.jpg


BIN
public/images/resource/67.jpg


BIN
public/images/resource/7.jpg


BIN
public/images/resource/8.jpg


BIN
public/images/resource/87.jpg


BIN
public/images/resource/91.jpg


BIN
public/images/thumb/1.jpg


BIN
public/images/thumb/10.jpg


BIN
public/images/thumb/11.jpg


BIN
public/images/thumb/13.jpg


BIN
public/images/thumb/14.jpg


BIN
public/images/thumb/15.jpg


BIN
public/images/thumb/16.jpg


BIN
public/images/thumb/17.jpg


BIN
public/images/thumb/18.jpg


BIN
public/images/thumb/19.jpg


BIN
public/images/thumb/2.jpg


BIN
public/images/thumb/20.jpg


BIN
public/images/thumb/21.jpg


BIN
public/images/thumb/22.jpg


BIN
public/images/thumb/23.jpg


BIN
public/images/thumb/24.jpg


BIN
public/images/thumb/25.jpg


BIN
public/images/thumb/26.jpg


BIN
public/images/thumb/27.jpg


BIN
public/images/thumb/3.jpg


BIN
public/images/thumb/38.jpg


BIN
public/images/thumb/4.jpg


BIN
public/images/thumb/40.jpg


BIN
public/images/thumb/44.jpg


BIN
public/images/thumb/5.jpg


BIN
public/images/thumb/6.jpg


BIN
public/images/thumb/67.jpg


BIN
public/images/thumb/7.jpg


BIN
public/images/thumb/8.jpg


BIN
public/images/thumb/87.jpg


BIN
public/images/thumb/91.jpg


+ 9 - 5
src/components/Sidebar/index.vue

@@ -114,7 +114,7 @@ const rawData = ref([]);
 
 onMounted(async () => {
   try {
-    const res = await fetch("/data.json");
+    const res = await fetch("./data.json");
     rawData.value = await res.json();
   } catch (e) {
     console.error("加载 data.json 失败:", e);
@@ -176,13 +176,17 @@ const handleItemClick = (item) => {
     router.replace({ name: "Picture", query: { id: item.id } });
     return;
   }
-  if (item.scene) {
-    window.open(item.scene.trim(), "_blank");
-  } else if (item.lng && item.lat) {
-    router.push({
+  // 地图页:有经纬度则跳转到标签所在位置
+  if (item.lng && item.lat) {
+    const nav = route.name === "MapView" ? router.replace : router.push;
+    nav({
       name: "MapView",
       query: { lng: item.lng, lat: item.lat, name: item.name },
     });
+    return;
+  }
+  if (item.scene) {
+    window.open(item.scene.trim(), "_blank");
   }
 };
 </script>

+ 144 - 113
src/pages/Home/index.vue

@@ -1,113 +1,144 @@
-<template>
-  <div class="home" :class="{ 'home--sidebar-visible': sidebarVisible }">
-    <div class="map-wrapper" v-html="mapSvgContent" ref="mapWrapper"></div>
-
-    <img src="@/assets/images/mini-map.jpg" alt="mini-map" class="mini-map" />
-  </div>
-</template>
-
-<script setup>
-import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
-import { storeToRefs } from "pinia";
-import svgPanZoom from "svg-pan-zoom";
-import mapSvgContent from "./images/map.svg?raw";
-import { useSidebarStore } from "@/stores/sidebar";
-
-const { visible: sidebarVisible } = storeToRefs(useSidebarStore());
-const mapWrapper = ref(null);
-let panZoomInstance = null;
-
-onMounted(async () => {
-  await nextTick();
-  if (!mapWrapper.value) return;
-  const svgEl = mapWrapper.value.querySelector("svg");
-  if (svgEl) {
-    panZoomInstance = svgPanZoom(svgEl, {
-      controlIconsEnabled: false,
-      panEnabled: false,
-      zoomEnabled: false,
-      dblClickZoomEnabled: false,
-      mouseWheelZoomEnabled: false,
-      fit: false,
-      contain: true, // 铺满容器(cover),fit 为 true 时会留白
-      center: true,
-      minZoom: 0.5,
-      maxZoom: 10,
-      zoomScaleSensitivity: 0.35,
-    });
-  }
-});
-
-onBeforeUnmount(() => {
-  if (panZoomInstance) {
-    panZoomInstance.destroy();
-  }
-});
-</script>
-
-<style scoped lang="scss">
-@use "@/assets/utils.scss";
-
-.home {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-}
-
-.map-wrapper {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  overflow: hidden;
-}
-
-.map-wrapper :deep(svg) {
-  width: 100%;
-  height: 100%;
-}
-
-.map-wrapper :deep([id^="icon-"]) {
-  transform-origin: 50% 50%;
-  transform-box: fill-box;
-  transition: transform 0.25s ease-out;
-  cursor: pointer;
-}
-.map-wrapper :deep([id^="icon-"]:hover) {
-  transform: scale(1.2);
-}
-
-.mini-map {
-  position: absolute;
-  right: utils.vh-calc(18);
-  bottom: utils.vh-calc(28);
-  width: utils.vh-calc(404);
-  height: utils.vh-calc(220);
-  transition: right 0.3s ease;
-}
-
-.home--sidebar-visible .mini-map {
-  right: utils.vh-calc(318);
-}
-
-@keyframes breathe {
-  0%,
-  100% {
-    opacity: 0.3;
-  }
-  50% {
-    opacity: 1;
-  }
-}
-.map-wrapper :deep([id^="select-"]) {
-  opacity: 0;
-
-  &:hover {
-    transform-origin: 50% 50%;
-    transform-box: fill-box;
-    animation: breathe 2.5s ease-in-out infinite;
-  }
-}
-</style>
+<template>
+  <div class="home" :class="{ 'home--sidebar-visible': sidebarVisible }">
+    <div class="map-wrapper" v-html="mapSvgContent" ref="mapWrapper"></div>
+
+    <img src="@/assets/images/mini-map.jpg" alt="mini-map" class="mini-map" />
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
+import { useRouter } from "vue-router";
+import { storeToRefs } from "pinia";
+import svgPanZoom from "svg-pan-zoom";
+import mapSvgContent from "./images/map.svg?raw";
+import { useSidebarStore } from "@/stores/sidebar";
+
+const router = useRouter();
+const { visible: sidebarVisible } = storeToRefs(useSidebarStore());
+const mapWrapper = ref(null);
+let panZoomInstance = null;
+
+const REGION_CENTERS = {
+  "icon-DouMen": { lng: 113.206, lat: 22.241, name: "斗门区" },
+  "icon-GaoXin": { lng: 113.556, lat: 22.374, name: "高新区" },
+  "icon-XiangZhou": { lng: 113.536, lat: 22.263, name: "香洲区" },
+  "icon-JinWan": { lng: 113.355, lat: 22.025, name: "金湾区" },
+  "icon-HengQin": { lng: 113.52, lat: 22.13, name: "横琴" },
+};
+
+function handleMapClick(e) {
+  const target = e.target.closest('[id^="icon-"]');
+  if (!target) return;
+  const id = target.id;
+  const region = REGION_CENTERS[id];
+  if (region) {
+    setTimeout(() => {
+      router.push({
+        name: "MapView",
+        query: { lng: region.lng, lat: region.lat, zoom: 14 },
+      });
+    }, 500);
+  }
+}
+
+onMounted(async () => {
+  await nextTick();
+  if (!mapWrapper.value) return;
+  mapWrapper.value.addEventListener("click", handleMapClick);
+  const svgEl = mapWrapper.value.querySelector("svg");
+  if (svgEl) {
+    panZoomInstance = svgPanZoom(svgEl, {
+      controlIconsEnabled: false,
+      panEnabled: false,
+      zoomEnabled: false,
+      dblClickZoomEnabled: false,
+      mouseWheelZoomEnabled: false,
+      fit: false,
+      contain: true, // 铺满容器(cover),fit 为 true 时会留白
+      center: true,
+      minZoom: 0.5,
+      maxZoom: 10,
+      zoomScaleSensitivity: 0.35,
+    });
+  }
+});
+
+onBeforeUnmount(() => {
+  mapWrapper.value?.removeEventListener("click", handleMapClick);
+  if (panZoomInstance) {
+    panZoomInstance.destroy();
+  }
+});
+</script>
+
+<style scoped lang="scss">
+@use "@/assets/utils.scss";
+
+.home {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+.map-wrapper {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+.map-wrapper :deep(svg) {
+  width: 100%;
+  height: 100%;
+}
+
+.map-wrapper :deep([id^="icon-"]) {
+  transform-origin: 50% 50%;
+  transform-box: fill-box;
+  transition: transform 0.25s ease-out;
+  cursor: pointer;
+}
+.map-wrapper :deep([id^="icon-"]:hover) {
+  transform: scale(1.2);
+}
+
+:deep(#icon-HengQin) {
+  display: none;
+}
+
+.mini-map {
+  position: absolute;
+  right: utils.vh-calc(18);
+  bottom: utils.vh-calc(28);
+  width: utils.vh-calc(404);
+  height: utils.vh-calc(220);
+  transition: right 0.3s ease;
+}
+
+.home--sidebar-visible .mini-map {
+  right: utils.vh-calc(318);
+}
+
+@keyframes breathe {
+  0%,
+  100% {
+    opacity: 0.3;
+  }
+  50% {
+    opacity: 1;
+  }
+}
+.map-wrapper :deep([id^="select-"]) {
+  opacity: 0;
+
+  &:hover {
+    transform-origin: 50% 50%;
+    transform-box: fill-box;
+    animation: breathe 2.5s ease-in-out infinite;
+  }
+}
+</style>

+ 292 - 176
src/pages/Map/index.vue

@@ -1,176 +1,292 @@
-<template>
-  <div class="map-page">
-    <div id="tdt-map-container" ref="mapContainer"></div>
-  </div>
-</template>
-
-<script setup>
-import { ref, onMounted, onBeforeUnmount } from "vue";
-
-const mapContainer = ref(null);
-let map = null;
-const markers = [];
-
-// 珠海市中心坐标
-const ZHUHAI_CENTER = { lng: 113.57668, lat: 22.271 };
-// Demo 标签坐标(珠海市政府附近)
-const DEMO_POINT = { lng: 113.55267584, lat: 22.22234309 };
-
-const PAD_X = 10;
-const PAD_Y = 6;
-const FONT_SIZE = 16;
-const BORDER_TOP = 4;
-const TRI_H = 18;
-const TRI_W = 12;
-
-function getMarkerSize(text) {
-  const textWidth = Math.max(text.length * FONT_SIZE, 80);
-  const boxW = textWidth + PAD_X * 2;
-  const boxH = FONT_SIZE + PAD_Y * 2;
-  const totalH = boxH + TRI_H;
-  return { boxW, totalH };
-}
-
-/** 生成 marker 的 SVG 内容(气泡样式:渐变背景 + 顶边 + 底部三角) */
-function createMarkerSvg(text, hover = false) {
-  const { boxW, totalH } = getMarkerSize(text);
-  const boxH = FONT_SIZE + PAD_Y * 2;
-
-  const fill = hover ? "url(#bgHover)" : "url(#bg)";
-  const strokeColor = hover ? "#e5b735" : "#98382e";
-  const textColor = hover ? "#e4ca77" : "#98382e";
-
-  return `<svg xmlns="http://www.w3.org/2000/svg" width="${boxW}" height="${totalH}" viewBox="0 0 ${boxW} ${totalH}">
-  <defs>
-    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="0">
-      <stop offset="0%" stop-color="#fffccd"/>
-      <stop offset="50%" stop-color="#fffef0"/>
-      <stop offset="100%" stop-color="#fffccd"/>
-    </linearGradient>
-    <linearGradient id="bgHover" x1="0" y1="0" x2="1" y2="0">
-      <stop offset="0%" stop-color="#99372d"/>
-      <stop offset="50%" stop-color="#b84033"/>
-      <stop offset="100%" stop-color="#99372d"/>
-    </linearGradient>
-  </defs>
-  <!-- 气泡主体 + 底部三角 -->
-  <path d="M 0 ${BORDER_TOP} L 0 ${boxH} L ${boxW / 2 - TRI_W / 2} ${boxH} L ${boxW / 2} ${totalH} L ${boxW / 2 + TRI_W / 2} ${boxH} L ${boxW} ${boxH} L ${boxW} ${BORDER_TOP} L 0 ${BORDER_TOP} Z" fill="${fill}" stroke="none"/>
-  <!-- 顶边 -->
-  <rect x="0" y="0" width="${boxW}" height="${BORDER_TOP}" fill="${strokeColor}"/>
-  <!-- 文本 -->
-  <text x="${boxW / 2}" y="${BORDER_TOP + PAD_Y + FONT_SIZE - 4}" text-anchor="middle" font-size="${FONT_SIZE}" fill="${textColor}" font-family="sans-serif">${escapeXml(text)}</text>
-</svg>`;
-}
-
-function escapeXml(s) {
-  return s
-    .replace(/&/g, "&amp;")
-    .replace(/</g, "&lt;")
-    .replace(/>/g, "&gt;")
-    .replace(/"/g, "&quot;")
-    .replace(/'/g, "&apos;");
-}
-
-/** 将 SVG 转为 data URL,供 T.Icon 使用 */
-function svgToDataUrl(svg) {
-  return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
-}
-
-onMounted(() => {
-  if (typeof T === "undefined") {
-    console.error("天地图 API 未加载,请检查 index.html 中的 tk 密钥配置");
-    return;
-  }
-
-  const script = document.querySelector('script[src*="tianditu"]');
-  const tk = script?.src.match(/tk=([^&]+)/)?.[1] || "";
-
-  // 珠海市边界(纬度 21°48′~22°27′,经度 113°03′~114°19′)
-  const zhuhaiBounds = new T.LngLatBounds(
-    new T.LngLat(113.05, 21.8), // 西南角
-    new T.LngLat(114.32, 22.45), // 东北角
-  );
-
-  // 矢量底图 + 矢量注记(EPSG:4326 使用 vec_c / cva_c)
-  const vecUrl = `https://t0.tianditu.gov.cn/vec_c/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=c&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`;
-  const cvaUrl = `https://t0.tianditu.gov.cn/cva_c/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=c&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`;
-
-  const vecLayer = new T.TileLayer(vecUrl, { minZoom: 6, maxZoom: 18 });
-  const cvaLayer = new T.TileLayer(cvaUrl, { minZoom: 6, maxZoom: 18 });
-
-  map = new T.Map(mapContainer.value, {
-    projection: "EPSG:4326",
-    minZoom: 12,
-    maxZoom: 18,
-    maxBounds: zhuhaiBounds,
-    layers: [vecLayer, cvaLayer],
-  });
-
-  map.centerAndZoom(new T.LngLat(ZHUHAI_CENTER.lng, ZHUHAI_CENTER.lat), 13);
-
-  ["南屏村卓斋街一巷8号民居"].forEach((item) => {
-    const { boxW, totalH } = getMarkerSize(item);
-    const svg = createMarkerSvg(item);
-    const iconUrl = svgToDataUrl(svg);
-
-    const icon = new T.Icon({
-      iconUrl,
-      iconSize: new T.Point(boxW, totalH),
-      iconAnchor: new T.Point(boxW / 2, totalH),
-    });
-
-    const marker = new T.Marker(new T.LngLat(DEMO_POINT.lng, DEMO_POINT.lat), {
-      icon,
-    });
-
-    marker.on("click", () => console.log(item));
-    marker.on("mouseover", () => {
-      const hoverSvg = createMarkerSvg(item, true);
-      marker.setIcon(
-        new T.Icon({
-          iconUrl: svgToDataUrl(hoverSvg),
-          iconSize: new T.Point(boxW, totalH),
-          iconAnchor: new T.Point(boxW / 2, totalH),
-        }),
-      );
-    });
-    marker.on("mouseout", () => {
-      marker.setIcon(
-        new T.Icon({
-          iconUrl,
-          iconSize: new T.Point(boxW, totalH),
-          iconAnchor: new T.Point(boxW / 2, totalH),
-        }),
-      );
-    });
-
-    map.addOverLay(marker);
-    markers.push(marker);
-  });
-});
-
-onBeforeUnmount(() => {
-  markers.forEach((m) => map?.removeOverLay(m));
-  markers.length = 0;
-  map = null;
-});
-</script>
-
-<style scoped lang="scss">
-.map-page {
-  position: fixed;
-  inset: 0;
-  width: 100vw;
-  height: 100vh;
-  z-index: 0;
-}
-
-#tdt-map-container {
-  width: 100%;
-  height: 100%;
-}
-
-:deep(.tdt-marker) {
-  cursor: pointer;
-}
-</style>
+<template>
+  <div
+    class="map-page"
+    :class="{ 'map-page--sidebar-visible': sidebarVisible }"
+  >
+    <div id="tdt-map-container" ref="mapContainer"></div>
+
+    <img
+      class="map-page-back"
+      src="@/assets/images/icon-fanhui.png"
+      @click="$router.push({ name: 'Home' })"
+    />
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount, watch } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { storeToRefs } from "pinia";
+import { useSidebarStore } from "@/stores/sidebar";
+
+const route = useRoute();
+const { visible: sidebarVisible } = storeToRefs(useSidebarStore());
+const router = useRouter();
+const mapContainer = ref(null);
+let map = null;
+/** @type {Array<{ marker: T.Marker, item: object, name: string, boxW: number, totalH: number }>} */
+const markerRecords = [];
+const mapData = ref([]); // 有经纬度的数据项
+
+// 珠海市中心坐标
+const ZHUHAI_CENTER = { lng: 113.57668, lat: 22.271 };
+/** 默认视野;侧边栏选中某建筑时放大 */
+const ZOOM_DEFAULT = 14;
+const ZOOM_SIDEBAR_SELECT = 17;
+
+const PAD_X = 10;
+const PAD_Y = 6;
+const FONT_SIZE = 16;
+const BORDER_TOP = 4;
+const TRI_H = 18;
+const TRI_W = 12;
+
+function getMarkerSize(text) {
+  const textWidth = Math.max(text.length * FONT_SIZE, 80);
+  const boxW = textWidth + PAD_X * 2;
+  const boxH = FONT_SIZE + PAD_Y * 2;
+  const totalH = boxH + TRI_H;
+  return { boxW, totalH };
+}
+
+/** 生成 marker 的 SVG 内容(气泡样式:渐变背景 + 顶边 + 底部三角) */
+function createMarkerSvg(text, hover = false) {
+  const { boxW, totalH } = getMarkerSize(text);
+  const boxH = FONT_SIZE + PAD_Y * 2;
+
+  const fill = hover ? "url(#bgHover)" : "url(#bg)";
+  const strokeColor = hover ? "#e5b735" : "#98382e";
+  const textColor = hover ? "#e4ca77" : "#98382e";
+
+  return `<svg xmlns="http://www.w3.org/2000/svg" width="${boxW}" height="${totalH}" viewBox="0 0 ${boxW} ${totalH}">
+  <defs>
+    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="0">
+      <stop offset="0%" stop-color="#fffccd"/>
+      <stop offset="50%" stop-color="#fffef0"/>
+      <stop offset="100%" stop-color="#fffccd"/>
+    </linearGradient>
+    <linearGradient id="bgHover" x1="0" y1="0" x2="1" y2="0">
+      <stop offset="0%" stop-color="#99372d"/>
+      <stop offset="50%" stop-color="#b84033"/>
+      <stop offset="100%" stop-color="#99372d"/>
+    </linearGradient>
+  </defs>
+  <!-- 气泡主体 + 底部三角 -->
+  <path d="M 0 ${BORDER_TOP} L 0 ${boxH} L ${boxW / 2 - TRI_W / 2} ${boxH} L ${boxW / 2} ${totalH} L ${boxW / 2 + TRI_W / 2} ${boxH} L ${boxW} ${boxH} L ${boxW} ${BORDER_TOP} L 0 ${BORDER_TOP} Z" fill="${fill}" stroke="none"/>
+  <!-- 顶边 -->
+  <rect x="0" y="0" width="${boxW}" height="${BORDER_TOP}" fill="${strokeColor}"/>
+  <!-- 文本 -->
+  <text x="${boxW / 2}" y="${BORDER_TOP + PAD_Y + FONT_SIZE - 4}" text-anchor="middle" font-size="${FONT_SIZE}" fill="${textColor}" font-family="sans-serif">${escapeXml(text)}</text>
+</svg>`;
+}
+
+function escapeXml(s) {
+  return s
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&apos;");
+}
+
+/** 将 SVG 转为 data URL,供 T.Icon 使用 */
+function svgToDataUrl(svg) {
+  return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
+}
+
+function getSelectedNameFromRoute() {
+  return route.query.name ? String(route.query.name).trim() : "";
+}
+
+function isNameSelected(name) {
+  const sel = getSelectedNameFromRoute();
+  return !!sel && String(name).trim() === sel;
+}
+
+/** 设置 marker 外观:高亮为 true 时使用选中/悬停配色 */
+function applyMarkerVisual(rec, useHighlight) {
+  const { marker, name, boxW, totalH } = rec;
+  const svg = createMarkerSvg(name, useHighlight);
+  marker.setIcon(
+    new T.Icon({
+      iconUrl: svgToDataUrl(svg),
+      iconSize: new T.Point(boxW, totalH),
+      iconAnchor: new T.Point(boxW / 2, totalH),
+    }),
+  );
+}
+
+/** 根据路由同步所有 marker 高亮,并将选中项置于覆盖物最上层 */
+function syncMarkerHighlight() {
+  if (!map || !markerRecords.length) return;
+  for (const rec of markerRecords) {
+    applyMarkerVisual(rec, isNameSelected(rec.name));
+  }
+  const sel = getSelectedNameFromRoute();
+  if (!sel) return;
+  const rec = markerRecords.find((r) => String(r.name).trim() === sel);
+  if (!rec) return;
+  map.removeOverLay(rec.marker);
+  map.addOverLay(rec.marker);
+}
+
+/** 地图 min/maxZoom 与实例一致,用于钳制 query.zoom */
+function clampZoom(z) {
+  return Math.min(18, Math.max(12, z));
+}
+
+function getZoomFromRoute() {
+  if (getSelectedNameFromRoute()) return ZOOM_SIDEBAR_SELECT;
+  const raw = route.query.zoom;
+  if (raw !== undefined && raw !== null && String(raw).trim() !== "") {
+    const z = Number(raw);
+    if (!isNaN(z)) return clampZoom(z);
+  }
+  return ZOOM_DEFAULT;
+}
+
+/** 根据 route query 定位地图中心;侧边栏选中 name、或首页传入 zoom 等 */
+function centerMapToQuery() {
+  if (!map) return;
+  const lng = Number(route.query.lng);
+  const lat = Number(route.query.lat);
+  const center = !isNaN(lng) && !isNaN(lat) ? { lng, lat } : ZHUHAI_CENTER;
+  map.centerAndZoom(new T.LngLat(center.lng, center.lat), getZoomFromRoute());
+}
+
+/** 添加所有有经纬度的数据为地图标记 */
+function addMarkers() {
+  if (!map || !mapData.value.length) return;
+  mapData.value.forEach((item) => {
+    const lng = Number(item.lng);
+    const lat = Number(item.lat);
+    if (isNaN(lng) || isNaN(lat)) return;
+
+    const name = item.name;
+    const { boxW, totalH } = getMarkerSize(name);
+    const svg = createMarkerSvg(name, isNameSelected(name));
+    const iconUrl = svgToDataUrl(svg);
+
+    const icon = new T.Icon({
+      iconUrl,
+      iconSize: new T.Point(boxW, totalH),
+      iconAnchor: new T.Point(boxW / 2, totalH),
+    });
+
+    const marker = new T.Marker(new T.LngLat(lng, lat), { icon });
+    const rec = { marker, item, name, boxW, totalH };
+
+    marker.on("click", () => {
+      if (item.scene) {
+        router.push({ name: "Scene", query: { url: item.scene.trim() } });
+      }
+    });
+    marker.on("mouseover", () => {
+      applyMarkerVisual(rec, true);
+    });
+    marker.on("mouseout", () => {
+      applyMarkerVisual(rec, isNameSelected(name));
+    });
+
+    map.addOverLay(marker);
+    markerRecords.push(rec);
+  });
+  syncMarkerHighlight();
+}
+
+watch(
+  () => ({
+    lng: route.query.lng,
+    lat: route.query.lat,
+    name: route.query.name,
+    zoom: route.query.zoom,
+  }),
+  () => {
+    centerMapToQuery();
+    syncMarkerHighlight();
+  },
+  { deep: true },
+);
+
+onMounted(async () => {
+  if (typeof T === "undefined") {
+    console.error("天地图 API 未加载,请检查 index.html 中的 tk 密钥配置");
+    return;
+  }
+
+  try {
+    const res = await fetch("./data.json");
+    const raw = await res.json();
+    mapData.value = (raw || []).filter(
+      (i) => i.lng && i.lat && !isNaN(Number(i.lng)) && !isNaN(Number(i.lat)),
+    );
+  } catch (e) {
+    console.error("加载 data.json 失败:", e);
+  }
+
+  const tk = "85cd0cdb9cea33df08557f0af24063d1";
+
+  const zhuhaiBounds = new T.LngLatBounds(
+    new T.LngLat(113.05, 21.8),
+    new T.LngLat(114.32, 22.45),
+  );
+
+  const vecUrl = `https://t0.tianditu.gov.cn/vec_c/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=c&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${tk}`;
+
+  const vecLayer = new T.TileLayer(vecUrl, { minZoom: 6, maxZoom: 18 });
+
+  map = new T.Map(mapContainer.value, {
+    projection: "EPSG:4326",
+    minZoom: 12,
+    maxZoom: 18,
+    maxBounds: zhuhaiBounds,
+    layers: [vecLayer],
+  });
+
+  centerMapToQuery();
+  addMarkers();
+});
+
+onBeforeUnmount(() => {
+  markerRecords.forEach((r) => map?.removeOverLay(r.marker));
+  markerRecords.length = 0;
+  map = null;
+});
+</script>
+
+<style scoped lang="scss">
+@use "@/assets/utils.scss";
+
+.map-page {
+  position: fixed;
+  inset: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 0;
+
+  &-back {
+    position: absolute;
+    right: utils.vh-calc(20);
+    bottom: utils.vh-calc(30);
+    width: utils.vh-calc(50);
+    height: utils.vh-calc(50);
+    cursor: pointer;
+    z-index: 999;
+    transition: right 0.3s ease;
+  }
+
+  &--sidebar-visible &-back {
+    /* 与 Home 页 mini-map 一致:侧栏展开时右移约 300 设计单位 */
+    right: utils.vh-calc(320);
+  }
+}
+
+#tdt-map-container {
+  width: 100%;
+  height: 100%;
+}
+
+:deep(.tdt-marker) {
+  cursor: pointer;
+}
+</style>

+ 21 - 5
src/pages/Picture/index.vue

@@ -49,6 +49,12 @@
           <p>{{ slideItem.name }}</p>
         </swiper-slide>
       </swiper>
+
+      <img
+        class="picture-content-back"
+        src="@/assets/images/icon-fanhui.png"
+        @click="$router.push({ name: 'Home' })"
+      />
     </div>
 
     <div
@@ -108,13 +114,13 @@ const currentSlideIndex = computed(() => {
 const bannerImgSrc = computed(() => {
   const name = currentItem.value?.imgName;
   if (!name?.trim()) return "";
-  return `/images/resource/${name.trim()}`;
+  return `./images/resource/${name.trim()}`;
 });
 
 /** 缩略图路径:/images/thumb/ + imgName */
 function getThumbSrc(imgName) {
   if (!imgName?.trim()) return "";
-  return `/images/thumb/${imgName.trim()}`;
+  return `./images/thumb/${imgName.trim()}`;
 }
 
 function onSwiper(swiper) {
@@ -161,7 +167,7 @@ watch(
 
 onMounted(async () => {
   try {
-    const res = await fetch("/data.json");
+    const res = await fetch("./data.json");
     rawData.value = await res.json();
   } catch (e) {
     console.error("加载 data.json 失败:", e);
@@ -207,15 +213,25 @@ onMounted(async () => {
     }
     p {
       text-indent: 2em;
-      font-size: utils.vh-calc(20);
+      font-size: utils.vh-calc(18);
       color: #4e1a00;
-      line-height: utils.vh-calc(40);
+      line-height: utils.vh-calc(30);
     }
   }
   &-content {
+    position: relative;
     padding: utils.vh-calc(90) 105px utils.vh-calc(75) 23px;
     flex: 1;
     width: 0;
+
+    &-back {
+      position: absolute;
+      right: utils.vw-calc(20);
+      bottom: utils.vh-calc(30);
+      width: utils.vh-calc(50);
+      height: utils.vh-calc(50);
+      cursor: pointer;
+    }
   }
   &-swiper {
     margin-top: utils.vh-calc(30);

+ 2 - 2
src/router/index.js

@@ -4,7 +4,7 @@ const router = createRouter({
   history: createWebHashHistory(import.meta.env.BASE_URL),
   routes: [
     {
-      path: "/map",
+      path: "/",
       component: () => import("@/pages/Home/index.vue"),
       name: "Home",
       meta: { showTabbar: true },
@@ -22,7 +22,7 @@ const router = createRouter({
       meta: { showTabbar: false },
     },
     {
-      path: "/",
+      path: "/picture",
       component: () => import("@/pages/Picture/index.vue"),
       name: "Picture",
       meta: { showTabbar: true },