chenlei 1 hari lalu
induk
melakukan
628d68cce3

+ 9 - 4
index.html

@@ -1,13 +1,18 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="">
   <head>
-    <meta charset="UTF-8">
-    <link rel="icon" href="/favicon.ico">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>珠海市历史建筑三维展示与导览平台</title>
   </head>
   <body>
     <div id="app"></div>
+    <!-- 天地图 API,需在 https://lbs.tianditu.gov.cn/ 申请 tk 密钥 -->
+    <script
+      src="https://api.tianditu.gov.cn/api?v=4.0&tk=85cd0cdb9cea33df08557f0af24063d1"
+      type="text/javascript"
+    ></script>
     <script type="module" src="/src/main.js"></script>
   </body>
 </html>

File diff ditekan karena terlalu besar
+ 2066 - 0
public/data.json


TEMPAT SAMPAH
public/favicon.ico


+ 6 - 1
src/App.vue

@@ -1,13 +1,18 @@
 <script setup>
+import { computed } from "vue";
+import { useRoute } from "vue-router";
 import { RouterView } from "vue-router";
 import Sidebar from "@/components/Sidebar/index.vue";
 import Tabbar from "@/components/Tabbar/index.vue";
+
+const route = useRoute();
+const showTabbar = computed(() => route.meta?.showTabbar !== false);
 </script>
 
 <template>
   <RouterView />
   <Sidebar />
-  <Tabbar />
+  <Tabbar v-if="showTabbar" />
 </template>
 
 <style lang="scss">

+ 210 - 44
src/components/Sidebar/index.vue

@@ -2,56 +2,193 @@
   <div class="sidebar" :class="{ 'sidebar--hidden': !visible }">
     <div class="sidebar-hide-btn" @click="toggle" />
 
-    <img draggable="false" class="sidebar-logo" src="@/assets/images/logo.png" alt="logo" />
+    <img
+      draggable="false"
+      class="sidebar-logo"
+      src="@/assets/images/logo.png"
+      alt="logo"
+    />
 
     <div class="sidebar-search">
-      <input name="search" placeholder="请搜索建筑名称" />
+      <input
+        v-model="searchKeyword"
+        name="search"
+        placeholder="请搜索建筑名称"
+      />
       <i class="sidebar-search-icon" />
     </div>
 
-    <el-tabs v-model="activeTab" class="sidebar-tabs" stretch @tab-change="handleTabChange">
+    <el-tabs
+      v-model="activeTab"
+      class="sidebar-tabs"
+      stretch
+      @tab-change="handleTabChange"
+    >
       <el-tab-pane label="区域划分" name="area">
         <el-scrollbar>
-          <el-collapse v-for="item in 10" :key="item" class="sidebar-collapse">
-            <el-collapse-item title="区域1" name="area1">
+          <div v-if="!areaGroups.length" class="sidebar-empty">
+            {{ searchKeyword ? "无匹配结果" : "暂无数据" }}
+          </div>
+          <el-collapse v-else class="sidebar-collapse">
+            <el-collapse-item
+              v-for="(group, idx) in areaGroups"
+              :key="'area-' + group.key"
+              :name="'area-' + group.key"
+            >
+              <template #title>
+                <span class="collapse-header">
+                  <span class="tag">{{ group.items.length }}</span>
+                  <span class="collapse-title">{{ group.key }}</span>
+                </span>
+              </template>
+              <template #icon="{ isActive }">
+                <span class="icon" :class="{ 'icon--active': isActive }" />
+              </template>
+              <ul>
+                <li
+                  v-for="item in group.items"
+                  :key="item.id"
+                  :class="{ active: isItemActive(item) }"
+                  @click="handleItemClick(item)"
+                >
+                  ·{{ item.name }}
+                </li>
+              </ul>
+            </el-collapse-item>
+          </el-collapse>
+        </el-scrollbar>
+      </el-tab-pane>
+      <el-tab-pane label="类型划分" name="type">
+        <el-scrollbar>
+          <div v-if="!typeGroups.length" class="sidebar-empty">
+            {{ searchKeyword ? "无匹配结果" : "暂无数据" }}
+          </div>
+          <el-collapse v-else class="sidebar-collapse">
+            <el-collapse-item
+              v-for="(group, idx) in typeGroups"
+              :key="'type-' + group.key"
+              :name="'type-' + group.key"
+            >
+              <template #title>
+                <span class="collapse-header">
+                  <span class="tag">{{ group.items.length }}</span>
+                  <span class="collapse-title">{{ group.key }}</span>
+                </span>
+              </template>
               <template #icon="{ isActive }">
-                <span class="tag">20</span>
                 <span class="icon" :class="{ 'icon--active': isActive }" />
               </template>
-
               <ul>
-                <li>·建筑1</li>
-                <li>·建筑2</li>
-                <li>·建筑3</li>
+                <li
+                  v-for="item in group.items"
+                  :key="item.id"
+                  :class="{ active: isItemActive(item) }"
+                  @click="handleItemClick(item)"
+                >
+                  ·{{ item.name }}
+                </li>
               </ul>
             </el-collapse-item>
           </el-collapse>
         </el-scrollbar>
       </el-tab-pane>
-      <el-tab-pane label="类型划分" name="type" />
     </el-tabs>
   </div>
 </template>
 
 <script setup>
-import { ref } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useSidebarStore } from '@/stores/sidebar'
+import { ref, computed, onMounted } from "vue";
+import { useRouter, useRoute } from "vue-router";
+import { storeToRefs } from "pinia";
+import { useSidebarStore } from "@/stores/sidebar";
+
+const emit = defineEmits(["select"]);
+const router = useRouter();
+const route = useRoute();
+const { visible } = storeToRefs(useSidebarStore());
+const { toggle } = useSidebarStore();
+
+const searchKeyword = ref("");
+const activeTab = ref("area");
+const rawData = ref([]);
 
-const { visible } = storeToRefs(useSidebarStore())
-const { toggle } = useSidebarStore()
+onMounted(async () => {
+  try {
+    const res = await fetch("/data.json");
+    rawData.value = await res.json();
+  } catch (e) {
+    console.error("加载 data.json 失败:", e);
+  }
+});
+
+/** 根据搜索关键词过滤后的数据 */
+const filteredData = computed(() => {
+  const list = rawData.value || [];
+  const kw = (searchKeyword.value || "").trim().toLowerCase();
+  if (!kw) return list;
+  return list.filter((item) => (item.name || "").toLowerCase().includes(kw));
+});
 
-const search = ref('')
-const activeTab = ref('area')
+/** 按 area 分组,保持数据顺序 */
+const areaGroups = computed(() => {
+  const map = new Map();
+  for (const item of filteredData.value) {
+    const key = item.area || "未分类";
+    if (!map.has(key)) map.set(key, []);
+    map.get(key).push(item);
+  }
+  return Array.from(map.entries()).map(([key, items]) => ({ key, items }));
+});
+
+/** 按 type 分组,保持数据顺序 */
+const typeGroups = computed(() => {
+  const map = new Map();
+  for (const item of filteredData.value) {
+    const key = item.type || "未分类";
+    if (!map.has(key)) map.set(key, []);
+    map.get(key).push(item);
+  }
+  return Array.from(map.entries()).map(([key, items]) => ({ key, items }));
+});
+
+/** 当前选中的 item id(图文模式用 id,地图模式用 name 匹配) */
+const activeItemId = computed(() => {
+  if (route.name === "Picture" && route.query.id) return route.query.id;
+  if (route.name === "MapView" && route.query.name) {
+    const found = rawData.value.find(
+      (i) => (i.name || "").trim() === String(route.query.name).trim(),
+    );
+    return found?.id;
+  }
+  return null;
+});
+
+const isItemActive = (item) => item?.id === activeItemId.value;
 
 const handleTabChange = (tab) => {
-  console.log(tab)
-}
+  // 可在此处理 tab 切换逻辑
+};
+
+const handleItemClick = (item) => {
+  emit("select", item);
+  // 图文模式页:点击 item 则显示该 item
+  if (route.name === "Picture") {
+    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({
+      name: "MapView",
+      query: { lng: item.lng, lat: item.lat, name: item.name },
+    });
+  }
+};
 </script>
 
 <style scoped lang="scss">
