Przeglądaj źródła

接入设备,视频数据

chenrj 1 miesiąc temu
rodzic
commit
0d3451dbaa

Plik diff jest za duży
+ 0 - 0
public/jessibuca/decoder.js


BIN
public/jessibuca/decoder.wasm


+ 88 - 0
public/jessibuca/jessibuca.d.ts

@@ -0,0 +1,88 @@
+declare namespace Jessibuca {
+
+    interface Config {
+        container: HTMLMediaElement | string
+
+        videoBuffer?: number,
+        decoder?: string,
+        forceNoOffscreen?: boolean,
+        hiddenAutoPause?: boolean,
+        hasAudio?: boolean,
+        rotate?: boolean,
+        isResize?: boolean,
+        isFullSize?: boolean,
+        isFlv?: boolean,
+        debug?: boolean,
+        timeout?: number,
+        supportDblclickFullscreen?: boolean,
+        showBandwidth?: boolean,
+        operateBtns?: {
+            fullscreen?: boolean,
+            screenshot?: boolean,
+            play?: boolean,
+            audio?: boolean
+            record?: boolean
+        }
+        keepScreenOn?: boolean,
+        isNotMute?: boolean,
+        loadingText?: boolean,
+        background?: string,
+        useWCS?: boolean,
+        useMSE?: boolean
+    }
+
+    interface JessibucaConstructor {
+        new(config?: Config): Jessibuca
+    }
+
+    interface Jessibuca {
+        constructor: JessibucaConstructor,
+
+        setDebug(flag: boolean): void,
+
+        mute(): void,
+
+        cancelMute(): void,
+
+        audioResume(): void,
+
+        setTimeout(): void,
+
+        setScaleMode(mode: number): void,
+
+        pause(): Promise<void>,
+
+        close(): void,
+
+        destroy(): void,
+
+        clearView(): void,
+
+        play(url?: string): Promise<void>,
+
+        resize(): void,
+
+        setBufferTime(time: number): void,
+
+        setRotate(deg: number): void,
+
+        setVolume(volume: number): void,
+
+        hasLoaded(): boolean,
+
+        setKeepScreenOn(): boolean,
+
+        setFullscreen(flag: boolean): void,
+
+        screenshot(filename?: string, format?: string, quality?: number, type?: string)
+
+        isPlaying(): boolean,
+
+        isMute(): boolean,
+
+        isRecording(): boolean,
+
+        on(event: string, callback: Function): void
+
+    }
+}

Plik diff jest za duży
+ 0 - 0
public/jessibuca/jessibuca.js


Plik diff jest za duży
+ 0 - 0
public/jessibuca/jessibuca.js.map


+ 167 - 30
src/App.vue

@@ -1,35 +1,171 @@
 <script setup>
-import { ref } from 'vue'
+import { ref, onMounted, onUnmounted } from 'vue'
 import TerrainViewer from './components/TerrainViewer.vue'
+import { config } from './config'
 
