Bläddra i källkod

Merge branch 'master' of http://121.40.40.223:3000/zizhong.wang/dtScreen

hebotao 1 månad sedan
förälder
incheckning
da72748096

+ 3 - 0
.gitignore

@@ -26,3 +26,6 @@ pnpm-debug.log*
 .history
 pyscripts
 pyscripts/*
+src/components/TongzhouTrafficMap copy.vue
+src/components/TongzhouTrafficMap_实时交通版.vue
+src/components/TongzhouTrafficMap_bak.vue

+ 44 - 0
package-lock.json

@@ -16,8 +16,10 @@
         "echarts": "^5.6.0",
         "echarts-gl": "^2.0.9",
         "konva": "^10.2.0",
+        "swiper": "^5.4.5",
         "three": "^0.183.1",
         "vue": "^2.6.14",
+        "vue-awesome-swiper": "^4.1.1",
         "vue-router": "^3.6.5"
       },
       "devDependencies": {
@@ -5352,6 +5354,14 @@
         "url": "https://github.com/fb55/entities?sponsor=1"
       }
     },
+    "node_modules/dom7": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/dom7/-/dom7-2.1.5.tgz",
+      "integrity": "sha512-xnhwVgyOh3eD++/XGtH+5qBwYTgCm0aW91GFgPJ3XG+jlsRLyJivnbP0QmUBFhI+Oaz9FV0s7cxgXHezwOEBYA==",
+      "dependencies": {
+        "ssr-window": "^2.0.0"
+      }
+    },
     "node_modules/domelementtype": {
       "version": "2.3.0",
       "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
@@ -10950,6 +10960,11 @@
       "dev": true,
       "license": "BSD-3-Clause"
     },
+    "node_modules/ssr-window": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-2.0.0.tgz",
+      "integrity": "sha512-NXzN+/HPObKAx191H3zKlYomE5WrVIkoCB5IaSdvKokxTpjBdWfr0RaP+1Z5KOfDT0ZVz+2tdtiBkhsEQ9p+0A=="
+    },
     "node_modules/ssri": {
       "version": "8.0.1",
       "resolved": "https://registry.npmmirror.com/ssri/-/ssri-8.0.1.tgz",
@@ -11150,6 +11165,23 @@
         "node": ">= 10"
       }
     },
+    "node_modules/swiper": {
+      "version": "5.4.5",
+      "resolved": "https://registry.npmjs.org/swiper/-/swiper-5.4.5.tgz",
+      "integrity": "sha512-7QjA0XpdOmiMoClfaZ2lYN6ICHcMm72LXiY+NF4fQLFidigameaofvpjEEiTQuw3xm5eksG5hzkaRsjQX57vtA==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "dom7": "^2.1.5",
+        "ssr-window": "^2.0.0"
+      },
+      "engines": {
+        "node": ">= 4.7.0"
+      },
+      "funding": {
+        "type": "patreon",
+        "url": "https://www.patreon.com/vladimirkharlampidi"
+      }
+    },
     "node_modules/table": {
       "version": "6.9.0",
       "resolved": "https://registry.npmmirror.com/table/-/table-6.9.0.tgz",
@@ -11714,6 +11746,18 @@
         "csstype": "^3.1.0"
       }
     },
+    "node_modules/vue-awesome-swiper": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/vue-awesome-swiper/-/vue-awesome-swiper-4.1.1.tgz",
+      "integrity": "sha512-50um10t6N+lJaORkpwSi1wWuMmBI1sgFc9Znsi5oUykw2cO5DzLaBHcO2JNX21R+Ue4TGoIJDhhxjBHtkFrTEQ==",
+      "engines": {
+        "node": ">=8"
+      },
+      "peerDependencies": {
+        "swiper": "^5.2.0",
+        "vue": "2.x"
+      }
+    },
     "node_modules/vue-eslint-parser": {
       "version": "8.3.0",
       "resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz",

+ 2 - 0
package.json

@@ -16,8 +16,10 @@
     "echarts": "^5.6.0",
     "echarts-gl": "^2.0.9",
     "konva": "^10.2.0",
+    "swiper": "^5.4.5",
     "three": "^0.183.1",
     "vue": "^2.6.14",
+    "vue-awesome-swiper": "^4.1.1",
     "vue-router": "^3.6.5"
   },
   "devDependencies": {

BIN
src/assets/images/camera.png


+ 154 - 118
src/components/TongzhouTrafficMap.vue

@@ -13,11 +13,21 @@
 
         <div v-for="item in legendConfig" class="legend-item" @click="toggleRouteVisible(item.name)" :key="item.name"
           :class="{ 'is-inactive': !activeLegends.includes(item.name) }">
-          <div class="legend-dot" :style="{ backgroundColor: item.color }">
-            <span>{{ item.name.charAt(0) }}</span>
+
+          <div class="legend-dot"
+            :style="{ backgroundColor: ['离线', '降级', '故障'].includes(item.name) ? 'transparent' : item.color }"
+            :class="{ 'special-route': ['干线协调', '勤务路线'].includes(item.name), 'is-status-wrapper': ['离线', '降级', '故障'].includes(item.name) }">
+
+            <span v-if="!['离线', '降级', '故障'].includes(item.name)">
+              {{ item.name.charAt(0) }}
+            </span>
+
+            <img v-else
+              :src="require(`@/assets/images/icon_${item.name === '离线' ? 'lixian' : item.name === '降级' ? 'jiangji' : 'guzhang'}.png`)"
+              class="status-icon" />
           </div>
+
           <div class="legend-label">{{ item.name }}</div>
-          <!-- <div class="legend-status">{{ activeLegends.includes(item.name) ? '在线' : '离线' }}</div> -->
         </div>
       </div>
     </div>
@@ -56,7 +66,7 @@ export default {
         { name: "特殊控制", start: [116.6820, 39.9215], end: [116.6825, 39.8815], color: "#A26218" }, // 临河里路
         { name: "离线", start: [116.6415, 39.9235], end: [116.6850, 39.9240], color: "#7A7A7A" }, // 北关大街-潞苑
         { name: "降级", start: [116.6365, 39.8850], end: [116.6800, 39.8860], color: "#D9C13B" }, // 万盛南街段
-        { name: "故障", start: [116.6950, 39.9150], end: [116.6955, 39.8850], color: "#FF3938" }  // 潞通大街
+        { name: "故障", start: [116.6950, 39.9150], end: [116.6955, 39.885], color: "#FF3938" }  // 潞通大街
       ]
     };
   },
@@ -70,9 +80,32 @@ export default {
   },
   beforeDestroy() {
     if (this.infoWindow) this.infoWindow.close();
-    this.polylines.forEach(p => p.setMap(null));
-    Object.values(this.routeGroups).forEach(g => g.setMap(null));
-    if (this.map) this.map.destroy();
+
+    // 1. 清理普通的 polylines 数组
+    this.polylines.forEach(p => {
+      if (p && typeof p.setMap === 'function') p.setMap(null);
+    });
+
+    // 2. 核心修改:清理 routeGroups
+    Object.values(this.routeGroups).forEach(g => {
+      if (!g) return;
+
+      if (Array.isArray(g)) {
+        // 如果是数组(当前的逻辑),遍历每个成员销毁
+        g.forEach(overlay => {
+          if (overlay && typeof overlay.setMap === 'function') {
+            overlay.setMap(null);
+          }
+        });
+      } else if (typeof g.setMap === 'function') {
+        // 如果是旧版的 OverlayGroup 或单个覆盖物
+        g.setMap(null);
+      }
+    });
+
+    if (this.map) {
+      this.map.destroy();
+    }
   },
   computed: {
     // 判断是否所有图例都在激活列表中
@@ -123,21 +156,6 @@ export default {
               path = [config.start, config.end];
             }
 
-            // --- 【关键修改】:注释掉以下 Polyline 的定义 ---
-            /*
-            const polyline = new AMap.Polyline({
-              path: path,
-              strokeColor: config.color,
-              strokeWeight: 8,
-              strokeOpacity: 0.8,
-              showDir: false,
-              lineJoin: 'round',
-              zIndex: 15,
-              map: null
-            });
-            */
-
-            // 1. 只有“干线协调”和“勤务路线”才创建路线对象
             let polyline = null;
             const needRouteLine = ["干线协调", "勤务路线"].includes(config.name);
 
@@ -154,24 +172,30 @@ export default {
               });
             }
 
-
-            // 2. 在路径上分布点 (保持原样)
-            // 修改 points 的采样逻辑,例如每隔 10 个坐标点取一个点
             const points = [];
             const step = 10; // 步长越大,点越稀疏
             for (let i = 0; i < path.length; i += step) {
               points.push(path[i]);
             }
+
             // 确保终点也被加上
             if ((path.length - 1) % step !== 0) {
               points.push(path[path.length - 1]);
             }
 
-            // const points = path.length > 2
-            //   ? [path[0], path[Math.floor(path.length / 2)], path[path.length - 1]]
-            //   : [path[0], path[1]];
+            points.forEach((pos, idx) => {
+              // 临时逻辑,有真实接口后可以删除
+              const posMap = {
+                8: 'pos1',
+                9: 'pos2',
+                10: 'pos3'
+              };
+
+              if (idx === 0 && posMap[index]) {
+                localStorage.setItem(posMap[index], pos);
+              }
+              // 临时逻辑,有真实接口后可以删除
 
-            points.forEach(pos => {
               markers.push(this.createTrafficLightMarker(pos, config));
             });
 
@@ -179,12 +203,6 @@ export default {
             const overlays = [...markers, polyline].filter(Boolean);
             this.routeGroups[config.name] = overlays;
 
-            // if (this.activeLegends.includes(config.name)) {
-            //   this.map.add(overlays);
-            //   // 这里的 setFitView 会根据点的位置自动聚焦
-            //   this.map.setFitView(overlays, false, [60, 60, 60, 60]);
-            // }
-
             if (this.activeLegends.includes(config.name)) {
               this.map.add(overlays);
             }
@@ -212,6 +230,7 @@ export default {
         offset: new this.AMap.Pixel(-10, -10),
         extData: {
           ...config,
+          position: [lng, lat],
           statusColor: config.color, // 统一弹窗小圆点颜色
           statusLabel: displayStatus, // 统一弹窗状态文字
           road: '北京路与南京路',
@@ -223,35 +242,7 @@ export default {
       return marker;
     },
 
-    // openLightInfo(data, position) {
-    //   const content = `
-    //     <div class="traffic-window">
-    //       <div class="window-header" style="background: ${data.lightDetail.color}">
-    //         <span class="title">路口信号机: ${data.name}</span>
-    //       </div>
-    //       <div class="window-body">
-    //         <div class="data-row"><span class="label">设备序列:</span><span>${data.lightDetail.sn}</span></div>
-    //         <div class="data-row">
-    //           <span class="label">当前相位:</span>
-    //           <span style="color: ${data.lightDetail.color}; font-weight:bold">${data.lightDetail.status}</span>
-    //         </div>
-    //         <div class="data-row"><span class="label">相位余时:</span><span class="highlight">${data.lightDetail.timeLeft}s</span></div>
-    //         <div class="data-row"><span class="label">运行模式:</span><span>智能感应</span></div>
-    //         <div class="progress-container">
-    //            <div class="progress-bar" style="width: ${(data.lightDetail.timeLeft / 60) * 100}%; background: ${data.lightDetail.color}"></div>
-    //         </div>
-    //       </div>
-    //     </div>
-    //   `;
-
-    //   if (!this.infoWindow) {
-    //     this.infoWindow = new this.AMap.InfoWindow({ isCustom: true, offset: new this.AMap.Pixel(0, -20) });
-    //   }
-    //   this.infoWindow.setContent(content);
-    //   this.infoWindow.open(this.map, position);
-    // },
     openLightInfo(data, position) {
-      // 在 openLightInfo 内部
       const content = `
         <div class="custom-info-card">
           <div class="close-btn" onclick="window.closeMapInfoWindow()">✕</div>
@@ -290,20 +281,6 @@ export default {
       this.infoWindow.open(this.map, position);
     },
 
-    // 全选/全不选逻辑
-    // toggleAll() {
-    //   if (this.isAllSelected) {
-    //     // 如果当前是全选,则清空激活列表,并隐藏地图上所有组
-    //     this.activeLegends = [];
-    //     Object.values(this.routeGroups).forEach(group => group && group.hide());
-    //     if (this.infoWindow) this.infoWindow.close();
-    //   } else {
-    //     // 如果当前不是全选,则填充所有图例名称,并显示地图上所有组
-    //     this.activeLegends = this.legendConfig.map(item => item.name);
-    //     Object.values(this.routeGroups).forEach(group => group && group.show());
-    //   }
-    // },
-
     // 全选/全不选逻辑修正版
     toggleAll() {
       const targetState = !this.isAllSelected; // 获取点击后的目标状态(true为全选,false为全不选)
@@ -338,20 +315,6 @@ export default {
       }
     },
 
-    // 保留你原有的单个切换方法,但确保逻辑一致
-    // toggleRouteVisible(name) {
-    //   const group = this.routeGroups[name];
-    //   const index = this.activeLegends.indexOf(name);
-    //   if (index > -1) {
-    //     this.activeLegends.splice(index, 1);
-    //     group && group.hide();
-    //     this.infoWindow && this.infoWindow.close();
-    //   } else {
-    //     this.activeLegends.push(name);
-    //     group && group.show();
-    //   }
-    // },
-
     toggleRouteVisible(name) {
       const overlays = this.routeGroups[name] || []; // 获取的是数组
       const index = this.activeLegends.indexOf(name);
@@ -363,6 +326,37 @@ export default {
         this.activeLegends.push(name);
         this.map.add(overlays); // 改用 add
       }
+    },
+
+    // 其他组件点击定位到地图指定的点
+    focusByLocation(targetPos) {
+      if (!targetPos || targetPos.length !== 2) return;
+
+      let foundMarker = null;
+
+      // 1. 遍历所有路线组
+      Object.values(this.routeGroups).forEach(group => {
+        // 2. 在组内寻找 Marker
+        const marker = group.find(item => {
+          if (!(item instanceof this.AMap.Marker)) return false;
+          const pos = item.getExtData().position;
+          // 3. 坐标比对(考虑到浮点数精度,建议使用 AMap 自带的几何工具或简单比对)
+          return pos[0] === targetPos[0] && pos[1] === targetPos[1];
+        });
+        if (marker) foundMarker = marker;
+      });
+
+      if (foundMarker) {
+        // 4. 定位并打开弹窗
+        const finalPos = foundMarker.getPosition();
+        this.map.setZoomAndCenter(17, finalPos, false, 500); // 17级视角,平滑移动
+
+        setTimeout(() => {
+          this.openLightInfo(foundMarker.getExtData(), finalPos);
+        }, 600);
+      } else {
+        console.warn("未在地图上找到该坐标对应的点位:", targetPos);
+      }
     }
   }
 };
@@ -381,7 +375,6 @@ export default {
   height: 100%;
 }
 
-/* --- 红绿灯圆点:去掉数字后的纯净样式 --- */
 ::v-deep .pure-light-node {
   width: 16px;
   height: 16px;
@@ -396,9 +389,6 @@ export default {
 }
 
 ::v-deep .pure-light-node span {
-  display: inline-block;
-  width: 16px;
-  height: 16px;
   display: flex;
   transform: scale(0.75);
   align-items: center;
@@ -423,7 +413,6 @@ export default {
   }
 }
 
-/* 关闭按钮样式 */
 ::v-deep .close-btn {
   position: absolute;
   top: 10px;
@@ -440,7 +429,6 @@ export default {
   color: #ffffff;
 }
 
-/* 确保容器相对定位,以便按钮定位 */
 ::v-deep .custom-info-card {
   position: relative;
   background: rgba(10, 15, 24, 0.95);
@@ -449,18 +437,6 @@ export default {
   min-width: 200px;
   border: 1px solid rgba(255, 255, 255, 0.1);
   box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
-}
-
-/* --- 彻底还原图片的自定义弹窗 --- */
-::v-deep .custom-info-card {
-  background: rgba(10, 15, 24, 0.95);
-  /* 极深色背景 */
-  border-radius: 10px;
-  /* 较大的圆角 */
-  padding: 12px 16px;
-  min-width: 200px;
-  border: 1px solid rgba(255, 255, 255, 0.1);
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
   color: #fff;
 }
 
@@ -478,10 +454,9 @@ export default {
   justify-content: center;
   align-items: center;
   margin-right: 8px;
-  font-size: 11px;
-  color: #000;
-  /* 图标内文字为黑色 */
-  font-weight: bold;
+  font-size: 12px;
+  padding: 12px;
+  box-sizing: border-box;
 }
 
 ::v-deep .status-dot span {
@@ -502,18 +477,15 @@ export default {
 
 ::v-deep .label {
   color: #8da6c7;
-  /* 标签灰色 */
   white-space: nowrap;
 }
 
 ::v-deep .value {
   color: #ffffff;
-  /* 内容白色 */
 }
 
 ::v-deep .digital {
   font-family: 'Consolas', monospace;
-  /* 模拟数字字体 */
 }
 
 .map-legend {
@@ -549,10 +521,16 @@ export default {
 }
 
 .legend-dot {
+  margin-right: 10px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.legend-dot:not(.is-status-wrapper) {
   width: 20px;
   height: 20px;
-  margin-right: 10px;
-  border-radius: 10px;
+  border-radius: 50%;
 }
 
 .legend-dot span {
@@ -566,6 +544,47 @@ export default {
   line-height: 20px;
 }
 
+.legend-dot.special-route {
+  position: relative;
+  overflow: visible !important;
+  z-index: 1;
+  border: none !important;
+  border-radius: 50%;
+}
+
+.legend-dot.special-route span {
+  position: relative;
+  z-index: 3;
+  font-weight: bold;
+}
+
+.legend-dot.special-route::after {
+  content: "";
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 150%;
+  height: 3px;
+  background-color: inherit;
+  opacity: 0.8;
+  transform: translate(-50%, -50%) rotate(-45deg);
+  pointer-events: none;
+  z-index: 0;
+}
+
+.legend-dot.special-route::before {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  z-index: 2;
+  background-color: inherit;
+  opacity: 1;
+}
+
 .legend-label {
   flex: 1;
   color: #d0d9e2;
@@ -589,4 +608,21 @@ export default {
   width: 10px;
   height: 10px;
 }
+
+.legend-dot.is-status-wrapper {
+  width: 28px;
+  height: 28px;
+  background-color: transparent !important;
+  box-shadow: none !important;
+  border: none !important;
+  margin-right: 0;
+  transform: translateX(-15%);
+}
+
+.status-icon {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+  display: block;
+}
 </style>

+ 214 - 0
src/components/ui/IntersectionControlCard.vue

@@ -0,0 +1,214 @@
+<template>
+    <div class="control-card">
+        <div class="card-header">
+            <span class="dot" :style="{ background: data.statusColor }"></span>
+            <span class="road-name">{{ data.name }}</span>
+        </div>
+
+        <div class="card-body">
+            <div class="micro-map-container">
+                <IntersectionMapVideos v-if="data.mapData" :mapData="data.mapData" :videoUrls="data.videoUrls || {}" />
+            </div>
+
+            <div class="info-panel">
+                <div class="info-item">驻留阶段:<span>{{ data.stage }}</span></div>
+                <div class="info-item">执行方式:<span>{{ data.mode }}</span></div>
+                <div class="info-item">剩余时间:<span class="time">{{ data.timeLeft }}s</span></div>
+                <button :class="{'btn btn-view': data.btnType === 'normal', 'action-btn primary': data.btnType === 'primary'}">{{ data.btnText }}</button>
+            </div>
+        </div>
+
+        <div class="card-footer">
+            <div 
+                v-for="phase in data.phases" 
+                :key="phase.id" 
+                class="phase-box" 
+                :class="{ 'is-active': phase.active }"
+                @click="selectPhase(phase)"
+            >
+                <img v-if="phase.img" :src="phase.img" alt="phase-img" class="phase-image" />
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
+
+export default {
+    name: 'IntersectionControlCard',
+    components: { IntersectionMapVideos },
+    props: { 
+        data: { type: Object, required: true } 
+    },
+    methods: {
+        // 单选逻辑处理
+        selectPhase(selectedPhase) {
+            // 1. 如果点击的已经是激活状态,不做处理 (或者你也可以让它可以取消选中,看业务需求)
+            if (selectedPhase.active) return;
+
+            // 2. 排他操作:把当前卡片下的所有 phase 的 active 置为 false
+            this.data.phases.forEach(p => {
+                p.active = false;
+            });
+
+            // 3. 将当前点击的置为 true
+            selectedPhase.active = true;
+
+            // 4. (可选) 向父组件派发事件,告知哪个路口切换了哪个相位,方便父组件发送请求给后端
+            this.$emit('phase-changed', {
+                cardId: this.data.id,
+                phaseId: selectedPhase.id
+            });
+        }
+    }
+}
+</script>
+
+<style scoped>
+.control-card {
+    background: #112445;
+    padding: 16px;
+    border-radius: 6px;
+    border: 1px solid rgba(68, 138, 255, 0.15);
+    display: flex;
+    flex-direction: column;
+}
+
+.card-header {
+    color: #fff;
+    font-size: 14px;
+    margin-bottom: 16px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.dot {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    box-shadow: 0 0 5px currentColor;
+}
+
+.road-name {
+    font-weight: 500;
+    letter-spacing: 0.5px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.card-body {
+    display: flex;
+    gap: 16px;
+    margin-bottom: 16px;
+}
+
+.micro-map-container {
+    width: 140px;
+    height: 140px;
+    background: #050a17;
+    border-radius: 4px;
+    overflow: hidden;
+    flex-shrink: 0;
+    border: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.info-panel {
+    color: rgba(255, 255, 255, 0.7);
+    font-size: 13px;
+    display: flex;
+    flex-direction: column;
+}
+
+.info-item {
+    margin-bottom: 10px;
+    color: #6CFFD2;
+}
+
+.info-item span {
+    color: #6CFFD2;
+    font-weight: bold;
+}
+
+.info-item .time {
+    color: #6CFFD2;
+}
+
+.action-btn {
+    margin-top: auto;
+    width: 100%;
+    padding: 5px 8px;
+    border: none;
+    border-radius: 4px;
+    color: #fff;
+    cursor: pointer;
+    font-size: 14px;
+    font-weight: bold;
+    transition: opacity 0.3s;
+}
+
+.action-btn:hover {
+    opacity: 0.8;
+}
+
+.action-btn.primary {
+    background: #1E6AFF;
+}
+
+/* ================== 【修改】底部相位按钮组样式 ================== */
+.card-footer {
+    display: flex;
+    justify-content: space-between;
+    gap: 8px;
+    margin-top: auto;
+}
+
+.phase-box {
+    position: relative;
+    flex: 0 0 56px; 
+    height: 56px;
+    background: #E6F0FF; 
+    border-radius: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    box-sizing: border-box;
+    overflow: hidden;
+}
+
+/* 保证图片居中且自适应大小 */
+.phase-image {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+}
+
+.phase-box::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    /* 这里设置蒙层的颜色,比如主题蓝 #1E6AFF 的 50% 透明度 */
+    background: rgba(30, 106, 255, 0.5); 
+    opacity: 0; /* 默认隐藏 */
+    transition: opacity 0.3s ease;
+    pointer-events: none; /* 关键:让鼠标点击穿透蒙层,防止阻挡点击事件 */
+}
+
+/* 激活状态下的样式 */
+.phase-box.is-active {
+    
+}
+
+/* 激活时,让透明遮罩层显示出来 */
+.phase-box.is-active::after {
+    opacity: 1; 
+}
+</style>

+ 12 - 5
src/components/ui/MenuItem.vue

@@ -17,7 +17,11 @@
     >
       <i v-if="node.icon" :class="node.icon" class="node-icon"></i>
       
-      <span class="node-label">{{ node.label }}</span>
+      <span class="node-label">
+        <slot name="label" :node="node">
+          {{ node.label }}
+        </slot>
+      </span>
       
       <span 
         v-if="hasChildren" 
@@ -36,7 +40,11 @@
         :level="level + 1"
         :theme="theme" 
         @node-click="passEventUp" 
-      />
+      >
+        <template #label="{ node: innerNode }">
+          <slot name="label" :node="innerNode"></slot>
+        </template>
+      </MenuItem>
     </div>
   </div>
 </template>
@@ -73,9 +81,8 @@ export default {
     handleClick() {
       if (this.hasChildren) {
         this.isOpen = !this.isOpen;
-      } else {
-        this.$emit('node-click', this.node);
-      }
+      } 
+      this.$emit('node-click', this.node);
     },
     passEventUp(nodeData) {
       this.$emit('node-click', nodeData);

+ 166 - 0
src/components/ui/SpecialTaskMonitorPanel.vue

@@ -0,0 +1,166 @@
+<template>
+  <div class="special-task-panel">
+    
+    <swiper class="my-swiper" :options="swiperOptions" ref="mySwiper">
+      <swiper-slide v-for="(item, index) in combinedList" :key="index" class="custom-slide">
+        
+        <VideoMonitorBox 
+          v-if="item.video" 
+          :videoUrl="item.video.url" 
+        />
+        <div v-else class="empty-placeholder"></div>
+
+        <IntersectionControlCard 
+          v-if="item.card" 
+          :data="item.card" 
+          class="margin-top-20"
+        />
+        
+      </swiper-slide>
+    </swiper>
+
+    <div class="nav-btn left-btn swiper-button-prev"></div>
+    <div class="nav-btn right-btn swiper-button-next"></div>
+
+  </div>
+</template>
+
+<script>
+import { Swiper, SwiperSlide } from 'vue-awesome-swiper';
+import 'swiper/css/swiper.css'; 
+
+import VideoMonitorBox from './VideoMonitorBox.vue';
+import IntersectionControlCard from './IntersectionControlCard.vue';
+
+export default {
+  name: 'SpecialTaskMonitorPanel',
+  components: { Swiper, SwiperSlide, VideoMonitorBox, IntersectionControlCard },
+  props: {
+    panelData: { type: Object, required: true }
+  },
+  data() {
+    return {
+      swiperOptions: {
+        slidesPerView: 3,      // 显示 3 列
+        slidesPerGroup: 3,     // 每次滑动 3 列
+        spaceBetween: 20,      // 列间距 20px
+        simulateTouch: true,   // 允许鼠标拖拽
+        speed: 600,            // 滑动动画 600ms,更加优雅
+        navigation: {
+          nextEl: '.swiper-button-next',
+          prevEl: '.swiper-button-prev'
+        }
+      }
+    };
+  },
+  computed: {
+    // 将独立的两组数据合并为 "列数据",方便 swiper 渲染
+    combinedList() {
+      if (!this.panelData) return [];
+      const videos = this.panelData.videos || [];
+      const cards = this.panelData.intersections || [];
+      const maxLen = Math.max(videos.length, cards.length);
+      
+      const list = [];
+      for (let i = 0; i < maxLen; i++) {
+        list.push({
+          video: videos[i] || null,
+          card: cards[i] || null
+        });
+      }
+      return list;
+    }
+  }
+}
+</script>
+
+<style scoped>
+.special-task-panel {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  padding: 20px 60px; /* 给左右箭头留出足够空间 */
+  box-sizing: border-box;
+}
+
+.my-swiper {
+  width: 100%;
+  height: 100%;
+  padding: 10px 5px; /* 防止子元素的阴影被裁切 */
+}
+
+.custom-slide {
+  display: flex;
+  flex-direction: column;
+}
+
+.empty-placeholder {
+  height: 220px; /* 需与 VideoMonitorBox 的高度一致 */
+  background: rgba(255,255,255,0.02);
+  border-radius: 6px;
+  border: 1px dashed rgba(255,255,255,0.1);
+}
+
+.margin-top-20 {
+  margin-top: 20px;
+}
+
+/* ================= 自定义左右箭头样式 ================= */
+.swiper-button-prev:after, .swiper-button-next:after { display: none; } /* 隐藏默认箭头 */
+
+.nav-btn {
+  position: absolute;
+  top: 50%;
+  margin-top: -22px; /* 替代 translateY 居中,防止和 rotate 冲突 */
+  width: 44px;
+  height: 44px;
+  background-color: transparent;
+  background-size: contain;
+  background-position: center;
+  background-repeat: no-repeat;
+  border: none;
+  cursor: pointer;
+  z-index: 10;
+  transition: filter 0.3s ease; /* 动画过渡 */
+}
+
+/* ---------------- 左侧按钮逻辑 ---------------- */
+.left-btn {
+  left: 10px;
+  /* 正常可用状态:借用右可用图,旋转 180 度变成向左 */
+  background-image: url('@/assets/main/main-right.png');
+  transform: rotate(180deg);
+}
+.left-btn.swiper-button-disabled {
+  /* 禁用状态:使用原生的左禁用图,不旋转 */
+  background-image: url('@/assets/main/main-left.png');
+  transform: rotate(0deg);
+  cursor: not-allowed;
+}
+
+/* ---------------- 右侧按钮逻辑 ---------------- */
+.right-btn {
+  right: 10px;
+  /* 正常可用状态:直接使用右可用图,不旋转 */
+  background-image: url('@/assets/main/main-right.png');
+  transform: rotate(0deg);
+}
+.right-btn.swiper-button-disabled {
+  /* 禁用状态:借用左禁用图,旋转 180 度变成向右 */
+  background-image: url('@/assets/main/main-left.png');
+  transform: rotate(180deg);
+  cursor: not-allowed;
+}
+
+/* ---------------- 悬停特效 (仅对可用状态生效) ---------------- */
+/* 左按钮悬停:保持 180 度旋转并放大 */
+.left-btn:not(.swiper-button-disabled):hover {
+  transform: rotate(180deg) scale(1.1);
+  filter: drop-shadow(0 0 8px rgba(68, 138, 255, 0.6));
+}
+/* 右按钮悬停:保持 0 度旋转并放大 */
+.right-btn:not(.swiper-button-disabled):hover {
+  transform: rotate(0deg) scale(1.1);
+  filter: drop-shadow(0 0 8px rgba(68, 138, 255, 0.6));
+}
+</style>

+ 72 - 0
src/components/ui/TaskMonitorHeader.vue

@@ -0,0 +1,72 @@
+<template>
+    <div class="task-header">
+        <div class="left-info">
+            <span class="status-dot" :style="{ background: taskData.statusColor }"></span>
+            <span class="title">{{ taskData.name }}</span>
+            <span class="info-text">{{ taskData.time }}</span>
+            <span class="info-text">{{ taskData.manager }}</span>
+            <span class="level-tag">{{ taskData.level }}</span>
+            <span class="info-text">{{ taskData.status }}</span>
+            <button class="btn btn-view" @click="handleEnd">立即结束</button>
+        </div>
+    </div>
+</template>
+
+<script>
+export default {
+    name: 'TaskMonitorHeader',
+    props: {
+        taskData: { type: Object, required: true },
+        onEndTask: { type: Function }
+    },
+    methods: {
+        handleEnd() {
+            if (this.onEndTask) this.onEndTask();
+        }
+    }
+}
+</script>
+
+<style scoped>
+.task-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    width: 100%;
+    padding-right: 20px;
+}
+
+.left-info {
+    display: flex;
+    align-items: center;
+    gap: 15px;
+    color: #fff;
+    font-size: 14px;
+}
+
+.status-dot {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+    box-shadow: 0 0 8px currentColor;
+}
+
+.title {
+    font-weight: bold;
+    font-size: 16px;
+    letter-spacing: 1px;
+}
+
+.info-text {
+    color: rgba(255, 255, 255, 0.7);
+}
+
+.level-tag {
+    color: #ff4d4f;
+    border: 1px solid rgba(255, 77, 79, 0.5);
+    background: rgba(255, 77, 79, 0.1);
+    padding: 2px 8px;
+    border-radius: 4px;
+    font-size: 12px;
+}
+</style>

+ 113 - 0
src/components/ui/VideoMonitorBox.vue

@@ -0,0 +1,113 @@
+<template>
+  <div class="video-box">
+    <template v-if="videoSrc">
+      <video :src="videoSrc" autoplay loop muted class="real-video"></video>
+      <div class="close-btn" title="关闭视频" @click="handleClose">
+        ×
+      </div>
+    </template>
+    
+    <template v-else>
+      <div class="empty-state" @click="handleOpen" title="点击关联视频">
+        <span class="empty-tag">关联视频</span>
+        <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'VideoMonitorBox',
+  props: {
+    videoUrl: { type: String, default: '' }
+  },
+  data() {
+    return {
+        videoSrc: null,
+    }
+  },
+  methods: {
+    // 处理关闭/关联视频的事件
+    handleClose() {
+      this.$emit('close');
+      this.videoSrc = null;
+    },
+    handleOpen() {
+      this.$emit('open');
+      this.videoSrc = this.videoUrl;
+    }
+  }
+}
+</script>
+
+<style scoped>
+.video-box {
+  background: #112445;
+  border-radius: 6px;
+  height: 220px;
+  position: relative;
+  overflow: hidden;
+  border: 1px solid rgba(68, 138, 255, 0.15);
+  box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.5);
+}
+.real-video { width: 100%; height: 100%; object-fit: cover; }
+
+.close-btn { 
+  position: absolute; 
+  top: 10px; 
+  right: 10px; 
+  background: rgba(0,0,0,0.6); 
+  color: #fff; 
+  width: 24px; 
+  height: 24px; 
+  border-radius: 50%; 
+  display: flex; 
+  align-items: center; 
+  justify-content: center; 
+  cursor: pointer; 
+  transition: background 0.3s; 
+  z-index: 10; 
+}
+.close-btn:hover { background: rgba(0, 0, 0, 1) }
+.empty-state { 
+  display: flex; 
+  flex-direction: column; 
+  align-items: center; 
+  justify-content: center; 
+  height: 100%; 
+  cursor: pointer;
+  transition: all 0.3s ease;
+}
+
+.empty-state:hover {
+  background: rgba(68, 138, 255, 0.1);
+}
+.empty-state:hover .empty-tag {
+  background: rgba(68, 138, 255, 1);
+  box-shadow: 0 0 10px rgba(68, 138, 255, 0.5);
+}
+.empty-state:hover .camera-image {
+  transform: scale(1.05);
+  transition: transform 0.3s ease;
+}
+
+.empty-tag { 
+  background: rgba(68, 138, 255, 0.8); 
+  color: #fff; 
+  padding: 4px 16px; 
+  border-radius: 4px; 
+  font-size: 14px; 
+  margin-bottom: 20px; 
+  letter-spacing: 1px; 
+  transition: all 0.3s ease;
+}
+
+.camera-image {
+  width: 80px; 
+  height: 80px; 
+  object-fit: contain; 
+  opacity: 0.8; 
+  margin-bottom: 20px; 
+}
+</style>

+ 5 - 1
src/layouts/DashboardLayout.vue

@@ -92,6 +92,8 @@ import DeviceRestart from '@/components/ui/DeviceRestart.vue';
 import DeviceUpgrade from '@/components/ui/DeviceUpgrade.vue';
 import OnlineStatusTabs from '@/components/ui/OnlineStatusTabs.vue';
 import DeviceStatusTabs from '@/components/ui/DeviceStatusTabs.vue';
+import TaskMonitorHeader from '@/components/ui/TaskMonitorHeader.vue';
+import SpecialTaskMonitorPanel from '@/components/ui/SpecialTaskMonitorPanel.vue';
 
 export default {
     name: 'DashboardLayout',
@@ -112,7 +114,9 @@ export default {
         DeviceRestart,
         DeviceUpgrade,
         OnlineStatusTabs,
-        DeviceStatusTabs
+        DeviceStatusTabs,
+        TaskMonitorHeader,
+        SpecialTaskMonitorPanel
     },
     provide() {
         return {

+ 13 - 5
src/views/Home.vue

@@ -12,6 +12,7 @@
     <!-- 地图 -->
     <template #map>
         <TongzhouTrafficMap
+          ref="trafficMapRef"
           amapKey="db2da7e3e248c3b2077d53fc809be63f"
           securityJsCode="a7413c674852c5eaf01d90813c5b7ef6"
         />
@@ -152,21 +153,24 @@ export default {
           title: '通讯中断',
           type: 'error', // 渲染为红色
           time: '16:28:28',
-          description: '中关村大街-科学院南路口-设备离线'
+          description: '中关村大街-科学院南路口-设备离线',
+          position: [116.695702, 39.892886]
         },
         {
           id: '2',
           title: '2.降级黄闪',
           type: 'warning', // 渲染为黄色
           time: '16:28:28', // 
-          description: '中关村大街-科学院南路口-设备离线'
+          description: '中关村大街-科学院南路口-设备离线',
+          position: [116.6365, 39.8850]
         },
         {
           id: '3',
           title: '3.降级黄闪',
           type: 'warning',
           time: '16:28:28',
-          description: '中关村大街-科学院南路口-设备离线'
+          description: '中关村大街-科学院南路口-设备离线',
+          position: [116.6800, 39.8860]
         }
       ],
       // 1. 表头
@@ -225,8 +229,12 @@ export default {
     },
     // 处理查看逻辑
     onAlarmView({ item, index }) {
-      console.log('点击了查看:', item.title);
-      // 这里可以触发打开一个弹窗 (调用你之前的 SmartDialog 或者路由跳转)
+      console.log('点击了查看:', item);
+      // 临时逻辑,有真实接口后可以删除
+      const position = localStorage.getItem(`pos${index + 1}`).split(',');
+      
+      // 地图联动
+      this.$refs.trafficMapRef.focusByLocation([Number(position[0]), Number(position[1])]);
     },
     onIntersectionRowClick({ row, index }) {
       console.log(`准备跳转查看关键路口详情,当前路口:`, row.id, row.intersection);

+ 167 - 48
src/views/StatusMonitoring.vue

@@ -28,11 +28,14 @@
                     </TechTabPane>
                     <TechTabPane label="干线" name="trunkLine" class="menu-scroll-view">
                         <MenuItem v-for="item in menuData" :key="item.id" :node="item" :level="0"
-                            @node-click="handleMenuClick" />
+                            @node-click="handleMenuClick">
+                        <template #label="{ node }">
+                            <span v-if="node.children && node.children.length > 0">{{ node.label }}</span>
+                            <span v-else>{{ node.label }} 绿波带</span>
+                        </template>
+                        </MenuItem>
                     </TechTabPane>
-                    <TechTabPane label="特勤" name="specialDuty" class="menu-scroll-view">
-                        <MenuItem v-for="item in menuData" :key="item.id" :node="item" :level="0"
-                            @node-click="handleMenuClick" />
+                    <TechTabPane label="特勤" name="specialDuty">
                     </TechTabPane>
                 </TechTabs>
             </div>
@@ -61,6 +64,9 @@ import TongzhouTrafficMap from '@/components/TongzhouTrafficMap.vue';
 import MenuItem from '@/components/ui/MenuItem.vue';
 import ButtonGroup from '@/components/ui/ButtonGroup.vue';
 import { makeTrafficTimeSpaceData } from '@/mock/data';
+import testVideo1 from '@/assets/videos/video1.mp4';
+import testVideo2 from '@/assets/videos/video2.mp4';
+import testImg1 from '@/assets/test_img1.png';
 
 
 export default {
@@ -223,12 +229,12 @@ export default {
     mounted() {
         // 组件挂载时检查路由
         this.checkRouteParams();
-        
+
         // 初始显示顶部图表(如果没有路由参数覆盖的话)
         if (Object.keys(this.$route.query).length === 0) {
-            this.showTopChartDalogs(); 
+            this.showTopChartDalogs();
         }
-        
+
     },
     methods: {
         // 模式切换
@@ -242,8 +248,8 @@ export default {
                     id: 'crossing-list', // 这里的 ID 可以根据实际业务场景动态生成
                     title: '',
                     component: 'CrossingListPanel',
-                    width: 1720,
-                    height: 682,
+                    width: 1920,
+                    height: 750,
                     center: false,
                     showClose: true,
                     noPadding: false,
@@ -294,12 +300,13 @@ export default {
             } else if (this.activeLeftTab === 'trunkLine') { // 干线
                 // TODO: 干线Tab的顶部图表
             } else if (this.activeLeftTab === 'specialDuty') { // 特勤
-                // TODO: 特勤Tab的顶部图表
+                this.openDutyDetailDialog();
             }
         },
         // 显示总览弹窗组
         showOverviewDalogs(nodeData) {
             console.log('显示总览弹窗组', nodeData.id, nodeData.label);
+            this.showCrossingDetailDialogs(nodeData);
         },
         showOverviewTopDialogs() {
             this.$refs.layout.openDialog({
@@ -336,25 +343,27 @@ export default {
             console.log('显示路口弹窗组', nodeData.id, nodeData.label);
 
             // 路口弹窗
-            this.$refs.layout.openDialog({
-                id: 'crossing3_' + nodeData.id, // 这里的 ID 可以根据实际业务场景动态生成
-                title: nodeData.label,
-                component: 'CrossingPanel',
-                width: 260,
-                height: 260,
-                center: false,
-                showClose: true,
-                position: { x: 950, y: 430 },
-                noPadding: false,
-                data: {
-                    ...nodeData,
-                    onExpand: (data) => this.handleDoubleClickExpend(data)
-                },
-                onClose: () => {
-                    this.$refs.layout.handleDialogClose('top-chart-crossing-1');
-                    this.$refs.layout.handleDialogClose('top-chart-crossing-2');
-                }
-            });
+            // this.$refs.layout.openDialog({
+            //     id: 'crossing3_' + nodeData.id, // 这里的 ID 可以根据实际业务场景动态生成
+            //     title: nodeData.label,
+            //     component: 'CrossingPanel',
+            //     width: 260,
+            //     height: 260,
+            //     center: false,
+            //     showClose: true,
+            //     position: { x: 950, y: 430 },
+            //     noPadding: false,
+            //     data: {
+            //         ...nodeData,
+            //         onExpand: (data) => this.handleDoubleClickExpend(data)
+            //     },
+            //     onClose: () => {
+            //         this.$refs.layout.handleDialogClose('top-chart-crossing-1');
+            //         this.$refs.layout.handleDialogClose('top-chart-crossing-2');
+            //     }
+            // });
+
+            this.showCrossingDetailDialogs(nodeData);
 
 
         },
@@ -367,9 +376,9 @@ export default {
                 component: 'CrossingDetailPanel',
                 width: 1315,
                 height: 682,
-                center: true,
+                center: false,
                 showClose: true,
-                // position: { x: 750, y: 130 },
+                position: { x: 500, y: 170 },
                 noPadding: false,
                 enableDblclickExpand: false,
                 data: nodeData
@@ -447,9 +456,9 @@ export default {
         showSpecialDutyDalogs(nodeData) {
             console.log('显示干线弹窗组', nodeData.id, nodeData.label);
         },
-        // === 新增:解析路由参数并执行对应操作 ===
+        // === 解析路由参数并执行对应操作 ===
         checkRouteParams() {
-            // 【修正】统一参数接收:特勤接收 id,路口接收 intersectionName 和 plan
+            // 统一参数接收:特勤接收 id,路口接收 intersectionName 和 plan
             const { tab, action, id, } = this.$route.query;
 
             if (!tab) return; // 如果没有传递 tab 参数,说明是正常访问,不处理
@@ -459,14 +468,14 @@ export default {
                 this.activeLeftTab = 'specialDuty'; // 切换到左侧【特勤】Tab
                 this.handleTabClick('specialDuty'); // 手动触发 Tab 切换事件,更新顶部图表
 
-                // 【修正】这里判断的条件改为 id
+                // 这里判断的条件改为 id
                 if (action === 'open-dialog' && id) {
                     this.$nextTick(() => {
                         this.openDutyDetailDialog(id); // 打开特勤弹窗
                     });
                 }
-            } 
-            
+            }
+
             // 2. 处理“关键路口”跳转
             else if (tab === 'crossing') {
                 this.activeLeftTab = 'crossing'; // 切换到左侧【路口】Tab
@@ -484,24 +493,134 @@ export default {
             }
         },
 
-        // === 新增:特勤详情弹窗 (你需要根据实际组件名替换) ===
-        openDutyDetailDialog(dutyId) {
+        // === 特勤详情弹窗 (你需要根据实际组件名替换) ===
+        async openDutyDetailDialog(dutyId) {
             console.log('准备打开特勤线路详情,ID:', dutyId);
-            // 这里仿照你原有的开窗逻辑,打开对应的组件
+            // 1. 获取数据
+            const panelData = await this.fetchSpecialTaskData();
+
+            // 2. 呼出弹窗
             this.$refs.layout.openDialog({
-                id: 'special-duty-detail-' + dutyId,
-                title: '勤务执行详情',
-                component: 'SecurityRoutePanelSwitch', // 注意:请替换为你项目中真实的特勤弹窗组件名
-                width: 1200,
-                height: 600,
-                center: true,
-                showClose: true,
-                data: { 
-                    id: dutyId 
+                id: 'special-task-dialog',
+                title: ' ', // 留空以隐藏默认标题,使用自定义 Header
+                width: 1400, // 弹窗宽一点,容纳 3 列
+                height: 700,
+                center: false,
+                showClose: false,
+                noPadding: true, // 去除默认内边距,让内部组件自己控制
+                position: {x: 200, y: 150},
+                // 挂载主体组件和数据
+                component: 'SpecialTaskMonitorPanel',
+                data: { panelData: panelData },
+
+                // 挂载自定义 Header 和数据
+                headerComponent: 'TaskMonitorHeader',
+                headerProps: {
+                    taskData: panelData.taskInfo,
+                    onEndTask: () => {
+                        console.log('点击了结束任务');
+                        this.$refs.layout.handleDialogClose('special-task-dialog');
+                    }
                 }
             });
         },
 
+        // 模拟从后端拉取数据
+        async fetchSpecialTaskData() {
+            // 模拟 API 请求延迟
+            await new Promise(resolve => setTimeout(resolve, 500));
+
+            // 这是后端返回的完整数据结构
+            return {
+                // 1. 头部任务信息
+                taskInfo: {
+                    name: '北京路演唱会特勤路线',
+                    time: '12:00-14:00',
+                    manager: '张飞',
+                    level: '一级',
+                    status: '进行中',
+                    statusColor: '#ff4d4f' // 红色状态灯
+                },
+                // 2. 视频流列表 (支持有源和无源)
+                videos: [
+                    { id: 1, url: testVideo1 }, // 有视频
+                    { id: 2, url: testVideo2 }, // 无视频,展示占位
+                    { id: 3, url: testVideo1 },
+                    { id: 4, url: testVideo2 } // 第4个,用于测试轮播翻页
+                ],
+                // 3. 路口控制卡片列表
+                intersections: [
+                    {
+                        id: 'INT_01',
+                        name: '京原路与北宫路交叉口1',
+                        statusColor: '#ffaa00', // 黄色状态灯
+                        stage: 3,
+                        mode: '步进',
+                        timeLeft: 30,
+                        btnText: '立即解锁',
+                        btnType: 'normal',
+                        phases: [
+                            { id: 1, icon: '↑', img: testImg1, active: false },
+                            { id: 2, icon: '↰', img: testImg1, active: false },
+                            { id: 3, icon: '↑', img: testImg1, active: true }, // 当前激活相位
+                            { id: 4, icon: '↰', img: testImg1, active: false }
+                        ],
+                        // 传给你原有的 IntersectionMapVideos 组件的数据
+                        mapData: {
+                            armsConfig: {
+                                N: { lanes: ['L', 'S', 'R'], cameraType: 1 },
+                                S: { lanes: ['L', 'S', 'R'], cameraType: 1 },
+                                E: { lanes: ['L', 'S', 'R'], cameraType: 2 },
+                                W: { lanes: ['L', 'S', 'R'], cameraType: 2 }
+                            },
+                            signals: {
+                                ns: { isGreen: true, time: 30, phaseName: '南北直行' },
+                                ew: { isGreen: false, time: 45, phaseName: '东西直行' }
+                            }
+                        },
+                        videoUrls: {
+                            nw: testVideo1,
+                            ne: testVideo2,
+                            sw: testVideo1,
+                            se: testVideo2
+                        }
+                    },
+                    // 为了演示,这里复制上面的数据作为第2、3、4个路口
+                    ...Array.from({ length: 3 }).map((_, i) => ({
+                        id: `INT_0${i + 2}`,
+                        name: `京原路与北宫路交叉口${i + 2}`,
+                        statusColor: '#00e5ff',
+                        stage: 2, mode: '系统', timeLeft: 15,
+                        btnText: '立即执行', btnType: 'primary',
+                        phases: [
+                            { id: 1, icon: '↑', img: testImg1, active: true },
+                            { id: 2, icon: '↰', img: testImg1, active: false },
+                            { id: 3, icon: '↑', img: testImg1, active: false },
+                            { id: 4, icon: '↰', img: testImg1, active: false }
+                        ],
+                        mapData: {
+                            armsConfig: {
+                                N: { lanes: ['L', 'S', 'R'], cameraType: 1 },
+                                S: { lanes: ['L', 'S', 'R'], cameraType: 1 },
+                                E: { lanes: ['L', 'S', 'R'], cameraType: 2 },
+                                W: { lanes: ['L', 'S', 'R'], cameraType: 2 }
+                            },
+                            signals: {
+                                ns: { isGreen: true, time: 30, phaseName: '南北直行' },
+                                ew: { isGreen: false, time: 45, phaseName: '东西直行' }
+                            }
+                        },
+                        videoUrls: {
+                            nw: testVideo1,
+                            ne: testVideo2,
+                            sw: testVideo2,
+                            se: testVideo1
+                        }
+                    }))
+                ]
+            };
+        },
+
     }
 }
 </script>