-
-@use '@/assets/utils.scss';
+@use "@/assets/utils.scss";
 
 .sidebar {
   position: absolute;
@@ -62,7 +199,8 @@ const handleTabChange = (tab) => {
   flex-direction: column;
   align-items: center;
   width: utils.vh-calc(300);
-  background: linear-gradient(#D9BF9D, #CFAC7D);
+  background: linear-gradient(rgba(217, 191, 157, 1), rgba(207, 172, 125, 0.8));
+  backdrop-filter: blur(10px);
   transition: transform 0.3s ease;
 
   &--hidden {
@@ -74,16 +212,16 @@ const handleTabChange = (tab) => {
   }
 
   :deep(.sidebar-tabs) {
-    --el-text-color-primary: #4E1A00;
+    --el-text-color-primary: #4e1a00;
     --el-font-size-base: utils.vh-calc(20);
-    --el-border-color-light: #98382E;
+    --el-border-color-light: #98382e;
     margin: utils.vh-calc(25) 0;
     width: 100%;
     height: 0;
     flex: 1;
 
     .el-tabs__item.is-active {
-      font-family: 'SourceHanSerifSC-Bold';
+      font-family: "SourceHanSerifSC-Bold";
     }
     .el-tabs__nav-wrap::after {
       height: 1px;
@@ -105,10 +243,17 @@ const handleTabChange = (tab) => {
       height: 100%;
     }
   }
+  .sidebar-empty {
+    padding: utils.vh-calc(30);
+    text-align: center;
+    color: #4e1a00;
+    font-size: utils.vh-calc(16);
+    opacity: 0.7;
+  }
   :deep(.sidebar-collapse) {
     --el-fill-color-blank: transparent;
     --el-collapse-border-color: transparent;
-    --el-collapse-header-text-color: #4E1A00;
+    --el-collapse-header-text-color: #4e1a00;
 
     .el-collapse-item__header {
       padding-left: utils.vh-calc(5);
@@ -121,21 +266,37 @@ const handleTabChange = (tab) => {
       padding-bottom: 0;
     }
     .tag {
-      width: utils.vh-calc(28);
+      flex-shrink: 0;
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      min-width: utils.vh-calc(28);
       height: utils.vh-calc(18);
-      line-height: utils.vh-calc(18);
-      text-align: center;
-      color: #F8D561;
+      padding: 0 utils.vh-calc(6);
+      color: #f8d561;
       font-size: utils.vh-calc(14);
       border-radius: 100px;
-      background: #98382E;
+      background: #98382e;
+    }
+    .collapse-header {
+      display: flex;
+      align-items: center;
+      flex: 1;
+      min-width: 0;
+    }
+    .collapse-title {
+      margin-left: utils.vh-calc(8);
+      flex: 1;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
     }
     .icon {
       margin-left: utils.vh-calc(12);
       width: utils.vh-calc(18);
       height: utils.vh-calc(10);
       transition: transform 0.3s ease;
-      background: url('@/assets/images/icon-xia.png') no-repeat center / contain;
+      background: url("@/assets/images/icon-xia.png") no-repeat center / contain;
 
       &--active {
         transform: rotate(180deg);
@@ -148,11 +309,15 @@ const handleTabChange = (tab) => {
         height: utils.vh-calc(40);
         line-height: utils.vh-calc(40);
         font-size: utils.vh-calc(16);
-        color: #4E1A00;
+        color: #4e1a00;
         cursor: pointer;
 
         &:hover {
-          color: #98382E;
+          color: #98382e;
+        }
+        &.active {
+          color: #98382e;
+          font-family: "SourceHanSerifSC-Bold";
         }
       }
     }
@@ -164,7 +329,7 @@ const handleTabChange = (tab) => {
     align-items: center;
     width: utils.vh-calc(270);
     height: utils.vh-calc(44);
-    background: url('./images/search-bg.png') no-repeat center / contain;
+    background: url("./images/search-bg.png") no-repeat center / contain;
 
     input {
       flex: 1;
@@ -173,11 +338,11 @@ const handleTabChange = (tab) => {
       height: 100%;
       background: transparent;
       border: none;
-      color: #F7E8D4;
+      color: #f7e8d4;
       font-size: utils.vh-calc(18);
 
       &::placeholder {
-        color: #F7E8D4;
+        color: #f7e8d4;
       }
       &:focus {
         outline: none;
@@ -193,11 +358,11 @@ const handleTabChange = (tab) => {
       cursor: pointer;
 
       &::before {
-        content: '';
+        content: "";
         display: block;
         width: utils.vh-calc(24);
         height: utils.vh-calc(22);
-        background: url('./images/search-icon.png') no-repeat center / contain;
+        background: url("./images/search-icon.png") no-repeat center / contain;
       }
     }
   }
@@ -214,18 +379,19 @@ const handleTabChange = (tab) => {
     width: utils.vh-calc(18);
     height: utils.vh-calc(70);
     cursor: pointer;
-    background: url('./images/sidebar-hide-bg.png') no-repeat center / contain;
+    background: url("./images/sidebar-hide-bg.png") no-repeat center / contain;
 
     &::before {
-      content: '';
+      content: "";
       position: absolute;
       top: 50%;
       left: 50%;
       width: utils.vh-calc(11);
       height: utils.vh-calc(18);
       transform: translate(-50%, -50%);
-      background: url('./images/sidebar-hide-icon.png') no-repeat center / contain;
+      background: url("./images/sidebar-hide-icon.png") no-repeat center /
+        contain;
     }
   }
 }
-</style>
+</style>

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

@@ -40,7 +40,7 @@ const LIST = [
     pathName: "Home",
   },
 ];
-const active = computed(() => route.name);
+const active = computed(() => (route.name === "MapView" ? "Home" : route.name));
 
 const handleClick = (pathName) => {
   router.push({ name: pathName });

+ 0 - 0
src/constants.js


+ 26 - 22
src/pages/Home/index.vue

@@ -7,20 +7,20 @@
 </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'
+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
+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')
+  await nextTick();
+  if (!mapWrapper.value) return;
+  const svgEl = mapWrapper.value.querySelector("svg");
   if (svgEl) {
     panZoomInstance = svgPanZoom(svgEl, {
       controlIconsEnabled: false,
@@ -34,19 +34,19 @@ onMounted(async () => {
       minZoom: 0.5,
       maxZoom: 10,
       zoomScaleSensitivity: 0.35,
-    })
+    });
   }
-})
+});
 
 onBeforeUnmount(() => {
   if (panZoomInstance) {
-    panZoomInstance.destroy()
+    panZoomInstance.destroy();
   }
-})
+});
 </script>
 
 <style scoped lang="scss">
-@use '@/assets/utils.scss';
+@use "@/assets/utils.scss";
 
 .home {
   position: absolute;
@@ -69,13 +69,13 @@ onBeforeUnmount(() => {
   height: 100%;
 }
 
-.map-wrapper :deep([id^='icon-']) {
+.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) {
+.map-wrapper :deep([id^="icon-"]:hover) {
   transform: scale(1.2);
 }
 
@@ -101,9 +101,13 @@ onBeforeUnmount(() => {
     opacity: 1;
   }
 }
-.map-wrapper :deep([id^='select-']) {
-  transform-origin: 50% 50%;
-  transform-box: fill-box;
-  animation: breathe 2.5s ease-in-out infinite;
+.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>

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

@@ -0,0 +1,176 @@
+<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>

TEMPAT SAMPAH
src/pages/Picture/images/1.png


File diff ditekan karena terlalu besar
+ 150 - 10
src/pages/Picture/index.vue


+ 70 - 0
src/pages/Scene/index.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="scene-page">
+    <iframe
+      class="scene-iframe"
+      :src="iframeSrc"
+      frameborder="0"
+      title="场景"
+    />
+    <img
+      src="@/assets/images/icon-fanhui.png"
+      alt="返回"
+      class="scene-back-btn"
+      :class="{ 'scene-back-btn--sidebar-visible': sidebarVisible }"
+      @click="goBack"
+    />
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { storeToRefs } from "pinia";
+import { useRouter, useRoute } from "vue-router";
+import { useSidebarStore } from "@/stores/sidebar";
+
+const router = useRouter();
+const route = useRoute();
+const { visible: sidebarVisible } = storeToRefs(useSidebarStore());
+
+// iframe 地址,通过 route query 传入,如 /#/scene?url=https://example.com
+const iframeSrc = computed(() => route.query.url || "about:blank");
+
+const goBack = () => {
+  router.back();
+};
+</script>
+
+<style scoped lang="scss">
+@use "@/assets/utils.scss";
+
+.scene-page {
+  position: fixed;
+  inset: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 0;
+}
+
+.scene-iframe {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  border: none;
+}
+
+// prettier-ignore
+.scene-back-btn {
+  position: absolute;
+  right: 40PX;
+  bottom: calc(40PX + 44PX + 10PX);
+  width: 44PX;
+  height: 44PX;
+  cursor: pointer;
+
+  /* 贴着 sidebar:sidebar 可见时,按钮在 sidebar 左侧 */
+  &--sidebar-visible {
+    right: utils.vh-calc(318); /* 300(sidebar) + 18(margin) */
+  }
+}
+</style>

+ 14 - 0
src/router/index.js

@@ -7,11 +7,25 @@ const router = createRouter({
       path: "/map",
       component: () => import("@/pages/Home/index.vue"),
       name: "Home",
+      meta: { showTabbar: true },
+    },
+    {
+      path: "/map/view",
+      component: () => import("@/pages/Map/index.vue"),
+      name: "MapView",
+      meta: { showTabbar: true },
+    },
+    {
+      path: "/scene",
+      component: () => import("@/pages/Scene/index.vue"),
+      name: "Scene",
+      meta: { showTabbar: false },
     },
     {
       path: "/",
       component: () => import("@/pages/Picture/index.vue"),
       name: "Picture",
+      meta: { showTabbar: true },
     },
   ],
 });

+ 6 - 5
vite.config.js

@@ -1,7 +1,7 @@
-import { fileURLToPath, URL } from 'node:url'
+import { fileURLToPath, URL } from "node:url";
 
-import { defineConfig } from 'vite'
-import vue from '@vitejs/plugin-vue'
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
 
 import AutoImport from "unplugin-auto-import/vite";
 import Components from "unplugin-vue-components/vite";
@@ -9,6 +9,7 @@ import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
 
 // https://vite.dev/config/
 export default defineConfig({
+  base: "./",
   plugins: [
     vue(),
     AutoImport({
@@ -24,7 +25,7 @@ export default defineConfig({
   ],
   resolve: {
     alias: {
-      '@': fileURLToPath(new URL('./src', import.meta.url))
+      "@": fileURLToPath(new URL("./src", import.meta.url)),
     },
   },
   css: {
@@ -37,4 +38,4 @@ export default defineConfig({
       },
     },
   },
-})
+});