-const markers = ref([
-  {
-    lon: 113.722,
-    lat: 24.575,
-    title: '监控点 A',
-    type: 'video',
-    videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4'
-  },
-  {
-    lon: 113.730,
-    lat: 24.568,
-    title: '设备 B',
-    type: 'device',
-    status: '正常',
-    metrics: [
-      { label: '温度', value: 24, unit: '°C' },
-      { label: '湿度', value: 62, unit: '%' },
-      { label: '风速', value: 3.2, unit: 'm/s' }
-    ]
-  },
-  {
-    lon: 113.725,
-    lat: 24.572,
-    title: '报表 C',
-    type: 'html',
-    htmlContent: '<div style="color:#22c55e;font-weight:600">今日产量:1,240 吨</div><div style="margin-top:8px;color:rgba(255,255,255,0.6)">环比昨日 +5.3%</div>'
+const markers = ref([])
+
+// 获取设备数据
+async function fetchDeviceData() {
+  try {
+    const response = await fetch(config.getApiUrl('/service/page/node.json?paged=1&pageSize=100'), {
+      method: 'POST',
+      headers: config.getHeaders(),
+      body: JSON.stringify({})
+    })
+    
+    const result = await response.json()
+    
+    if (result.status === 2 && result.data?.data) {
+      const deviceMarkers = result.data.data
+        .filter(device => device.lonLat) // 过滤掉经纬度为空的设备
+        .map(device => {
+          const [lon, lat] = device.lonLat.split(',').map(Number)
+          const isOnline = device.iot_node_status === 16
+          
+          return {
+            lon,
+            lat,
+            title: device.name || device.scene_name,
+            type: 'device',
+            status: isOnline ? '在线' : '离线',
+            nodeId: device.id,
+            deviceCode: device.device_code,
+            deviceType: device.type,
+            metrics: [
+              
+            ]
+          }
+        })
+      
+      return deviceMarkers
+    }
+  } catch (error) {
+    console.error('获取设备数据失败:', error)
+  }
+  return []
+}
+
+// 获取视频数据
+async function fetchVideoData() {
+  try {
+    const response = await fetch(config.getApiUrl('/service/page/video.json?paged=1&pageSize=100'), {
+      method: 'POST',
+      headers: config.getHeaders(),
+      body: JSON.stringify({})
+    })
+    
+    const result = await response.json()
+    
+    if (result.status === 2 && result.data?.data) {
+      const videoMarkers = result.data.data
+        .filter(video => video.lonlat) // 过滤掉经纬度为空的视频
+        .map(video => {
+          const [lon, lat] = video.lonlat.split(',').map(Number)
+          const videoUrl = video.gb_url + '/rtp/' + video.device_serial + '_' + video.channel + '.live.flv'
+          
+          return {
+            lon,
+            lat,
+            title: video.name || video.scene_name,
+            type: 'video',
+            videoUrl,
+            deviceSerial: video.device_serial,
+            channel: video.channel
+          }
+        })
+      
+      return videoMarkers
+    }
+  } catch (error) {
+    console.error('获取视频数据失败:', error)
+  }
+  return []
+}
+
+// 加载所有数据
+async function loadAllData() {
+  const [deviceMarkers, videoMarkers] = await Promise.all([
+    fetchDeviceData(),
+    fetchVideoData()
+  ])
+  
+  markers.value = [...deviceMarkers, ...videoMarkers]
+  console.log('已加载设备数据:', deviceMarkers.length, '个,视频数据:', videoMarkers.length, '个')
+}
+
+// 获取传感器数据
+async function fetchSensorData(nodeId) {
+  try {
+    const response = await fetch(config.getApiUrl('/service/page/sensor.json?paged=1&pageSize=50'), {
+      method: 'POST',
+      headers: config.getHeaders(),
+      body: JSON.stringify({ node_id: nodeId })
+    })
+    
+    const result = await response.json()
+    
+    if (result.status === 2 && result.data?.data) {
+      return result.data.data.map(sensor => ({
+        name: sensor.name,
+        value: sensor.sdata,
+        unit: sensor.measure_unit || ''
+      }))
+    }
+  } catch (error) {
+    console.error('获取传感器数据失败:', error)
   }
-])
+  return []
+}
+
+// 接收父窗口消息的处理函数
+function handleMessage(event) {
+  // 安全检查:可以验证消息来源
+  // if (event.origin !== 'https://your-parent-domain.com') return
+
+  const { type, data } = event.data || {}
+
+  switch (type) {
+    case 'updateMarkers':
+      // 父窗口更新标记点数据
+      if (Array.isArray(data)) {
+        markers.value = data
+        console.log('已从父窗口接收标记点数据:', data)
+      }
+      break
+    case 'addMarker':
+      // 父窗口添加单个标记点
+      if (data) {
+        markers.value = [...markers.value, data]
+        console.log('已从父窗口添加标记点:', data)
+      }
+      break
+    case 'clearMarkers':
+      // 父窗口清空所有标记点
+      markers.value = []
+      console.log('已清空所有标记点')
+      break
+    case 'ping':
+      // 心跳检测,回复 pong
+      window.parent?.postMessage({ type: 'pong' }, '*')
+      break
+    default:
+      console.log('收到未知消息类型:', type, data)
+  }
+}
+
+onMounted(() => {
+  // 监听父窗口消息
+  window.addEventListener('message', handleMessage)
+  // 通知父窗口 iframe 已准备好
+  window.parent?.postMessage({ type: 'ready' }, '*')
+  // 加载所有数据
+  loadAllData()
+})
+
+onUnmounted(() => {
+  window.removeEventListener('message', handleMessage)
+})
 
 function onMarkerUpdate(newMarkers) {
   markers.value = newMarkers
@@ -37,8 +173,9 @@ function onMarkerUpdate(newMarkers) {
 
 function onMarkerClick(marker) {
   console.log('点击标记点:', marker)
-  // 可以发送消息给外部系统
-  window.parent?.postMessage({ type: 'markerClick', data: marker }, '*')
+  // 传感器数据已在 TerrainViewer 组件中获取
+  // TODO: 通知父窗口功能暂时隐藏
+  // window.parent?.postMessage({ type: 'markerClick', data: marker }, '*')
 }
 </script>
 

+ 147 - 7
src/components/MarkerPopup.vue

@@ -1,10 +1,15 @@
 <script setup>
-import { computed } from 'vue'
+import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
 
 const props = defineProps({
-  marker: { type: Object, required: true }
+  marker: { type: Object, required: true },
+  realtimeMetrics: { type: Array, default: () => [] },  // 实时获取的 metrics
+  loadingMetrics: { type: Boolean, default: false }  // 加载状态
 })
 
+const videoRef = ref(null)
+const jessibucaPlayer = ref(null)
+
 const statusColor = computed(() => {
   const s = String(props.marker.status || '').toLowerCase()
   if (['正常', '在线', '良好', '运行中', 'ok'].some(k => s.includes(k))) return '#22c55e'
@@ -12,6 +17,103 @@ const statusColor = computed(() => {
   if (['故障', '离线', '错误', 'error', 'danger'].some(k => s.includes(k))) return '#ef4444'
   return '#9ca3af'
 })
+
+const isFlvVideo = computed(() => {
+  return props.marker.type === 'video' && props.marker.videoUrl && props.marker.videoUrl.includes('.flv')
+})
+
+// 使用实时 metrics 或原始 metrics
+const displayMetrics = computed(() => {
+  return props.realtimeMetrics.length > 0 ? props.realtimeMetrics : props.marker.metrics
+})
+
+// 初始化 Jessibuca 播放器
+async function initJessibucaPlayer() {
+  if (!videoRef.value || !isFlvVideo.value) return
+  
+  // 销毁旧的播放器
+  if (jessibucaPlayer.value) {
+    jessibucaPlayer.value.destroy()
+    jessibucaPlayer.value = null
+  }
+  
+  await nextTick()
+  
+  // 动态加载 Jessibuca
+  if (!window.Jessibuca) {
+    await loadJessibucaScript()
+  }
+  
+  if (!window.Jessibuca) {
+    console.error('Jessibuca 加载失败')
+    return
+  }
+  
+  jessibucaPlayer.value = new window.Jessibuca({
+    container: videoRef.value,
+    videoBuffer: 0.2,
+    isResize: false,
+    loadingText: '加载中...',
+    hasAudio: true,
+    debug: false,
+    supportDblclickFullscreen: true,
+    showBandwidth: false,
+    operateBtns: {
+      fullscreen: true,
+      screenshot: false,
+      play: true,
+      audio: true,
+      ptz: false,
+      zoom: false
+    },
+    forceNoOffscreen: true,
+    isNotMute: false,
+    timeout: 10,
+    decoder: './jessibuca/decoder.js'
+  })
+  
+  jessibucaPlayer.value.play(props.marker.videoUrl)
+}
+
+// 动态加载 Jessibuca 脚本
+function loadJessibucaScript() {
+  return new Promise((resolve, reject) => {
+    if (window.Jessibuca) {
+      resolve()
+      return
+    }
+    
+    const script = document.createElement('script')
+    script.src = './jessibuca/jessibuca.js'
+    script.onload = () => {
+      resolve()
+    }
+    script.onerror = () => {
+      reject(new Error('Jessibuca script load failed'))
+    }
+    document.head.appendChild(script)
+  })
+}
+
+// 监听 marker 变化,重新初始化播放器
+watch(() => props.marker, () => {
+  if (isFlvVideo.value) {
+    setTimeout(initJessibucaPlayer, 100)
+  }
+}, { immediate: true })
+
+onMounted(() => {
+  if (isFlvVideo.value) {
+    setTimeout(initJessibucaPlayer, 100)
+  }
+})
+
+onUnmounted(() => {
+  if (jessibucaPlayer.value) {
+    jessibucaPlayer.value.destroy()
+    jessibucaPlayer.value = null
+  }
+})
 </script>
 
 <template>
@@ -22,8 +124,13 @@ const statusColor = computed(() => {
     <div class="popup-body">
       <!-- video -->
       <template v-if="marker.type === 'video'">
+        <div
+          v-if="marker.videoUrl && marker.videoUrl.includes('.flv')"
+          ref="videoRef"
+          class="popup-media jessibuca-container"
+        ></div>
         <video
-          v-if="marker.videoUrl"
+          v-else-if="marker.videoUrl"
           class="popup-media"
           :src="marker.videoUrl"
           controls
@@ -63,9 +170,14 @@ const statusColor = computed(() => {
           <span class="status-dot" :style="{ background: statusColor }"></span>
           <span class="status-text">{{ marker.status || '未知' }}</span>
         </div>
-        <div v-if="marker.metrics?.length" class="device-metrics">
+        <!-- 加载状态 -->
+        <div v-if="loadingMetrics" class="loading-metrics">
+          <div class="loading-spinner"></div>
+          <span>获取实时数据...</span>
+        </div>
+        <div v-else-if="displayMetrics?.length" class="device-metrics">
           <div
-            v-for="(m, i) in marker.metrics"
+            v-for="(m, i) in displayMetrics"
             :key="i"
             class="metric-item"
           >
@@ -133,6 +245,11 @@ const statusColor = computed(() => {
   display: block;
 }
 
+.jessibuca-container {
+  position: relative;
+  overflow: hidden;
+}
+
 .popup-html {
   font-size: 12px;
   color: rgba(255, 255, 255, 0.8);
@@ -146,6 +263,29 @@ const statusColor = computed(() => {
   color: rgba(255, 255, 255, 0.4);
 }
 
+.loading-metrics {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 24px 0;
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.6);
+}
+
+.loading-spinner {
+  width: 16px;
+  height: 16px;
+  border: 2px solid rgba(255, 255, 255, 0.2);
+  border-top-color: #409eff;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
 .popup-text {
   font-size: 13px;
   color: rgba(255, 255, 255, 0.75);
@@ -192,7 +332,7 @@ const statusColor = computed(() => {
 
 .metric-label {
   font-size: 12px;
-  color: rgba(255, 255, 255, 0.55);
+  color: rgba(255, 255, 255, 0.85);
 }
 
 .metric-value {
@@ -203,7 +343,7 @@ const statusColor = computed(() => {
 
 .metric-unit {
   font-size: 11px;
-  color: rgba(255, 255, 255, 0.5);
+  color: rgba(255, 255, 255, 0.85);
   margin-left: 2px;
 }
 </style>

+ 116 - 30
src/components/TerrainViewer.vue

@@ -4,6 +4,7 @@ import * as THREE from 'three'
 import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
 import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'
 import MarkerPopup from './MarkerPopup.vue'
+import { config } from '../config'
 
 const props = defineProps({
   metaUrl: { type: String, default: '/terrain-meta.json' },
@@ -24,11 +25,38 @@ const loading = ref(true)
 const error = ref(null)
 const veModel = ref(props.verticalExaggeration)
 const infoText = ref('')
+const isAutoRotating = ref(false)
 
 // 标记点相关
 const activeMarker = ref(null)
 const markerPopupPosition = ref({ x: 0, y: 0 })
 const activeMarkerWorldPos = ref({ x: 0, y: 0, z: 0 })
+const realtimeMetrics = ref([])  // 实时获取的 metrics
+const loadingMetrics = ref(false)  // 加载状态
+
+// 获取传感器实时数据
+async function fetchSensorData(nodeId) {
+  try {
+    const response = await fetch(config.getApiUrl('/service/page/sensor.json?paged=1&pageSize=50'), {
+      method: 'POST',
+      headers: config.getHeaders(),
+      body: JSON.stringify({ node_id: nodeId })
+    })
+    
+    const result = await response.json()
+    
+    if (result.status === 2 && result.data?.data) {
+      return result.data.data.map(sensor => ({
+        label: sensor.name,
+        value: sensor.sdata,
+        unit: sensor.measure_unit || ''
+      }))
+    }
+  } catch (error) {
+    console.error('获取传感器数据失败:', error)
+  }
+  return []
+}
 
 // iframe 消息处理
 function setupIframeListener() {
@@ -216,6 +244,58 @@ function resetCamera() {
   controls.update()
 }
 
+// 加载完成后的旋转动效
+let autoRotateAnimationId = null
+function startAutoRotate() {
+  if (!camera || !controls || !meta) return
+  
+  isAutoRotating.value = true
+  const duration = 6000 // 旋转持续时间 6秒
+  const startTime = performance.now()
+  const startAngle = Math.atan2(camera.position.x, camera.position.z)
+  const rotateAngle = Math.PI * 2 // 旋转完整一圈 360 度
+  
+  const targetDistance = camera.position.distanceTo(controls.target)
+  const startDistance = targetDistance * 8 // 从 8 倍距离开始
+  const targetPitch = Math.asin(camera.position.y / targetDistance)
+  const startPitch = Math.PI / 2.5 // 从更高的俯视角度开始(约72度)
+  
+  function animateRotate(currentTime) {
+    const elapsed = currentTime - startTime
+    const progress = Math.min(elapsed / duration, 1)
+    
+    // 使用 easeOutCubic 缓动函数
+    const easeProgress = 1 - Math.pow(1 - progress, 3)
+    
+    // 当前距离:从 startDistance 逐渐变化到 targetDistance
+    const currentDistance = startDistance + (targetDistance - startDistance) * easeProgress
+    // 当前俯仰角:从高俯视角逐渐变化到目标角度
+    const currentPitch = startPitch + (targetPitch - startPitch) * easeProgress
+    
+    const currentAngle = startAngle + rotateAngle * easeProgress
+    camera.position.x = currentDistance * Math.cos(currentPitch) * Math.sin(currentAngle)
+    camera.position.z = currentDistance * Math.cos(currentPitch) * Math.cos(currentAngle)
+    camera.position.y = currentDistance * Math.sin(currentPitch)
+    
+    if (progress < 1) {
+      autoRotateAnimationId = requestAnimationFrame(animateRotate)
+    } else {
+      isAutoRotating.value = false
+      autoRotateAnimationId = null
+    }
+  }
+  
+  autoRotateAnimationId = requestAnimationFrame(animateRotate)
+}
+
+function stopAutoRotate() {
+  if (autoRotateAnimationId) {
+    cancelAnimationFrame(autoRotateAnimationId)
+    autoRotateAnimationId = null
+  }
+  isAutoRotating.value = false
+}
+
 const TYPE_COLORS = {
   video: 0x22c55e,
   device: 0x38bdf8,
@@ -246,22 +326,8 @@ function createMarkerLabelElement(marker) {
     white-space: nowrap;
     pointer-events: auto;
     cursor: pointer;
-    transition: transform 0.15s, box-shadow 0.15s;
   `
-  div.innerHTML = `
-    <svg width="14" height="16" viewBox="0 0 24 28" style="fill:${color};display:block">
-      <path d="M12 0l10.4 6v12L12 24 1.6 18V6z"/>
-    </svg>
-    <span>${marker.title || '未命名'}</span>
-  `
-  div.addEventListener('mouseenter', () => {
-    div.style.transform = 'scale(1.05)'
-    div.style.boxShadow = `0 6px 20px ${color}55`
-  })
-  div.addEventListener('mouseleave', () => {
-    div.style.transform = 'scale(1)'
-    div.style.boxShadow = `0 4px 16px ${color}33`
-  })
+  div.innerHTML = `<span>${marker.title || '未命名'}</span>`
   div.addEventListener('click', (e) => {
     e.stopPropagation()
     openMarkerPopup(marker)
@@ -357,20 +423,32 @@ function createMarkers() {
   scene.add(labelGroup)
 }
 
-function openMarkerPopup(marker) {
+async function openMarkerPopup(marker) {
   activeMarker.value = marker
+  realtimeMetrics.value = []  // 清空之前的实时数据
+  loadingMetrics.value = false
   updatePopupPosition()
 
-  sendToParent({
-    type: 'markerClick',
-    lon: marker.lon,
-    lat: marker.lat,
-    alt: marker.alt,
-    title: marker.title,
-    content: marker.content,
-    markerType: marker.type
-  })
+  // TODO: 通知父窗口功能暂时隐藏
+  // sendToParent({
+  //   type: 'markerClick',
+  //   lon: marker.lon,
+  //   lat: marker.lat,
+  //   alt: marker.alt,
+  //   title: marker.title,
+  //   content: marker.content,
+  //   markerType: marker.type
+  // })
   emit('markerClick', marker)
+  
+  // 如果是设备类型,获取实时传感器数据
+  if (marker.type === 'device' && marker.nodeId) {
+    loadingMetrics.value = true
+    const sensorData = await fetchSensorData(marker.nodeId)
+    realtimeMetrics.value = sensorData
+    loadingMetrics.value = false
+    console.log('获取实时传感器数据:', sensorData)
+  }
 }
 
 // 点击标记点
@@ -453,6 +531,9 @@ async function loadAll() {
 
     buildMesh()
     loading.value = false
+    
+    // 加载完成后启动旋转动效
+    startAutoRotate()
   } catch (err) {
     console.error('加载失败:', err)
     error.value = '加载失败: ' + (err.message || err)
@@ -510,6 +591,9 @@ onMounted(() => {
   // 限制旋转角度,防止看到模型底部
   controls.minPolarAngle = Math.PI / 6  // 最小俯仰角(30度)
   controls.maxPolarAngle = Math.PI / 2  // 最大俯仰角(90度=垂直向下,不能翻转到底部)
+  
+  // 用户交互时停止自动旋转
+  controls.addEventListener('start', stopAutoRotate)
 
   const sun = new THREE.DirectionalLight(0xfff5e0, 2.0)
   sun.position.set(400, 600, 300)
@@ -579,6 +663,8 @@ onUnmounted(() => {
   window.removeEventListener('message', handleIframeMessage)
   window.removeEventListener('resize', onResize)
   cancelAnimationFrame(animId)
+  stopAutoRotate()
+  if (controls) controls.removeEventListener('start', stopAutoRotate)
   renderer?.domElement?.removeEventListener('dblclick', onDblClick)
   renderer?.domElement?.removeEventListener('click', onMarkerClick)
   renderer?.dispose()
@@ -591,7 +677,7 @@ onUnmounted(() => {
   <div ref="container" class="tv">
     <div v-if="loading" class="ov ld">
       <div class="sp"></div>
-      <div style="margin-top:10px;color:#333">加载地形数据...</div>
+      <div style="margin-top:10px;color:#fff">加载地形数据...</div>
     </div>
     <div v-if="error" class="ov er">
       <div style="font-size:36px">⚠️</div>
@@ -614,7 +700,7 @@ onUnmounted(() => {
       @click.stop
     >
       <button class="popup-close" @click="activeMarker = null">×</button>
-      <MarkerPopup :marker="activeMarker" />
+      <MarkerPopup :marker="activeMarker" :realtimeMetrics="realtimeMetrics" :loadingMetrics="loadingMetrics" />
     </div>
   </div>
 </template>
@@ -629,9 +715,9 @@ onUnmounted(() => {
 .b { background:rgba(0,0,0,0.06); border:1px solid rgba(0,0,0,0.12); padding:4px 12px; border-radius:4px; cursor:pointer; font-size:12px; color:#333; }
 .b:hover { background:rgba(0,0,0,0.12); }
 .ov { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:100; }
-.ld { background:rgba(255,255,255,0.85); }
-.er { background:rgba(255,255,255,0.85); }
-.sp { width:40px; height:40px; border:3px solid #ddd; border-top-color:#409eff; border-radius:50%; animation:sp 0.8s linear infinite; }
+.ld { background:rgba(0,0,0,0.95); }
+.er { background:rgba(0,0,0,0.95); }
+.sp { width:40px; height:40px; border:3px solid rgba(255,255,255,0.2); border-top-color:#409eff; border-radius:50%; animation:sp 0.8s linear infinite; }
 @keyframes sp { to { transform:rotate(360deg) } }
 
 /* 标记点弹窗 */

+ 23 - 0
src/config.js

@@ -0,0 +1,23 @@
+// 全局配置文件
+export const config = {
+  // API 基础地址(自动根据浏览器访问地址获取)
+  get baseUrl() {
+    return `http://59.32.228.147:81`
+  },
+  
+  // 用户密钥
+  userKey: 'b01a6a38926a47c2b55fc364b9afeccd',
+  
+  // 获取完整的 API URL
+  getApiUrl(path) {
+    return `${this.baseUrl}${path}`
+  },
+  
+  // 获取请求头
+  getHeaders() {
+    return {
+      'Content-Type': 'application/json',
+      'USER-KEY': this.userKey
+    }
+  }
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików