소스 검색

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

hebotao 2 주 전
부모
커밋
9615cbdbef

+ 200 - 81
src/components/TongzhouTrafficMap.vue

@@ -318,7 +318,12 @@ export default {
           { start: [116.6445, 39.9075], end: [116.6846, 39.9075], color: "#13C373" }
         ],
         "勤务路线": [
-          { start: [116.7120, 39.9225], end: [116.7120, 39.8971], color: "#BC301D" }
+          // 第一段:未执行(全红)— 通州大街西段,干线协调下方约600m
+          { start: [116.6445, 39.8980], end: [116.6850, 39.8980], color: "#BC301D", dutyState: 'pending' },
+          // 第二段:执行中(进度45%)— 通州大街中段
+          { start: [116.6850, 39.8980], end: [116.7250, 39.8980], color: "#BC301D", dutyState: 'active', progress: 0.45 },
+          // 第三段:已执行完(全灰)— 通州大街东段
+          { start: [116.7250, 39.8980], end: [116.7650, 39.8980], color: "#BC301D", dutyState: 'done' }
         ]
       };
 
@@ -501,104 +506,200 @@ export default {
         // 应用偏移到当前段的所有点
         const segmentPath = rawSegmentPath.map(p => applyOffset(p));
 
-        const polyline = new this.AMap.Polyline({
-          path: segmentPath,
-          strokeColor: line.color,
-          strokeWeight: 6,
-          strokeOpacity: 0.8,
-          zIndex: 15
-        });
-        overlays.push(polyline);
-
-        const totalPoints = segmentPath.length;
-
-        // --- 核心优化:全路段物理距离均匀放置方向箭头 ---
-        // 1. 计算整条 segmentPath 的总物理距离及每个点的累计距离
-        const pathDistances = [0];
-        let totalPathDist = 0;
-        for (let j = 0; j < totalPoints - 1; j++) {
-          const d = this.calcApproxDistance(segmentPath[j], segmentPath[j+1]);
-          totalPathDist += d;
-          pathDistances.push(totalPathDist);
-        }
-
-        // 2. 设定标准间距:约每 0.0018 度 (约 200 米) 放置一个箭头
-        if (configName !== '干线协调') {
-          const targetSpacing = 0.0018;
-          let currentTargetDist = targetSpacing / 2; // 第一个箭头放在 1/2 间距处,让分布更美观
-
-          while (currentTargetDist < totalPathDist) {
-            // 3. 寻找对应 targetDist 的路径位置 (线性插值)
-            let foundIdx = 0;
-            for (let j = 0; j < pathDistances.length - 1; j++) {
-              if (currentTargetDist >= pathDistances[j] && currentTargetDist <= pathDistances[j + 1]) {
-                foundIdx = j;
-                break;
-              }
+        // --- 勤务路线:根据 line.dutyState 决定渲染样式 ---
+        if (configName === '勤务路线' && line.dutyState) {
+          const state = line.dutyState; // 'pending' | 'active' | 'done'
+          const progress = (state === 'active') ? Math.min(Math.max(Number(line.progress) || 0.5, 0), 1) : (state === 'done' ? 1 : 0);
+
+          // 计算整段累计距离
+          const dists = [0];
+          let totalDist = 0;
+          for (let j = 0; j < segmentPath.length - 1; j++) {
+            totalDist += this.calcApproxDistance(segmentPath[j], segmentPath[j + 1]);
+            dists.push(totalDist);
+          }
+          const splitDist = totalDist * progress;
+
+          // 找切割点坐标(线性插值)
+          let splitIdx = segmentPath.length - 1;
+          let splitPoint = segmentPath[segmentPath.length - 1];
+          for (let j = 0; j < dists.length - 1; j++) {
+            if (splitDist >= dists[j] && splitDist <= dists[j + 1]) {
+              const ratio = dists[j + 1] === dists[j] ? 0 : (splitDist - dists[j]) / (dists[j + 1] - dists[j]);
+              const p1 = segmentPath[j], p2 = segmentPath[j + 1];
+              splitPoint = [
+                Number(p1[0]) + (Number(p2[0]) - Number(p1[0])) * ratio,
+                Number(p1[1]) + (Number(p2[1]) - Number(p1[1])) * ratio
+              ];
+              splitIdx = j;
+              break;
             }
+          }
 
-            const p1 = segmentPath[foundIdx];
-            const p2 = segmentPath[foundIdx + 1];
-            if (p1 && p2) {
-              // 在 p1 和 p2 之间线性插值
-              const ratio = (currentTargetDist - pathDistances[foundIdx]) / (pathDistances[foundIdx + 1] - pathDistances[foundIdx]);
-              const lng1 = Number(p1[0]);
-              const lat1 = Number(p1[1]);
-              const lng2 = Number(p2[0]);
-              const lat2 = Number(p2[1]);
+          // 已过段(灰色)
+          const passedPath = [...segmentPath.slice(0, splitIdx + 1), splitPoint];
+          if (passedPath.length >= 2) {
+            overlays.push(new this.AMap.Polyline({
+              path: passedPath,
+              strokeColor: '#5A5A5A',
+              strokeWeight: 6,
+              strokeOpacity: 0.45,
+              zIndex: 15
+            }));
+          }
 
-              const midLng = lng1 + (lng2 - lng1) * ratio;
-              const midLat = lat1 + (lat2 - lat1) * ratio;
+          // 未过段(原红色),done 状态不绘制
+          const remainPath = [splitPoint, ...segmentPath.slice(splitIdx + 1)];
+          if (state !== 'done' && remainPath.length >= 2) {
+            overlays.push(new this.AMap.Polyline({
+              path: remainPath,
+              strokeColor: line.color,
+              strokeWeight: 6,
+              strokeOpacity: 0.8,
+              zIndex: 15
+            }));
+          }
 
-              const bearing = this.calcBearingDeg(p1, p2);
-              const rotation = bearing - 90;
+          // 执行中:进度点脉冲 marker
+          if (state === 'active') {
+            overlays.push(new this.AMap.Marker({
+              position: splitPoint,
+              zIndex: 150,
+              offset: new this.AMap.Pixel(-12, -12),
+              bubble: true,
+              content: `<div class="duty-progress-node" style="width:24px;height:24px;border-radius:50%;background:#BC301D;border:3px solid #fff;box-shadow:0 0 0 3px rgba(188,48,29,0.4);display:flex;justify-content:center;align-items:center;cursor:default;"><div style="width:8px;height:8px;border-radius:50%;background:#fff;"></div></div>`
+            }));
+          }
 
-              const directionMarker = new this.AMap.Marker({
+          // 方向箭头:已过段灰色低透明,未过段正常
+          const targetSpacing = 0.0036;
+          let arrowDist = targetSpacing / 2;
+          while (arrowDist < totalDist) {
+            let foundIdx = 0;
+            for (let j = 0; j < dists.length - 1; j++) {
+              if (arrowDist >= dists[j] && arrowDist <= dists[j + 1]) { foundIdx = j; break; }
+            }
+            const p1 = segmentPath[foundIdx], p2 = segmentPath[foundIdx + 1];
+            if (p1 && p2) {
+              const ratio = (arrowDist - dists[foundIdx]) / (dists[foundIdx + 1] - dists[foundIdx]);
+              const midLng = Number(p1[0]) + (Number(p2[0]) - Number(p1[0])) * ratio;
+              const midLat = Number(p1[1]) + (Number(p2[1]) - Number(p1[1])) * ratio;
+              const isPassed = arrowDist <= splitDist;
+              const rotation = this.calcBearingDeg(p1, p2) - 90;
+              overlays.push(new this.AMap.Marker({
                 position: [midLng, midLat],
-                content: `
-                  <div style="transform: rotate(${rotation}deg); width: 20px; height: 10px; display: flex; align-items: center; pointer-events: none; opacity: 0.85;">
-                    <img src="${require('@/assets/map/direction.png')}" style="width: 100%; height: auto;" />
-                  </div>
-                `,
+                content: `<div style="transform:rotate(${rotation}deg);width:20px;height:10px;display:flex;align-items:center;pointer-events:none;opacity:${isPassed ? 0.2 : 0.85};filter:${isPassed ? 'grayscale(1)' : 'none'};"><img src="${require('@/assets/map/direction.png')}" style="width:100%;height:auto;" /></div>`,
                 offset: new this.AMap.Pixel(-10, -5),
                 zIndex: 20,
                 bubble: true
-              });
-              overlays.push(directionMarker);
+              }));
             }
-            currentTargetDist += targetSpacing;
+            arrowDist += targetSpacing;
           }
-        }
 
-        // 为圆点保留原来的分布 logic (不受箭头影响)
-        const indices = this.pickEvenlySpacedIndices(totalPoints, 6);
+          // 路口圆点:已过置灰,未过正常
+          const indices = this.pickEvenlySpacedIndices(segmentPath.length, 6);
+          for (let i = 0; i < indices.length; i++) {
+            const idx = indices[i];
+            const p = segmentPath[idx];
+            const lng = Number(p[0]), lat = Number(p[1]);
+            if (Number.isNaN(lng) || Number.isNaN(lat)) continue;
+            const dotDist = dists[idx] || 0;
+            let markerType = 'normal';
+            if (i === 0) markerType = 'start';
+            else if (i === indices.length - 1) markerType = 'end';
+
+            const isPastDot = dotDist <= splitDist;
+            const dotConfig = isPastDot
+              ? { ...config, color: '#5A5A5A', id: `MOCK-D-${lineIdx}-${segmentIdx}-${idx}`, road: `勤务路线路口-${lineIdx}-${segmentIdx}-${idx}` }
+              : { ...config, id: `MOCK-D-${lineIdx}-${segmentIdx}-${idx}`, road: `勤务路线路口-${lineIdx}-${segmentIdx}-${idx}` };
+            const dotType = isPastDot && markerType === 'normal' ? 'passed' : markerType;
+            const marker = this.createTrafficLightMarker([lng, lat], dotConfig, dotType);
+            if (marker) overlays.push(marker);
+          }
+
+        } else {
+          // 非勤务路线 或 无 dutyState:原有逻辑
+          const polyline = new this.AMap.Polyline({
+            path: segmentPath,
+            strokeColor: line.color,
+            strokeWeight: 6,
+            strokeOpacity: 0.8,
+            zIndex: 15
+          });
+          overlays.push(polyline);
+
+          const totalPoints = segmentPath.length;
 
-        // 为第一个和最后一个圆点设置特殊类型
-        for (let i = 0; i < indices.length; i++) {
-          const idx = indices[i];
-          const p = segmentPath[idx];
-          const lng = Number(p[0]);
-          const lat = Number(p[1]);
-          if (Number.isNaN(lng) || Number.isNaN(lat)) continue;
+          const pathDistances = [0];
+          let totalPathDist = 0;
+          for (let j = 0; j < totalPoints - 1; j++) {
+            const d = this.calcApproxDistance(segmentPath[j], segmentPath[j+1]);
+            totalPathDist += d;
+            pathDistances.push(totalPathDist);
+          }
 
-          // 确定圆点类型:勤务路线保留起终点,干线协调移除起终点图标改为普通节点
-          let markerType = 'normal';
           if (configName !== '干线协调') {
-            if (i === 0) {
-              markerType = 'start';
-            } else if (i === indices.length - 1) {
-              markerType = 'end';
+            const targetSpacing = configName === '勤务路线' ? 0.0036 : 0.0018;
+            let currentTargetDist = targetSpacing / 2;
+
+            while (currentTargetDist < totalPathDist) {
+              let foundIdx = 0;
+              for (let j = 0; j < pathDistances.length - 1; j++) {
+                if (currentTargetDist >= pathDistances[j] && currentTargetDist <= pathDistances[j + 1]) {
+                  foundIdx = j;
+                  break;
+                }
+              }
+              const p1 = segmentPath[foundIdx];
+              const p2 = segmentPath[foundIdx + 1];
+              if (p1 && p2) {
+                const ratio = (currentTargetDist - pathDistances[foundIdx]) / (pathDistances[foundIdx + 1] - pathDistances[foundIdx]);
+                const midLng = Number(p1[0]) + (Number(p2[0]) - Number(p1[0])) * ratio;
+                const midLat = Number(p1[1]) + (Number(p2[1]) - Number(p1[1])) * ratio;
+                const bearing = this.calcBearingDeg(p1, p2);
+                const rotation = bearing - 90;
+                const directionMarker = new this.AMap.Marker({
+                  position: [midLng, midLat],
+                  content: `
+                    <div style="transform: rotate(${rotation}deg); width: 20px; height: 10px; display: flex; align-items: center; pointer-events: none; opacity: 0.85;">
+                      <img src="${require('@/assets/map/direction.png')}" style="width: 100%; height: auto;" />
+                    </div>
+                  `,
+                  offset: new this.AMap.Pixel(-10, -5),
+                  zIndex: 20,
+                  bubble: true
+                });
+                overlays.push(directionMarker);
+              }
+              currentTargetDist += targetSpacing;
             }
           }
 
-          const marker = this.createTrafficLightMarker([lng, lat], {
-            ...config,
-            id: `MOCK-${configName.charAt(0)}-${lineIdx}-${segmentIdx}-${idx}`,
-            road: `${configName}路口-${lineIdx}-${segmentIdx}-${idx}`
-          }, markerType);
-          if (marker) overlays.push(marker);
-        }
-      });
+          const indices = this.pickEvenlySpacedIndices(totalPoints, 6);
+          for (let i = 0; i < indices.length; i++) {
+            const idx = indices[i];
+            const p = segmentPath[idx];
+            const lng = Number(p[0]);
+            const lat = Number(p[1]);
+            if (Number.isNaN(lng) || Number.isNaN(lat)) continue;
+
+            let markerType = 'normal';
+            if (configName !== '干线协调') {
+              if (i === 0) markerType = 'start';
+              else if (i === indices.length - 1) markerType = 'end';
+            }
+
+            const marker = this.createTrafficLightMarker([lng, lat], {
+              ...config,
+              id: `MOCK-${configName.charAt(0)}-${lineIdx}-${segmentIdx}-${idx}`,
+              road: `${configName}路口-${lineIdx}-${segmentIdx}-${idx}`
+            }, markerType);
+            if (marker) overlays.push(marker);
+          }
+        } // end else
+
+      }); // end segments.forEach
 
       return overlays;
     },
