chenlei 14 часов назад
Родитель
Сommit
deb77b05b7
6 измененных файлов с 1442 добавлено и 761 удалено
  1. 767 423
      public/data.json
  2. 1 1
      src/components/Sidebar/index.vue
  3. 318 8
      src/pages/Map/index.vue
  4. 333 328
      src/pages/Picture/index.vue
  5. 20 1
      src/pages/Scene/index.vue
  6. 3 0
      vite.config.js

Разница между файлами не показана из-за своего большого размера
+ 767 - 423
public/data.json


+ 1 - 1
src/components/Sidebar/index.vue

@@ -144,7 +144,7 @@ const areaGroups = computed(() => {
 const typeGroups = computed(() => {
   const map = new Map();
   for (const item of filteredData.value) {
-    const key = item.type || "未分类";
+    const key = item.buildingType || "未分类";
     if (!map.has(key)) map.set(key, []);
     map.get(key).push(item);
   }

+ 318 - 8
src/pages/Map/index.vue

@@ -5,6 +5,37 @@
   >
     <div id="tdt-map-container" ref="mapContainer"></div>
 
+    <transition name="map-cluster-fade">
+      <div v-if="clusterListVisible" class="map-cluster-popup">
+        <div
+          class="map-cluster-popup__mask"
+          @click="clusterListVisible = false"
+        />
+        <div class="map-cluster-popup__panel" @click.stop>
+          <div class="map-cluster-popup__title">
+            该位置共 {{ clusterListItems.length }} 处
+          </div>
+          <ul class="map-cluster-popup__list">
+            <li
+              v-for="rec in clusterListItems"
+              :key="rec.name"
+              class="map-cluster-popup__item"
+              @click="onClusterItemClick(rec)"
+            >
+              {{ rec.name }}
+            </li>
+          </ul>
+          <button
+            type="button"
+            class="map-cluster-popup__close"
+            @click="clusterListVisible = false"
+          >
+            关闭
+          </button>
+        </div>
+      </div>
+    </transition>
+
     <img
       class="map-page-back"
       src="@/assets/images/icon-fanhui.png"
@@ -14,7 +45,7 @@
 </template>
 
 <script setup>
-import { ref, onMounted, onBeforeUnmount, watch } from "vue";
+import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { storeToRefs } from "pinia";
 import { useSidebarStore } from "@/stores/sidebar";
@@ -24,9 +55,19 @@ 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 }>} */
+/** @type {Array<{ marker: T.Marker, item: object, name: string, boxW: number, totalH: number, lng: number, lat: number }>} */
 const markerRecords = [];
+/** @type {Array<{ marker: T.Marker, members: typeof markerRecords }>} */
+const clusterMarkerRecords = [];
 const mapData = ref([]); // 有经纬度的数据项
+const clusterListVisible = ref(false);
+const clusterListItems = ref([]);
+
+/** zoom 小于此值时(更小的级别、视野更远)对屏幕距离过近的标注做聚合;≥ 此值时全部单点展示 */
+const CLUSTER_ZOOM_THRESHOLD = 16;
+/** 图层像素距离小于该值视为堆叠,参与同一聚合 */
+const CLUSTER_PIXEL_RADIUS = 50;
+const CLUSTER_ICON_SIZE = 52;
 
 // 珠海市中心坐标
 const ZHUHAI_CENTER = { lng: 113.57668, lat: 22.271 };
@@ -89,6 +130,22 @@ function escapeXml(s) {
     .replace(/'/g, "&apos;");
 }
 
+/** 聚合点圆形图标(与单点标注配色一致) */
+function createClusterSvg(count, highlight = false) {
+  const s = CLUSTER_ICON_SIZE;
+  const r = 18;
+  const cx = s / 2;
+  const cy = s / 2 - 1;
+  const fill = highlight ? "#b84033" : "#98382e";
+  const stroke = highlight ? "#e5b735" : "#fffccd";
+  const text = count > 99 ? "99+" : String(count);
+  const fontSize = count > 9 ? 14 : 16;
+  return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 ${s} ${s}">
+  <circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
+  <text x="${cx}" y="${cy + fontSize / 3}" text-anchor="middle" font-size="${fontSize}" font-weight="700" fill="#fffef0" font-family="sans-serif">${escapeXml(text)}</text>
+</svg>`;
+}
+
 /** 将 SVG 转为 data URL,供 T.Icon 使用 */
 function svgToDataUrl(svg) {
   return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
@@ -116,20 +173,157 @@ function applyMarkerVisual(rec, useHighlight) {
   );
 }
 
+/** 聚合点图标随路由选中项更新 */
+function syncClusterMarkersHighlight() {
+  for (const cm of clusterMarkerRecords) {
+    const hl = cm.members.some((r) => isNameSelected(r.name));
+    cm.marker.setIcon(
+      new T.Icon({
+        iconUrl: svgToDataUrl(createClusterSvg(cm.members.length, hl)),
+        iconSize: new T.Point(CLUSTER_ICON_SIZE, CLUSTER_ICON_SIZE),
+        iconAnchor: new T.Point(CLUSTER_ICON_SIZE / 2, CLUSTER_ICON_SIZE / 2),
+      }),
+    );
+  }
+}
+
 /** 根据路由同步所有 marker 高亮,并将选中项置于覆盖物最上层 */
 function syncMarkerHighlight() {
   if (!map || !markerRecords.length) return;
   for (const rec of markerRecords) {
     applyMarkerVisual(rec, isNameSelected(rec.name));
   }
+  syncClusterMarkersHighlight();
   const sel = getSelectedNameFromRoute();
   if (!sel) return;
   const rec = markerRecords.find((r) => String(r.name).trim() === sel);
   if (!rec) return;
+  const overlays = map.getOverlays?.() ?? [];
+  if (!overlays.includes(rec.marker)) return;
   map.removeOverLay(rec.marker);
   map.addOverLay(rec.marker);
 }
 
+/**
+ * 在图层像素坐标下按距离做并查集聚合(传递闭包:A 近 B、B 近 C 则三者合一)
+ * @param {typeof markerRecords} records
+ */
+/** 使用容器像素坐标(与标注实际屏幕位置一致);图层坐标 lngLatToLayerPoint 在 EPSG:4326 下与堆叠判断易不一致 */
+function clusterRecordsByPixel(records, mapInstance, thresholdPx) {
+  const n = records.length;
+  if (n === 0) return [];
+  const pts = records.map((rec) => ({
+    rec,
+    p: mapInstance.lngLatToContainerPoint(new T.LngLat(rec.lng, rec.lat)),
+  }));
+  const parent = Array.from({ length: n }, (_, i) => i);
+  function find(x) {
+    return parent[x] === x ? x : (parent[x] = find(parent[x]));
+  }
+  function union(a, b) {
+    const ra = find(a);
+    const rb = find(b);
+    if (ra !== rb) parent[rb] = ra;
+  }
+  const t2 = thresholdPx * thresholdPx;
+  for (let i = 0; i < n; i++) {
+    for (let j = i + 1; j < n; j++) {
+      const dx = pts[i].p.x - pts[j].p.x;
+      const dy = pts[i].p.y - pts[j].p.y;
+      if (dx * dx + dy * dy <= t2) union(i, j);
+    }
+  }
+  const groups = new Map();
+  for (let i = 0; i < n; i++) {
+    const r = find(i);
+    if (!groups.has(r)) groups.set(r, []);
+    groups.get(r).push(pts[i].rec);
+  }
+  return [...groups.values()];
+}
+
+function clearClusterMarkersFromMap() {
+  for (const cm of clusterMarkerRecords) {
+    map?.removeOverLay(cm.marker);
+  }
+  clusterMarkerRecords.length = 0;
+}
+
+/** 按当前缩放与视野刷新:zoom<16 时堆叠点聚合,≥16 时全部单点展示 */
+function refreshMarkersDisplay() {
+  if (!map || !markerRecords.length) return;
+  clearClusterMarkersFromMap();
+  for (const r of markerRecords) {
+    map.removeOverLay(r.marker);
+  }
+  const z = Number(map.getZoom());
+  if (isNaN(z) || z >= CLUSTER_ZOOM_THRESHOLD) {
+    for (const r of markerRecords) {
+      map.addOverLay(r.marker);
+    }
+    clusterListVisible.value = false;
+    syncMarkerHighlight();
+    return;
+  }
+  const groups = clusterRecordsByPixel(
+    markerRecords,
+    map,
+    CLUSTER_PIXEL_RADIUS,
+  );
+  for (const group of groups) {
+    if (group.length === 1) {
+      map.addOverLay(group[0].marker);
+      continue;
+    }
+    let sumLng = 0;
+    let sumLat = 0;
+    for (const g of group) {
+      sumLng += g.lng;
+      sumLat += g.lat;
+    }
+    const lng = sumLng / group.length;
+    const lat = sumLat / group.length;
+    const highlight = group.some((r) => isNameSelected(r.name));
+    const clusterMarker = new T.Marker(new T.LngLat(lng, lat), {
+      icon: new T.Icon({
+        iconUrl: svgToDataUrl(createClusterSvg(group.length, highlight)),
+        iconSize: new T.Point(CLUSTER_ICON_SIZE, CLUSTER_ICON_SIZE),
+        iconAnchor: new T.Point(CLUSTER_ICON_SIZE / 2, CLUSTER_ICON_SIZE / 2),
+      }),
+    });
+    clusterMarker.on("click", () => {
+      clusterListItems.value = group;
+      clusterListVisible.value = true;
+    });
+    map.addOverLay(clusterMarker);
+    clusterMarkerRecords.push({ marker: clusterMarker, members: group });
+  }
+  syncMarkerHighlight();
+}
+
+function handleMapViewChanged() {
+  requestAnimationFrame(() => {
+    refreshMarkersDisplay();
+  });
+}
+
+function sceneRouteQuery(item) {
+  const url = String(item.scene).trim();
+  const q = { url };
+  if (item.batch != null && item.batch !== "") {
+    q.batch = String(item.batch);
+  }
+  return q;
+}
+
+function onClusterItemClick(rec) {
+  clusterListVisible.value = false;
+  const item = rec.item;
+  if (item.scene) {
+    router.push({ name: "Scene", query: sceneRouteQuery(item) });
+  }
+}
+
 /** 地图 min/maxZoom 与实例一致,用于钳制 query.zoom */
 function clampZoom(z) {
   return Math.min(18, Math.max(12, z));
@@ -174,11 +368,11 @@ function addMarkers() {
     });
 
     const marker = new T.Marker(new T.LngLat(lng, lat), { icon });
-    const rec = { marker, item, name, boxW, totalH };
+    const rec = { marker, item, name, boxW, totalH, lng, lat };
 
     marker.on("click", () => {
       if (item.scene) {
-        router.push({ name: "Scene", query: { url: item.scene.trim() } });
+        router.push({ name: "Scene", query: sceneRouteQuery(item) });
       }
     });
     marker.on("mouseover", () => {
@@ -188,10 +382,9 @@ function addMarkers() {
       applyMarkerVisual(rec, isNameSelected(name));
     });
 
-    map.addOverLay(marker);
     markerRecords.push(rec);
   });
-  syncMarkerHighlight();
+  refreshMarkersDisplay();
 }
 
 watch(
@@ -201,9 +394,10 @@ watch(
     name: route.query.name,
     zoom: route.query.zoom,
   }),
-  () => {
+  async () => {
     centerMapToQuery();
-    syncMarkerHighlight();
+    await nextTick();
+    refreshMarkersDisplay();
   },
   { deep: true },
 );
@@ -245,9 +439,33 @@ onMounted(async () => {
 
   centerMapToQuery();
   addMarkers();
+
+  if (typeof map.on === "function") {
+    map.on("zoomend", handleMapViewChanged);
+    map.on("moveend", handleMapViewChanged);
+  } else {
+    map.addEventListener("zoomend", handleMapViewChanged);
+    map.addEventListener("moveend", handleMapViewChanged);
+  }
+
+  await nextTick();
+  requestAnimationFrame(() => {
+    refreshMarkersDisplay();
+  });
 });
 
 onBeforeUnmount(() => {
+  clusterListVisible.value = false;
+  if (map) {
+    if (typeof map.off === "function") {
+      map.off("zoomend", handleMapViewChanged);
+      map.off("moveend", handleMapViewChanged);
+    } else {
+      map.removeEventListener("zoomend", handleMapViewChanged);
+      map.removeEventListener("moveend", handleMapViewChanged);
+    }
+  }
+  clearClusterMarkersFromMap();
   markerRecords.forEach((r) => map?.removeOverLay(r.marker));
   markerRecords.length = 0;
   map = null;
@@ -289,4 +507,96 @@ onBeforeUnmount(() => {
 :deep(.tdt-marker) {
   cursor: pointer;
 }
+
+.map-cluster-popup {
+  position: fixed;
+  inset: 0;
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  pointer-events: none;
+
+  &__mask {
+    position: absolute;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.35);
+    pointer-events: auto;
+  }
+
+  &__panel {
+    position: relative;
+    width: min(92vw, 700px);
+    max-height: min(55vh, 500);
+    padding: utils.vh-calc(16) utils.vw-calc(20);
+    background: rgb(217, 191, 157);
+    border: 2px solid #98382e;
+    border-radius: utils.vh-calc(8);
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
+    pointer-events: auto;
+    display: flex;
+    flex-direction: column;
+    gap: utils.vh-calc(10);
+  }
+
+  &__title {
+    font-size: utils.vh-calc(16);
+    font-weight: 600;
+    color: #98382e;
+    text-align: center;
+  }
+
+  &__list {
+    list-style: none;
+    margin: 0;
+    padding: 0 utils.vw-calc(20);
+    overflow-y: auto;
+    flex: 1;
+    min-height: 0;
+  }
+
+  &__item {
+    padding: utils.vh-calc(10) utils.vh-calc(12);
+    margin-bottom: utils.vh-calc(6);
+    font-size: utils.vh-calc(15);
+    color: #333;
+    background: rgba(255, 255, 255, 0.7);
+    border-radius: utils.vh-calc(6);
+    cursor: pointer;
+    transition: background 0.15s ease;
+
+    &:hover {
+      background: rgba(152, 56, 46, 0.12);
+    }
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  &__close {
+    align-self: center;
+    padding: utils.vh-calc(8) utils.vh-calc(24);
+    font-size: utils.vh-calc(14);
+    color: #98382e;
+    background: transparent;
+    border: 1px solid #98382e;
+    border-radius: utils.vh-calc(6);
+    cursor: pointer;
+
+    &:hover {
+      background: rgba(152, 56, 46, 0.08);
+    }
+  }
+}
+
+.map-cluster-fade-enter-active,
+.map-cluster-fade-leave-active {
+  transition: opacity 0.2s ease;
+}
+
+.map-cluster-fade-enter-from,
+.map-cluster-fade-leave-to {
+  opacity: 0;
+}
 </style>

+ 333 - 328
src/pages/Picture/index.vue

@@ -1,328 +1,333 @@
-<template>
-  <div class="picture">
-    <div class="picture-sidebar">
-      <h3>{{ currentItem?.name || "加载中..." }}</h3>
-      <el-scrollbar>
-        <p>{{ currentItem?.desc || "" }}</p>
-      </el-scrollbar>
-    </div>
-
-    <div class="picture-content">
-      <div class="picture-banner">
-        <el-image v-if="currentItem?.imgName" :src="bannerImgSrc" fit="cover" />
-        <div v-else class="picture-banner-placeholder" />
-        <div v-if="currentItem?.scene" class="scene-btn" @click="goToScene">
-          全景漫游
-        </div>
-      </div>
-
-      <swiper
-        :modules="modules"
-        :slides-per-view="'auto'"
-        :space-between="10"
-        :mousewheel="{
-          enabled: true,
-          forceToAxis: false,
-        }"
-        :free-mode="true"
-        :scrollbar="{
-          hide: false,
-          draggable: true,
-        }"
-        class="picture-swiper"
-        @swiper="onSwiper"
-      >
-        <swiper-slide
-          v-for="(slideItem, idx) in areaItems"
-          :key="slideItem.id"
-          class="picture-slide"
-          :class="{ 'picture-slide--active': slideItem.id === currentItem?.id }"
-          @click="selectItem(slideItem)"
-        >
-          <el-image
-            v-if="slideItem.imgName"
-            :src="getThumbSrc(slideItem.imgName)"
-            fit="cover"
-            lazy
-          />
-          <div v-else class="picture-slide-placeholder" />
-          <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
-      class="picture-temp"
-      :class="{ 'picture-temp--hidden': !sidebarVisible }"
-    />
-  </div>
-</template>
-
-<script setup>
-import { ref, computed, watch, onMounted } from "vue";
-import { useRoute, useRouter } from "vue-router";
-import { storeToRefs } from "pinia";
-import { useSidebarStore } from "@/stores/sidebar";
-import { Swiper, SwiperSlide } from "swiper/vue";
-import { FreeMode, Mousewheel, Scrollbar } from "swiper/modules";
-import "swiper/css";
-import "swiper/css/free-mode";
-import "swiper/css/scrollbar";
-
-const modules = [FreeMode, Mousewheel, Scrollbar];
-const { visible: sidebarVisible } = storeToRefs(useSidebarStore());
-const route = useRoute();
-const router = useRouter();
-
-const rawData = ref([]);
-const swiperInstance = ref(null);
-
-/** 从 URL 或默认取当前 item */
-const currentItem = computed(() => {
-  const list = rawData.value || [];
-  if (!list.length) return null;
-  const id = route.query.id;
-  if (id) {
-    const found = list.find((i) => i.id === id);
-    if (found) return found;
-  }
-  return list[0];
-});
-
-/** 当前 item 所在区的列表(用于 swiper) */
-const areaItems = computed(() => {
-  const item = currentItem.value;
-  if (!item?.area) return [];
-  return (rawData.value || []).filter((i) => i.area === item.area);
-});
-
-/** 当前 item 在 areaItems 中的索引 */
-const currentSlideIndex = computed(() => {
-  const item = currentItem.value;
-  if (!item) return 0;
-  const idx = areaItems.value.findIndex((i) => i.id === item.id);
-  return idx >= 0 ? idx : 0;
-});
-
-/** Banner 大图路径:/images/resource/ + imgName */
-const bannerImgSrc = computed(() => {
-  const name = currentItem.value?.imgName;
-  if (!name?.trim()) return "";
-  return `./images/resource/${name.trim()}`;
-});
-
-/** 缩略图路径:/images/thumb/ + imgName */
-function getThumbSrc(imgName) {
-  if (!imgName?.trim()) return "";
-  return `./images/thumb/${imgName.trim()}`;
-}
-
-function onSwiper(swiper) {
-  swiperInstance.value = swiper;
-}
-
-/** 点击 swiper slide 切换 item */
-function selectItem(item) {
-  router.replace({ name: "Picture", query: { ...route.query, id: item.id } });
-}
-
-/** 跳转到全景漫游 */
-function goToScene() {
-  const url = currentItem.value?.scene?.trim();
-  if (!url) return;
-  router.push({ name: "Scene", query: { url } });
-}
-
-/** 无 id 时用第一个 item 的 id 写入 URL,便于刷新恢复 */
-watch(
-  () => [rawData.value, route.query.id],
-  () => {
-    const list = rawData.value || [];
-    if (!list.length || route.query.id) return;
-    const first = list[0];
-    if (first) {
-      router.replace({ name: "Picture", query: { id: first.id } });
-    }
-  },
-  { immediate: true },
-);
-
-/** 当前 item 变化时,swiper 滚动到对应位置 */
-watch(
-  () => [currentSlideIndex.value, swiperInstance.value, areaItems.value.length],
-  () => {
-    const swiper = swiperInstance.value;
-    if (!swiper || areaItems.value.length === 0) return;
-    const idx = currentSlideIndex.value;
-    swiper.slideTo(idx, 300);
-  },
-  { immediate: true },
-);
-
-onMounted(async () => {
-  try {
-    const res = await fetch("./data.json");
-    rawData.value = await res.json();
-  } catch (e) {
-    console.error("加载 data.json 失败:", e);
-  }
-});
-</script>
-
-<style scoped lang="scss">
-@use "@/assets/utils.scss";
-
-.picture {
-  position: absolute;
-  inset: 0;
-  display: flex;
-  justify-content: stretch;
-  overflow: hidden;
-  background: url("./images/bg.jpg") no-repeat center / cover;
-
-  .el-image {
-    width: 100%;
-    height: 100%;
-  }
-  &-sidebar {
-    display: flex;
-    flex-direction: column;
-    padding: utils.vh-calc(110) 0 utils.vh-calc(170);
-    flex: 0 0 utils.vh-calc(382);
-    height: 100%;
-    background: linear-gradient(to left, transparent, rgba(197, 166, 123, 0.8));
-
-    h3 {
-      padding: 0 40px;
-      margin-bottom: utils.vh-calc(10);
-      font-size: utils.vh-calc(36);
-      color: #98382e;
-      font-family: "SourceHanSerifSC-Bold";
-      text-align: center;
-    }
-    .el-scrollbar {
-      flex: 1;
-      height: 0;
-      padding: 0 40px;
-    }
-    p {
-      text-indent: 2em;
-      font-size: utils.vh-calc(18);
-      color: #4e1a00;
-      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);
-    width: 100%;
-
-    :deep(.swiper-scrollbar) {
-      position: relative;
-      margin-top: utils.vh-calc(20);
-      height: utils.vh-calc(4);
-      border-radius: 50px;
-      background: transparent;
-
-      .swiper-scrollbar-drag {
-        background: #98382e;
-        border-radius: 50px;
-        cursor: pointer;
-      }
-    }
-  }
-  &-slide {
-    position: relative;
-    width: 220px;
-    height: 150px;
-    border-radius: 10px;
-    overflow: hidden;
-    cursor: pointer;
-
-    &--active {
-      border: 2px solid #98382e;
-
-      p {
-        background: #98382e !important;
-      }
-    }
-
-    p {
-      position: absolute;
-      left: 0;
-      right: 0;
-      bottom: 0;
-      padding: 0 10px;
-      height: utils.vh-calc(30);
-      line-height: utils.vh-calc(30);
-      font-size: utils.vh-calc(16);
-      color: #f7e8d4;
-      background: rgba(151, 92, 64, 0.9);
-    }
-  }
-  &-slide-placeholder {
-    width: 100%;
-    height: 100%;
-    background: #c0c0c0;
-  }
-  &-banner {
-    position: relative;
-    height: utils.vh-calc(644);
-    box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.25);
-    border-radius: 30px;
-    overflow: hidden;
-
-    &-placeholder {
-      width: 100%;
-      height: 100%;
-      background: #c0c0c0;
-    }
-
-    .scene-btn {
-      position: absolute;
-      bottom: utils.vh-calc(14);
-      left: 50%;
-      transform: translateX(-50%);
-      width: utils.vh-calc(233);
-      height: utils.vh-calc(66);
-      text-align: center;
-      font-size: utils.vh-calc(24);
-      color: #744e2e;
-      padding-left: utils.vh-calc(35);
-      line-height: utils.vh-calc(66);
-      font-family: "SourceHanSerifSC-Bold";
-      cursor: pointer;
-      background: url("@/assets/images/scene-btn.png") no-repeat center /
-        contain;
-    }
-  }
-  &-temp {
-    flex: 0 0 utils.vh-calc(300);
-    transition: flex 0.3s ease;
-
-    &--hidden {
-      flex: 0 0 0;
-    }
-  }
-}
-</style>
+<template>
+  <div class="picture">
+    <div class="picture-sidebar">
+      <h3>{{ currentItem?.name || "加载中..." }}</h3>
+      <el-scrollbar>
+        <p>{{ currentItem?.desc || "" }}</p>
+      </el-scrollbar>
+    </div>
+
+    <div class="picture-content">
+      <div class="picture-banner">
+        <el-image v-if="currentItem?.imgName" :src="bannerImgSrc" fit="cover" />
+        <div v-else class="picture-banner-placeholder" />
+        <div v-if="currentItem?.scene" class="scene-btn" @click="goToScene">
+          全景漫游
+        </div>
+      </div>
+
+      <swiper
+        :modules="modules"
+        :slides-per-view="'auto'"
+        :space-between="10"
+        :mousewheel="{
+          enabled: true,
+          forceToAxis: false,
+        }"
+        :free-mode="true"
+        :scrollbar="{
+          hide: false,
+          draggable: true,
+        }"
+        class="picture-swiper"
+        @swiper="onSwiper"
+      >
+        <swiper-slide
+          v-for="(slideItem, idx) in areaItems"
+          :key="slideItem.id"
+          class="picture-slide"
+          :class="{ 'picture-slide--active': slideItem.id === currentItem?.id }"
+          @click="selectItem(slideItem)"
+        >
+          <el-image
+            v-if="slideItem.imgName"
+            :src="getThumbSrc(slideItem.imgName)"
+            fit="cover"
+            lazy
+          />
+          <div v-else class="picture-slide-placeholder" />
+          <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
+      class="picture-temp"
+      :class="{ 'picture-temp--hidden': !sidebarVisible }"
+    />
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch, onMounted } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { storeToRefs } from "pinia";
+import { useSidebarStore } from "@/stores/sidebar";
+import { Swiper, SwiperSlide } from "swiper/vue";
+import { FreeMode, Mousewheel, Scrollbar } from "swiper/modules";
+import "swiper/css";
+import "swiper/css/free-mode";
+import "swiper/css/scrollbar";
+
+const modules = [FreeMode, Mousewheel, Scrollbar];
+const { visible: sidebarVisible } = storeToRefs(useSidebarStore());
+const route = useRoute();
+const router = useRouter();
+
+const rawData = ref([]);
+const swiperInstance = ref(null);
+
+/** 从 URL 或默认取当前 item */
+const currentItem = computed(() => {
+  const list = rawData.value || [];
+  if (!list.length) return null;
+  const id = route.query.id;
+  if (id) {
+    const found = list.find((i) => i.id === id);
+    if (found) return found;
+  }
+  return list[0];
+});
+
+/** 当前 item 所在区的列表(用于 swiper) */
+const areaItems = computed(() => {
+  const item = currentItem.value;
+  if (!item?.area) return [];
+  return (rawData.value || []).filter((i) => i.area === item.area);
+});
+
+/** 当前 item 在 areaItems 中的索引 */
+const currentSlideIndex = computed(() => {
+  const item = currentItem.value;
+  if (!item) return 0;
+  const idx = areaItems.value.findIndex((i) => i.id === item.id);
+  return idx >= 0 ? idx : 0;
+});
+
+/** Banner 大图路径:/images/resource/ + imgName */
+const bannerImgSrc = computed(() => {
+  const name = currentItem.value?.imgName;
+  if (!name?.trim()) return "";
+  return `./images/resource/${name.trim()}`;
+});
+
+/** 缩略图路径:/images/thumb/ + imgName */
+function getThumbSrc(imgName) {
+  if (!imgName?.trim()) return "";
+  return `./images/thumb/${imgName.trim()}`;
+}
+
+function onSwiper(swiper) {
+  swiperInstance.value = swiper;
+}
+
+/** 点击 swiper slide 切换 item */
+function selectItem(item) {
+  router.replace({ name: "Picture", query: { ...route.query, id: item.id } });
+}
+
+/** 跳转到全景漫游 */
+function goToScene() {
+  const item = currentItem.value;
+  const url = item?.scene?.trim();
+  if (!url) return;
+  const query = { url };
+  if (item.batch != null && item.batch !== "") {
+    query.batch = String(item.batch);
+  }
+  router.push({ name: "Scene", query });
+}
+
+/** 无 id 时用第一个 item 的 id 写入 URL,便于刷新恢复 */
+watch(
+  () => [rawData.value, route.query.id],
+  () => {
+    const list = rawData.value || [];
+    if (!list.length || route.query.id) return;
+    const first = list[0];
+    if (first) {
+      router.replace({ name: "Picture", query: { id: first.id } });
+    }
+  },
+  { immediate: true },
+);
+
+/** 当前 item 变化时,swiper 滚动到对应位置 */
+watch(
+  () => [currentSlideIndex.value, swiperInstance.value, areaItems.value.length],
+  () => {
+    const swiper = swiperInstance.value;
+    if (!swiper || areaItems.value.length === 0) return;
+    const idx = currentSlideIndex.value;
+    swiper.slideTo(idx, 300);
+  },
+  { immediate: true },
+);
+
+onMounted(async () => {
+  try {
+    const res = await fetch("./data.json");
+    rawData.value = await res.json();
+  } catch (e) {
+    console.error("加载 data.json 失败:", e);
+  }
+});
+</script>
+
+<style scoped lang="scss">
+@use "@/assets/utils.scss";
+
+.picture {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  justify-content: stretch;
+  overflow: hidden;
+  background: url("./images/bg.jpg") no-repeat center / cover;
+
+  .el-image {
+    width: 100%;
+    height: 100%;
+  }
+  &-sidebar {
+    display: flex;
+    flex-direction: column;
+    padding: utils.vh-calc(110) 0 utils.vh-calc(170);
+    flex: 0 0 utils.vh-calc(382);
+    height: 100%;
+    background: linear-gradient(to left, transparent, rgba(197, 166, 123, 0.8));
+
+    h3 {
+      padding: 0 40px;
+      margin-bottom: utils.vh-calc(10);
+      font-size: utils.vh-calc(36);
+      color: #98382e;
+      font-family: "SourceHanSerifSC-Bold";
+      text-align: center;
+    }
+    .el-scrollbar {
+      flex: 1;
+      height: 0;
+      padding: 0 40px;
+    }
+    p {
+      text-indent: 2em;
+      font-size: utils.vh-calc(18);
+      color: #4e1a00;
+      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);
+    width: 100%;
+
+    :deep(.swiper-scrollbar) {
+      position: relative;
+      margin-top: utils.vh-calc(20);
+      height: utils.vh-calc(4);
+      border-radius: 50px;
+      background: transparent;
+
+      .swiper-scrollbar-drag {
+        background: #98382e;
+        border-radius: 50px;
+        cursor: pointer;
+      }
+    }
+  }
+  &-slide {
+    position: relative;
+    width: 220px;
+    height: 150px;
+    border-radius: 10px;
+    overflow: hidden;
+    cursor: pointer;
+
+    &--active {
+      border: 2px solid #98382e;
+
+      p {
+        background: #98382e !important;
+      }
+    }
+
+    p {
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      padding: 0 10px;
+      height: utils.vh-calc(30);
+      line-height: utils.vh-calc(30);
+      font-size: utils.vh-calc(16);
+      color: #f7e8d4;
+      background: rgba(151, 92, 64, 0.9);
+    }
+  }
+  &-slide-placeholder {
+    width: 100%;
+    height: 100%;
+    background: #c0c0c0;
+  }
+  &-banner {
+    position: relative;
+    height: utils.vh-calc(644);
+    box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.25);
+    border-radius: 30px;
+    overflow: hidden;
+
+    &-placeholder {
+      width: 100%;
+      height: 100%;
+      background: #c0c0c0;
+    }
+
+    .scene-btn {
+      position: absolute;
+      bottom: utils.vh-calc(14);
+      left: 50%;
+      transform: translateX(-50%);
+      width: utils.vh-calc(233);
+      height: utils.vh-calc(66);
+      text-align: center;
+      font-size: utils.vh-calc(24);
+      color: #744e2e;
+      padding-left: utils.vh-calc(35);
+      line-height: utils.vh-calc(66);
+      font-family: "SourceHanSerifSC-Bold";
+      cursor: pointer;
+      background: url("@/assets/images/scene-btn.png") no-repeat center /
+        contain;
+    }
+  }
+  &-temp {
+    flex: 0 0 utils.vh-calc(300);
+    transition: flex 0.3s ease;
+
+    &--hidden {
+      flex: 0 0 0;
+    }
+  }
+}
+</style>

+ 20 - 1
src/pages/Scene/index.vue

@@ -13,6 +13,9 @@
       :class="{ 'scene-back-btn--sidebar-visible': sidebarVisible }"
       @click="goBack"
     />
+    <span class="scene-page-date">{{
+      VERSION_MAP[Number(route.query.batch)]
+    }}</span>
   </div>
 </template>
 
@@ -26,7 +29,14 @@ const router = useRouter();
 const route = useRoute();
 const { visible: sidebarVisible } = storeToRefs(useSidebarStore());
 
-// iframe 地址,通过 route query 传入,如 /#/scene?url=https://example.com
+const VERSION_MAP = {
+  1: "采集时间:2021年",
+  2: "采集时间:2021年",
+  3: "采集时间:2025年",
+  4: "采集时间:2025年",
+};
+
+// iframe 地址与批次:如 /#/scene?url=https://example.com&batch=1
 const iframeSrc = computed(() => route.query.url || "about:blank");
 
 const goBack = () => {
@@ -43,6 +53,15 @@ const goBack = () => {
   width: 100vw;
   height: 100vh;
   z-index: 0;
+
+  &-date {
+    position: absolute;
+    top: 10px;
+    right: 80px;
+    color: white;
+    font-size: 14px;
+    font-family: "微软雅黑";
+  }
 }
 
 .scene-iframe {

+ 3 - 0
vite.config.js

@@ -10,6 +10,9 @@ import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
 // https://vite.dev/config/
 export default defineConfig({
   base: "./",
+  server: {
+    host: "0.0.0.0",
+  },
   plugins: [
     vue(),
     AutoImport({