@@ -746,6 +847,7 @@ export default {
         const isAbnormal = ["离线", "降级", "故障"].includes(config.name);
         const isRoute = ["干线协调", "勤务路线"].includes(config.name);
         const isStartEnd = type === 'start' || type === 'end';
+        const isPassed = type === 'passed';
 
         // 核心配置映射:减少嵌套逻辑
         const markerStyle = isStartEnd ? {
@@ -776,6 +878,12 @@ export default {
               <img src="${require(`@/assets/map/${type}.png`)}" style="width: 100%; height: auto; object-fit: contain; pointer-events: none;" />
             </div>
           `;
+        } else if (isPassed) {
+          markerContent = `
+            <div class="pure-light-node" style="width: 14px; height: 14px; background: #5A5A5A; border: 1.5px solid rgba(255,255,255,0.25); box-sizing: content-box; display: flex; justify-content: center; align-items: center; color: #888; border-radius: 50%; cursor: pointer; padding: 2px; opacity: 0.55;">
+              <span style="transform: scale(0.8); font-weight: bold; font-size: 12px;">勤</span>
+            </div>
+          `;
         } else if (isAbnormal) {
           const iconName = config.name === '离线' ? 'lixian' : config.name === '降级' ? 'jiangji' : 'guzhang';
           markerContent = `
@@ -1496,4 +1604,15 @@ export default {
 ::v-deep .pure-light-node:not(.abnormal-node) {
   border: 1px solid rgba(255, 255, 255, 0.4);
 }
+
+/* 勤务路线执行进度点脉冲动画 */
+::v-deep .duty-progress-node {
+  animation: duty-pulse 1.6s infinite ease-in-out;
+}
+
+@keyframes duty-pulse {
+  0%   { box-shadow: 0 0 0 3px rgba(188, 48, 29, 0.5); }
+  50%  { box-shadow: 0 0 0 9px rgba(188, 48, 29, 0.08); }
+  100% { box-shadow: 0 0 0 3px rgba(188, 48, 29, 0.5); }
+}
 </style>

+ 15 - 5
src/components/ui/DeviceStatusPie.vue

@@ -132,18 +132,24 @@ export default {
   align-items: center;
   gap: clamp(10px, 2vw, 30px);
   padding: 10px;
+  container-type: size; /* 核心修改:让它成为容器查询的根节点 */
 }
 
 /* 饼图 */
 .tech-pie-chart {
   position: relative;
-  width: auto;
-  height: 100%;
-  aspect-ratio: 1;
+  /* 核心修改:直接计算出一个绝对的正方形尺寸!
+     取“父级宽度的45%”和“父级高度”中更小的那一个作为边长,彻底杜绝拉伸 */
+  width: min(45cqw, 100cqh - 20px);
+  height: min(45cqw, 100cqh - 20px);
+  
   flex-shrink: 0;
   display: flex;
   justify-content: center;
   align-items: center;
+  
+  /* 核心修改:把饼图自己也变成一个容器,为了让里面的文字根据它缩放 */
+  container-type: inline-size;
 }
 
 .chart-ring {
@@ -170,6 +176,7 @@ export default {
   height: 100%;
   transform: rotate(-90deg);
   filter: drop-shadow(0 0 20px rgba(0, 255, 120, 0.2));
+  overflow: visible; /* 核心修改:防止描边在 scale 时被 SVG 盒子裁切 */
 }
 
 .pie-segment {
@@ -178,6 +185,7 @@ export default {
   stroke-linecap: round;
   transition: all 0.5s cubic-bezier(0.3, 0.9, 0.3, 1);
   cursor: pointer;
+  transform-origin: center; /* 核心修改:确保放大是从中心向外扩展 */
 }
 .pie-segment:hover {
   stroke-width: 100;
@@ -205,12 +213,13 @@ export default {
 }
 
 .center-total {
-  font-size: clamp(16px, 3vh, 36px);
+  font-size: clamp(16px, 3cqh, 36px);
   font-weight: 700;
   color: #00ff88;
+  line-height: 1;
 }
 .center-label {
-  font-size: clamp(10px, 1.2vh, 14px);
+  font-size: clamp(10px, 1.2cqh, 14px);
   color: rgba(255, 255, 255, 0.8);
   letter-spacing: 1px;
   margin-top: 3px;
@@ -222,6 +231,7 @@ export default {
   flex-direction: column;
   gap: clamp(6px, 1.5vh, 12px);
   width: 45%;
+  flex-shrink: 0; /* 核心修改:保证右侧内容不被变形挤压 */
 }
 
 .status-item {

+ 35 - 16
src/components/ui/SeamlessScroll.vue

@@ -24,27 +24,36 @@ export default {
   },
   computed: {
     scrollData() {
+      // 当数据量大于 limit 时,开启滚动
       if (this.data && this.data.length > this.limit) {
         this.isScrollable = true;
+        
         const original = this.data.map((item, index) => ({
           ...item,
           _originalIndex: index
         }));
-        const clone = JSON.parse(JSON.stringify(this.data)).map((item, index) => ({
-          ...item,
-          _clone_id: `clone_${Date.now()}_${index}`,
-          _originalIndex: index
-        }));
-        return [...original, ...clone];
+
+        // 【大屏终极修复】强制克隆多份(总共 4 份),保证内容高度绝对碾压任何全屏容器
+        let result = [...original];
+        for (let i = 0; i < 3; i++) {
+          const clone = JSON.parse(JSON.stringify(this.data)).map((item, index) => ({
+            ...item,
+            _clone_id: `clone_${Date.now()}_${i}_${index}`,
+            _originalIndex: index
+          }));
+          result = result.concat(clone);
+        }
+        return result;
       }
+      
       this.isScrollable = false;
       return this.data.map((item, index) => ({ ...item, _originalIndex: index }));
     }
   },
-  // 加入 mounted 钩子,确保 DOM 绝对渲染完毕再测算
   mounted() {
     console.log('✅ SeamlessScroll: 组件已挂载,准备初始化滚动');
     this.initScroll();
+    
     // 全屏切换后容器高度变化,需要重新初始化滚动
     this._onFullscreenChange = () => {
       setTimeout(() => this.initScroll(), 300);
@@ -53,11 +62,12 @@ export default {
   },
   watch: {
     data: {
-      handler() { 
+      handler() {
         console.log('🔄 SeamlessScroll: 监测到数据变化');
-        this.initScroll(); 
+        this.initScroll();
       },
-      deep: true
+      deep: true,
+      immediate: true
     }
   },
   beforeDestroy() {
@@ -73,17 +83,19 @@ export default {
   methods: {
     initScroll() {
       this.pause();
+      
       // 清除上一次未执行完的延时器,防止多个 setTimeout 同时触发 resume
       if (this._initTimer) {
         clearTimeout(this._initTimer);
         this._initTimer = null;
       }
+      
       this.currentTop = 0;
       if (this.$refs.scrollRef) this.$refs.scrollRef.scrollTop = 0;
 
       // 直接判断数据量,不依赖 computed 副作用的时序
       if (!this.data || this.data.length <= this.limit) {
-        console.log('🛑 SeamlessScroll: 数据量不足,无需滚动');
+        console.log('🛑 SeamlessScroll: 数据量不足,无需滚动', this.data, this.limit);
         return;
       }
 
@@ -94,15 +106,16 @@ export default {
           const wrapper = this.$refs.scrollRef;
           if (!wrapper) return;
 
-          const measureEl = wrapper.querySelector(this.measureSelector);
-
-          // 如果容器高度等于或大于内容高度,说明没有溢出,肯定滚不动
+          // 如果 4 份数据加起来都没容器高,说明数据极其短,强制取消滚动避免报错
           if (wrapper.scrollHeight <= wrapper.clientHeight) {
-            console.warn('⚠️ SeamlessScroll 警告: 内容高度没有超出容器高度,滚动被迫终止!请检查外部 CSS 高度限制。');
+            console.warn('⚠️ SeamlessScroll: 数据总高度依然小于容器,取消滚动。');
             return;
           }
 
-          this.resetHeight = measureEl ? measureEl.offsetHeight / 2 : wrapper.scrollHeight / 2;
+          const measureEl = wrapper.querySelector(this.measureSelector);
+
+          // 【核心修复】因为 computed 里总共渲染了 4 份数据,所以总高度要除以 4 才能得到单份的真实复位高度
+          this.resetHeight = measureEl ? measureEl.offsetHeight / 4 : wrapper.scrollHeight / 4;
 
           this.resume();
         }, 100);
@@ -110,8 +123,10 @@ export default {
     },
     resume() {
       if ((!this.data || this.data.length <= this.limit) || this.resetHeight <= 0) return;
+      
       // 防止重复调用产生多个动画循环
       this.pause();
+      
       const step = () => {
         const wrapper = this.$refs.scrollRef;
         if (!wrapper) {
@@ -125,12 +140,16 @@ export default {
 
         // 到达复位点,或者已滚到底部无法继续时,都重置
         const maxScroll = wrapper.scrollHeight - wrapper.clientHeight;
+        
+        // 只要卷去的高度达到了单份数据的真实高度,就瞬间复位,实现无缝循环
         if (wrapper.scrollTop >= this.resetHeight || (maxScroll > 0 && wrapper.scrollTop >= maxScroll)) {
           this.currentTop = 0;
           wrapper.scrollTop = 0;
         }
+        
         this.scrollTimer = requestAnimationFrame(step);
       };
+      
       this.scrollTimer = requestAnimationFrame(step);
     },
     pause() {

+ 13 - 2
src/components/ui/TechTabPane.vue

@@ -1,6 +1,7 @@
 <template>
   <div class="tech-tab-pane" v-show="active">
-    <slot></slot>
+    <div v-if="loading" class="tab-pane-loading">加载中...</div>
+    <slot v-else></slot>
   </div>
 </template>
 
@@ -9,7 +10,8 @@ export default {
   name: 'TechTabPane',
   props: {
     label: { type: String, required: true }, // 头部显示的文字
-    name: { type: [String, Number], required: true } // 唯一标识符
+    name: { type: [String, Number], required: true }, // 唯一标识符
+    loading: { type: Boolean, default: false }
   },
   computed: {
     // 核心:动态判断自己是否被选中
@@ -40,4 +42,13 @@ export default {
   display: flex;
   flex-direction: column;
 }
+
+.tab-pane-loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 120px;
+  color: #758599;
+  font-size: 14px;
+}
 </style>

+ 9 - 18
src/mock/api.js

@@ -154,31 +154,22 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true) {
   // 修改点:将单个图标改为用逗号分隔的"成对图标"字符串
   // 前端组件会按逗号切割并分别放到对角位置
   // ==========================================
-  const iconsUD = [
-    'STRAIGHT_DOWN,STRAIGHT_UP',                 // 南北直行对放
-    'TURN_DOWN_LEFT,TURN_UP_LEFT',               // 南北左转对放
-    'TURN_DOWN_LEFT_UTURN,TURN_UP_LEFT_UTURN'    // 南北左转+掉头对放
-  ]; 
-  const iconsLR = [
-    'STRAIGHT_LEFT,STRAIGHT_RIGHT',              // 东西直行对放
-    'TURN_LEFT_DOWN,TURN_RIGHT_UP',              // 东西左转对放
-    'TURN_LEFT_DOWN_UTURN,TURN_RIGHT_UP_UTURN'   // 东西左转+掉头对放
+  // 固定4个阶段的图标和方向:P1南北直行、P2南北左转、P3东西直行、P4东西左转
+  const phaseConfig = [
+    { icon: 'STRAIGHT_DOWN,STRAIGHT_UP', direction: 'ns' },       // P1: 南北直行
+    { icon: 'TURN_DOWN_LEFT,TURN_UP_LEFT', direction: 'ns' },     // P2: 南北左转
+    { icon: 'STRAIGHT_LEFT,STRAIGHT_RIGHT', direction: 'ew' },    // P3: 东西直行
+    { icon: 'TURN_LEFT_DOWN,TURN_RIGHT_UP', direction: 'ew' },    // P4: 东西左转
   ];
 
-  const getRandomIcon = (pool) => pool[Math.floor(Math.random() * pool.length)];
-
-  let t = 0; 
+  let t = 0;
   for (let i = 0; i < n; i++) {
     const stageStart = t;
     const stageEnd = stageStart + stageTime;
-    const currentIconPool = (i < 2) ? iconsUD : iconsLR;
+    const { icon: stageIcon, direction } = phaseConfig[i];
 
-    // 辅助函数:生成单条轨道的一个阶段
-    // 第8列 [7] 标记方向: 'ns'(南北) 或 'ew'(东西)
-    const direction = (i < 2) ? 'ns' : 'ew';
     const pushTrackData = (trackIdx, phaseNamePrefix) => {
-      // 这里的 icon 现在抽出来的是诸如 "STRAIGHT_DOWN,STRAIGHT_UP" 的字符串
-      const icon = getRandomIcon(currentIconPool);
+      const icon = stageIcon;
       const phaseName = `${phaseNamePrefix}${i + 1}`;
       const g = Math.floor(Math.random() * 11) + 20; // 绿灯 20-30s
       const s = 3; // 闪烁/条纹 3s

+ 8 - 4
src/views/StatusMonitoring.vue

@@ -30,15 +30,15 @@
             <!-- 左侧Tab菜单栏 -->
             <div class="left-sidebar-wrap" v-if="currentView !== 'list-mode'">
                 <TechTabs v-model="activeLeftTab" type="underline" @tab-click="handleTabClick">
-                    <TechTabPane label="总览" name="overview" class="menu-scroll-view">
+                    <TechTabPane label="总览" name="overview" class="menu-scroll-view" :loading="menuData.length === 0">
                         <MenuItem theme="tech" v-for="item in menuData" :key="item.id" :node="item" :level="0"
                             @node-click="handleMenuClick" @folder-click="handleFolderClick"/>
                     </TechTabPane>
-                    <TechTabPane label="路口" name="crossing" class="menu-scroll-view">
+                    <TechTabPane label="路口" name="crossing" class="menu-scroll-view" :loading="menuData.length === 0">
                         <MenuItem theme="tech" v-for="item in menuData" :key="item.id" :node="item" :level="0"
                             @node-click="handleMenuClick" />
                     </TechTabPane>
-                    <TechTabPane label="干线" name="trunkLine" class="menu-scroll-view">
+                    <TechTabPane label="干线" name="trunkLine" class="menu-scroll-view" :loading="trunkLineMenuData.length === 0">
                         <MenuItem v-for="item in trunkLineMenuData" :key="item.id" :node="item" :level="0"
                             @node-click="handleTrunkLineClick">
                         <template #label="{ node }">
@@ -531,7 +531,11 @@ export default {
                         label: '通州区',
                         icon: 'icon-district',
                         isOpen: true,
-                        children: segments
+                        children: (() => {
+                            const list = segments.slice(0, 6);
+                            if (list[5]) list[5] = { ...list[5], label: '张台路与湖亦路路口' };
+                            return list;
+                        })()
                     }]
                 }]
             }];

+ 8 - 4
src/views/TrunkCoordination.vue

@@ -30,15 +30,15 @@
             <!-- 左侧Tab菜单栏 -->
             <div class="left-sidebar-wrap" v-if="currentView !== 'list-mode'">
                 <TechTabs v-model="activeLeftTab" type="underline" @tab-click="handleTabClick">
-                    <TechTabPane label="总览" name="overview" class="menu-scroll-view">
+                    <TechTabPane label="总览" name="overview" class="menu-scroll-view" :loading="menuData.length === 0">
                         <MenuItem theme="tech" v-for="item in menuData" :key="item.id" :node="item" :level="0"
                             @node-click="handleMenuClick" @folder-click="handleFolderClick"/>
                     </TechTabPane>
-                    <TechTabPane label="路口" name="crossing" class="menu-scroll-view">
+                    <TechTabPane label="路口" name="crossing" class="menu-scroll-view" :loading="menuData.length === 0">
                         <MenuItem theme="tech" v-for="item in menuData" :key="item.id" :node="item" :level="0"
                             @node-click="handleMenuClick" />
                     </TechTabPane>
-                    <TechTabPane label="干线" name="trunkLine" class="menu-scroll-view">
+                    <TechTabPane label="干线" name="trunkLine" class="menu-scroll-view" :loading="trunkLineMenuData.length === 0">
                         <MenuItem v-for="item in trunkLineMenuData" :key="item.id" :node="item" :level="0"
                             @node-click="handleTrunkLineClick">
                         <template #label="{ node }">
@@ -508,7 +508,11 @@ export default {
                         label: '通州区',
                         icon: 'icon-district',
                         isOpen: true,
-                        children: segments
+                        children: (() => {
+                            const list = segments.slice(0, 6);
+                            if (list[5]) list[5] = { ...list[5], label: '张台路与湖亦路路口' };
+                            return list;
+                        })()
                     }]
                 }]
             }];