Bläddra i källkod

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

hebotao 2 veckor sedan
förälder
incheckning
515012bc28

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 8536
pnpm-lock.yaml


+ 14 - 0
src/assets/images/svg/icon_straight_down.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="21.000000pt" height="62.000000pt" viewBox="0 0 21.000000 62.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,62.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M88 383 l-3 -238 -37 -3 -37 -3 42 -59 c23 -33 45 -60 49 -60 3 0 25
+26 47 57 l41 58 -35 3 -35 3 0 240 c0 204 -2 239 -15 239 -12 0 -15 -36 -17
+-237z"/>
+</g>
+</svg>

+ 14 - 0
src/assets/images/svg/icon_straight_left.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="61.000000pt" height="19.000000pt" viewBox="0 0 61.000000 19.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,19.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M61 138 l-54 -41 34 -28 c19 -15 46 -35 62 -44 l27 -16 0 35 0 36
+240 0 c207 0 240 2 240 15 0 13 -33 15 -240 15 l-240 0 0 35 c0 19 -3 35 -7
+34 -5 0 -32 -19 -62 -41z"/>
+</g>
+</svg>

+ 14 - 0
src/assets/images/svg/icon_straight_right.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="62.000000pt" height="20.000000pt" viewBox="0 0 62.000000 20.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,20.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M480 156 l0 -36 -240 0 c-207 0 -240 -2 -240 -15 0 -13 33 -15 240
+-15 l240 0 0 -35 c0 -19 2 -35 5 -35 3 0 31 19 62 42 l57 41 -31 26 c-18 14
+-45 34 -62 44 l-31 18 0 -35z"/>
+</g>
+</svg>

+ 14 - 0
src/assets/images/svg/icon_straight_up.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="19.000000pt" height="61.000000pt" viewBox="0 0 19.000000 61.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,61.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M50 545 c-22 -30 -40 -57 -40 -60 0 -3 16 -5 35 -5 l35 0 0 -240 c0
+-207 2 -240 15 -240 13 0 15 33 15 240 l0 240 35 0 c19 0 35 2 35 5 0 8 -80
+115 -85 115 -2 0 -23 -25 -45 -55z"/>
+</g>
+</svg>

+ 15 - 0
src/assets/images/svg/icon_turn_down_left.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="39.000000pt" height="64.000000pt" viewBox="0 0 39.000000 64.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,64.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M10 415 c0 -229 5 -260 50 -300 17 -15 40 -21 101 -24 l79 -3 0 -35
+c0 -19 3 -33 8 -31 20 9 122 82 122 87 0 6 -104 82 -122 89 -4 2 -8 -12 -8
+-32 l0 -36 -56 0 c-57 0 -90 12 -111 39 -9 11 -12 79 -13 239 l0 222 -25 0
+-25 0 0 -215z"/>
+</g>
+</svg>

+ 17 - 0
src/assets/images/svg/icon_turn_down_left_uturn.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="39.000000pt" height="68.000000pt" viewBox="0 0 39.000000 68.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,68.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M10 435 c0 -274 5 -300 62 -335 24 -15 50 -20 101 -20 l67 0 0 -35
+c0 -19 2 -35 5 -35 9 0 125 83 125 89 0 6 -105 82 -122 89 -4 2 -8 -12 -8 -32
+l0 -36 -56 0 c-86 0 -123 27 -124 93 l0 37 51 0 c72 0 117 24 140 74 10 23 19
+53 19 69 0 24 3 27 35 27 l36 0 -32 48 c-70 102 -60 100 -113 24 l-47 -67 36
+-3 c32 -3 35 -6 35 -36 0 -19 -9 -45 -21 -60 -18 -23 -28 -26 -80 -26 l-59 0
+0 190 0 190 -25 0 -25 0 0 -245z"/>
+</g>
+</svg>

+ 15 - 0
src/assets/images/svg/icon_turn_left_down.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="62.000000pt" height="37.000000pt" viewBox="0 0 62.000000 37.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,37.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M155 356 c-56 -25 -70 -50 -74 -137 l-3 -79 -34 0 c-19 0 -34 -2 -34
+-5 0 -9 83 -125 89 -125 6 0 82 105 89 122 2 4 -12 8 -32 8 l-36 0 0 56 c0 57
+12 90 39 111 11 9 79 12 239 13 l222 0 0 25 0 25 -217 0 c-158 -1 -226 -4
+-248 -14z"/>
+</g>
+</svg>

+ 17 - 0
src/assets/images/svg/icon_turn_left_down_uturn.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="68.000000pt" height="37.000000pt" viewBox="0 0 68.000000 37.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,37.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M153 355 c-50 -22 -73 -68 -73 -147 l0 -68 -35 0 c-19 0 -35 -2 -35
+-5 0 -10 83 -125 90 -125 7 0 90 115 90 125 0 3 -16 5 -35 5 l-35 0 0 59 c0
+49 4 65 26 90 21 25 33 31 65 31 l39 0 0 -57 c0 -39 6 -68 19 -89 21 -35 75
+-64 119 -64 28 0 31 -3 34 -35 l3 -35 63 44 c35 25 64 47 65 50 1 3 -27 25
+-63 50 l-65 46 -3 -35 c-3 -32 -6 -35 -36 -35 -19 0 -45 9 -60 21 -23 18 -26
+28 -26 80 l0 59 190 0 190 0 0 25 0 25 -247 0 c-190 -1 -256 -4 -280 -15z"/>
+</g>
+</svg>

+ 15 - 0
src/assets/images/svg/icon_turn_right_up.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="62.000000pt" height="37.000000pt" viewBox="0 0 62.000000 37.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,37.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M475 303 c-52 -75 -52 -73 -11 -73 l36 0 0 -56 c0 -57 -12 -90 -39
+-111 -11 -9 -79 -12 -238 -13 l-223 0 0 -25 0 -25 215 0 c229 0 260 5 300 50
+15 17 21 40 24 101 l3 79 34 0 c19 0 34 2 34 5 0 9 -83 125 -89 125 -4 0 -24
+-26 -46 -57z"/>
+</g>
+</svg>

+ 18 - 0
src/assets/images/svg/icon_turn_right_up_uturn.svg

@@ -0,0 +1,18 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="68.000000pt" height="37.000000pt" viewBox="0 0 68.000000 37.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,37.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M532 300 c-23 -32 -42 -61 -42 -65 0 -3 16 -5 35 -5 l35 0 0 -59 c0
+-49 -4 -65 -26 -90 -21 -25 -33 -31 -65 -31 l-39 0 0 58 c0 38 -6 67 -19 88
+-21 35 -75 64 -119 64 -28 0 -31 3 -34 35 l-3 35 -61 -43 c-33 -23 -60 -46
+-60 -52 0 -5 27 -29 60 -52 l61 -43 3 35 c3 32 6 35 36 35 19 0 45 -9 60 -21
+23 -18 26 -28 26 -80 l0 -59 -190 0 -190 0 0 -25 0 -25 245 0 c274 0 300 5
+335 62 15 24 20 50 20 101 l0 67 35 0 c19 0 35 2 35 5 0 10 -83 125 -90 125
+-3 0 -25 -27 -48 -60z"/>
+</g>
+</svg>

+ 15 - 0
src/assets/images/svg/icon_turn_up_left.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="39.000000pt" height="64.000000pt" viewBox="0 0 39.000000 64.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,64.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M80 580 c-30 -22 -56 -43 -57 -48 -2 -6 96 -81 119 -90 4 -2 8 12 8
+32 l0 36 59 0 c49 0 65 -4 90 -26 l31 -26 0 -224 0 -224 25 0 25 0 0 215 c0
+242 -6 271 -62 305 -24 15 -50 20 -100 20 l-68 0 0 35 c0 19 -3 35 -7 35 -5 0
+-33 -19 -63 -40z"/>
+</g>
+</svg>

+ 17 - 0
src/assets/images/svg/icon_turn_up_left_uturn.svg

@@ -0,0 +1,17 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="39.000000pt" height="68.000000pt" viewBox="0 0 39.000000 68.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,68.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M80 629 c-30 -22 -55 -44 -55 -49 0 -9 108 -90 119 -90 3 0 6 16 6
+35 l0 35 59 0 c49 0 65 -4 90 -26 25 -21 31 -33 31 -65 l0 -39 -57 0 c-39 0
+-68 -6 -89 -19 -35 -21 -64 -75 -64 -119 0 -28 -3 -31 -35 -34 l-36 -3 47 -67
+c53 -76 43 -78 113 25 l32 47 -36 0 c-34 0 -35 1 -35 38 0 58 31 82 103 82
+l57 0 0 -190 0 -190 25 0 25 0 -1 243 c0 270 -5 299 -58 334 -27 18 -48 23
+-102 23 l-69 0 0 35 c0 19 -3 35 -7 35 -5 -1 -33 -19 -63 -41z"/>
+</g>
+</svg>

+ 60 - 58
src/components/TongzhouTrafficMap.vue

@@ -318,13 +318,7 @@ export default {
           { start: [116.6445, 39.9075], end: [116.6846, 39.9075], color: "#13C373" }
         ],
         "勤务路线": [
-          { start: [116.6900, 39.9225], end: [116.6900, 39.8971], color: "#BC301D" },
-          { start: [116.6965, 39.9225], end: [116.6965, 39.8971], color: "#BC301D" },
-          { start: [116.7021, 39.9225], end: [116.7021, 39.8971], color: "#BC301D" },
-          { start: [116.7120, 39.9225], end: [116.7120, 39.8971], color: "#BC301D" },
-          { start: [116.7234, 39.9225], end: [116.7234, 39.8971], color: "#BC301D" },
-          { start: [116.7290, 39.9225], end: [116.7290, 39.8971], color: "#BC301D" },
-          { start: [116.7350, 39.9170], end: [116.7350, 39.9000], color: "#BC301D" }
+          { start: [116.7120, 39.9225], end: [116.7120, 39.8971], color: "#BC301D" }
         ]
       };
 
@@ -371,13 +365,18 @@ export default {
 
           // 统计干线协调实际生成的线段数
           if (config.name === '干线协调' && overlays.length > 0) {
+            const trunkNames = [
+              '古城南路与古城大街', '古城西路东口南一过街', '古城大街与古城北路',
+              '八角北路与八角东街', '古城西路与古城大街'
+            ];
             const polylineCount = overlays.filter(o => o instanceof this.AMap.Polyline).length;
             for (let s = 0; s < polylineCount; s++) {
               const idx = trunkSegments.length + 1;
+              const name = trunkNames[idx - 1] || ('干线' + idx);
               trunkSegments.push({
                 id: 'trunk_' + idx,
-                label: '干线' + idx,
-                intersections: Array.from({ length: 6 }, (_, k) => '干线' + idx + '_路口' + (k + 1)),
+                label: name,
+                intersections: Array.from({ length: 6 }, (_, k) => name + '_路口' + (k + 1)),
                 distances: Array.from({ length: 6 }, (_, k) => k * 1000),
                 _lineIdx: lineIdx,
                 _segmentIdx: s
@@ -484,10 +483,9 @@ export default {
         basePath = this.buildFallbackLinePath(line.start, line.end, 30);
       }
 
-      // --- 核心优化:物理分散偏移 (Dispersal Offset) ---
-      // 根据 lineIdx 为每条路径应用微小偏移,避免多条线路完全重合
-      // 0.00015 度约等于 15 米,(lineIdx - 3) 将多条线在中心点两侧排开
-      const offsetVal = (Number(lineIdx) - 3) * 0.00015;
+      // --- 核心优化:确保线路准确贴合真实道路 ---
+      // 取消原本的微小物理解散偏移,以保证所有线路(包含但不限单条调整后)严格绘制在导航真实道路上
+      const offsetVal = 0;
       const applyOffset = (p) => {
         const lng = p.lng || (p.getLng ? p.getLng() : (Array.isArray(p) ? Number(p[0]) : 0));
         const lat = p.lat || (p.getLat ? p.getLat() : (Array.isArray(p) ? Number(p[1]) : 0));
@@ -525,49 +523,51 @@ export default {
         }
 
         // 2. 设定标准间距:约每 0.0018 度 (约 200 米) 放置一个箭头
-        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;
+        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;
+              }
             }
-          }
 
-          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 midLng = lng1 + (lng2 - lng1) * ratio;
-            const midLat = lat1 + (lat2 - lat1) * 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);
+            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 midLng = lng1 + (lng2 - lng1) * ratio;
+              const midLat = lat1 + (lat2 - lat1) * 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;
           }
-          currentTargetDist += targetSpacing;
         }
 
         // 为圆点保留原来的分布 logic (不受箭头影响)
@@ -581,12 +581,14 @@ export default {
           const lat = Number(p[1]);
           if (Number.isNaN(lng) || Number.isNaN(lat)) continue;
 
-          // 确定圆点类型:第一个为start,最后一个为end,其余为normal
+          // 确定圆点类型:勤务路线保留起终点,干线协调移除起终点图标改为普通节点
           let markerType = 'normal';
-          if (i === 0) {
-            markerType = 'start';
-          } else if (i === indices.length - 1) {
-            markerType = 'end';
+          if (configName !== '干线协调') {
+            if (i === 0) {
+              markerType = 'start';
+            } else if (i === indices.length - 1) {
+              markerType = 'end';
+            }
           }
 
           const marker = this.createTrafficLightMarker([lng, lat], {

+ 106 - 3
src/components/ui/CrossingDetailPanel.vue

@@ -12,7 +12,7 @@
                     </div>
                 </div>
 
-                <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady" />
+                <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
             </div>
         </div>
 
@@ -67,6 +67,31 @@
                                 </div>
                             </div>
 
+                            <!-- 方案圆饼图 -->
+                            <div class="donut-row" v-if="!showLockTime">
+                                <div class="donut-item">
+                                    <div class="donut-title">实时方案(执行方案3)</div>
+                                    <PlanDonutChart
+                                        :chartData="realtimeDonutData"
+                                        centerValue="94"
+                                        centerLabel="剩余时长"
+                                        :showTotal="true"
+                                        :totalValue="180"
+                                        :scale="panelScale"
+                                    />
+                                </div>
+                                <div class="donut-item">
+                                    <div class="donut-title">下周期方案</div>
+                                    <PlanDonutChart
+                                        :chartData="nextCycleDonutData"
+                                        centerValue="98"
+                                        centerLabel="总时长"
+                                        :showTotal="false"
+                                        :scale="panelScale"
+                                    />
+                                </div>
+                            </div>
+
                             <transition name="fade">
                                 <div class="lock-time" v-if="showLockTime">
                                     <div class="lock-time-label-wrap glow-header">
@@ -111,6 +136,7 @@ import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
 import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
 import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
 import DropdownSelect from '@/components/ui/DropdownSelect.vue';
+import PlanDonutChart from '@/components/ui/PlanDonutChart.vue';
 
 import { apiGetCrossingDetailData } from '@/api';
 
@@ -120,7 +146,8 @@ export default {
         SignalTimingChart,
         IntersectionMapVideos,
         SegmentedRadio,
-        DropdownSelect
+        DropdownSelect,
+        PlanDonutChart
     },
     props: {
         preloadedData: { type: Object, default: null }
@@ -141,12 +168,26 @@ export default {
             phaseDiff: 0,
             coordTime: 0,
             mockPhaseData: [],
-            
+            panelScale: 1,
+
             // 控制方式数据
             controlMethodOptions: [],
             currentMethod: 'temp',
             currentScheme: 'early_peak',
             schemeOptions: [],
+            // 实时方案圆饼图数据
+            realtimeDonutData: [
+                { label: '已走时长', value: 86, color: '#8892a0' },
+                { label: '1-西单面', value: 0, color: '#3b82f6' },
+                { label: '2-东西直行', value: 43, color: '#a855f7' },
+                { label: '3-北左转', value: 51, color: '#14b8a6' }
+            ],
+            // 下周期方案圆饼图数据
+            nextCycleDonutData: [
+                { label: '1-西单面', value: 23, color: '#3b82f6' },
+                { label: '2-东西直行', value: 51, color: '#a855f7' },
+                { label: '3-北左转', value: 24, color: '#14b8a6' }
+            ],
             currentLocktime: 50,
             locktimeOptions: [],
             currentStage: '1', 
@@ -180,11 +221,57 @@ export default {
         if (this._ro) this._ro.disconnect();
     },
     methods: {
+        onScanTick(activeTime) {
+            if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
+            // 只看第一轨道(trackIdx=0)的相位
+            const phase = this.mockPhaseData.find(p => p[0] === 0 && activeTime >= p[1] && activeTime < p[2]);
+            if (!phase) return;
+
+            const type = phase[5];       // green/stripe/yellow/red
+            const iconValue = phase[6];  // 如 "STRAIGHT_DOWN,STRAIGHT_UP"
+            const direction = phase[7];  // ns/ew
+            const phaseName = phase[3];  // P1/P2 等
+            const endTime = phase[2];
+            const remaining = Math.max(0, Math.round(endTime - activeTime));
+
+            const nsGreen = (type === 'green' && direction === 'ns');
+            const ewGreen = (type === 'green' && direction === 'ew');
+
+            // 从图标值解析当前允许的行驶方向类型
+            // STRAIGHT→S, TURN_*_LEFT→L, *_UTURN→U
+            let activeArrowTypes = [];
+            if ((nsGreen || ewGreen) && iconValue) {
+                const icons = iconValue.split(',');
+                icons.forEach(ic => {
+                    if (ic.includes('UTURN')) activeArrowTypes.push('U');
+                    if (ic.includes('TURN') && !ic.includes('UTURN')) activeArrowTypes.push('L');
+                    if (ic.includes('STRAIGHT')) activeArrowTypes.push('S');
+                });
+                // 去重
+                activeArrowTypes = [...new Set(activeArrowTypes)];
+            }
+
+            this.$set(this.intersectionData, 'signals', {
+                ns: {
+                    phaseName: nsGreen ? (phaseName || '南北') : (this.intersectionData.signals?.ns?.phaseName || '南北'),
+                    time: remaining,
+                    isGreen: nsGreen,
+                    activeArrowTypes: nsGreen ? activeArrowTypes : []
+                },
+                ew: {
+                    phaseName: ewGreen ? (phaseName || '东西') : (this.intersectionData.signals?.ew?.phaseName || '东西'),
+                    time: remaining,
+                    isGreen: ewGreen,
+                    activeArrowTypes: ewGreen ? activeArrowTypes : []
+                }
+            });
+        },
         initScaleObserver() {
             const ro = new ResizeObserver(entries => {
                 const { width } = entries[0].contentRect;
                 const s = Math.min(width / 1315, 1);
                 this.$el.style.setProperty('--s', s);
+                this.panelScale = s;
             });
             ro.observe(this.$el);
             this._ro = ro;
@@ -718,4 +805,20 @@ export default {
     gap: clamp(3px, calc(var(--s) * 6px), 8px);
     cursor: pointer;
 }
+
+/* ===== 方案圆饼图左右布局 ===== */
+.donut-row {
+    display: flex;
+    gap: clamp(4px, calc(var(--s) * 16px), 16px);
+    width: 100%;
+}
+.donut-item {
+    flex: 1;
+    min-width: 0;
+}
+.donut-title {
+    font-size: clamp(11px, calc(var(--s) * 13px), 14px);
+    color: #a0aec0;
+    margin-bottom: 4px;
+}
 </style>

+ 16 - 2
src/components/ui/CrossingListPanel.vue

@@ -14,7 +14,8 @@
 
             <TechTable :columns="tableColumns" :data="tableList" height="100%">
                 <template #phaseStatus="{ row }">
-                    <div class="mini-chart-wrapper">
+                    <div v-if="row._offline" class="offline-placeholder">--</div>
+                    <div v-else class="mini-chart-wrapper">
                         <SignalTimingChart :phaseData="row.phaseData" :cycleLength="row.cycle" :currentTime="row.currentTime || 0" :showAxis="false" :showScanLine="true" :showScanLineLabel="false" :autoScan="true" />
                     </div>
                 </template>
@@ -152,7 +153,14 @@ export default {
                 console.log('发起请求,参数:', params);
 
                 const data = await apiGetCrossingList(params);
-                this.tableList = data?.list || data || [];
+                const list = data?.list || data || [];
+                // 离线设备:时间偏差、周期、版本信息显示"--"
+                this.tableList = list.map(row => {
+                    if (row.status === '离线') {
+                        return { ...row, timeOffset: '--', cycle: '--', version: '--', _offline: true };
+                    }
+                    return { ...row, _offline: false };
+                });
                 this.pagination.total = data?.total || 0;
             } catch (error) {
                 console.error('获取列表失败', error);
@@ -330,6 +338,12 @@ export default {
 
 /* ================= 针对插槽内容的样式 ================= */
 
+/* 离线设备占位符 */
+.offline-placeholder {
+    color: rgba(255, 255, 255, 0.3);
+    text-align: center;
+}
+
 /* 控制相位图表在表格内的高度 */
 .mini-chart-wrapper {
     height: 30px;

+ 1 - 1
src/components/ui/CrossingMultiView.vue

@@ -139,7 +139,7 @@ export default {
                 if (s.data && s.headerData) oldMap[s.data.id] = s.headerData;
             });
             this.localSlots = this.crossings.map(c => ({
-                type: 'panel', data: c, headerData: oldMap[c.id] || null
+                type: 'panel', data: c, headerData: oldMap[c.id] || c._preloadedData || null
             }));
             // 如果展开的路口被外部移除了,退出展开
             if (this.expandedId) {

+ 1 - 1
src/components/ui/DeviceStatusDonutChart.vue

@@ -218,7 +218,7 @@ export default {
 }
 
 .main-number {
-    color: #8392b4; /* 图3中数字4的灰色调 */
+    color: #8392b4;
     font-weight: bold;
     font-family: Arial, sans-serif;
     line-height: 1;

+ 32 - 7
src/components/ui/DeviceStatusPie.vue

@@ -4,7 +4,7 @@
     <div class="tech-pie-chart">
       <div class="chart-ring"></div>
       <svg class="pie-svg" viewBox="0 0 500 500">
-        <circle class="pie-segment" v-for="(item, index) in chartData" :key="index"
+        <circle class="pie-segment" v-for="(item, index) in pieData" :key="index"
           ref="segments"
           cx="250" cy="250" r="210"
         />
@@ -13,11 +13,15 @@
         <div class="center-total">{{ centerTitle || total }}</div>
         <div class="center-label">{{ centerSubTitle || '设备总数' }}</div>
       </div>
+      <div class="chart-center" v-else-if="activeFaults.length === 0">
+        <div class="center-total">0</div>
+        <div class="center-label">故障</div>
+      </div>
     </div>
 
     <!-- 右侧数据面板 -->
     <div class="status-panel">
-      <div class="status-item" v-for="(item, index) in chartData" :key="index">
+      <div class="status-item" v-for="(item, index) in panelData" :key="index">
         <div class="status-dot" :style="{ background: item.color }"></div>
         <div class="status-text">{{ item.name }}</div>
         <div class="status-num" :style="{ color: item.color }">{{ item.value }}</div>
@@ -57,6 +61,28 @@ export default {
   computed: {
     total() {
       return this.chartData.reduce((sum, item) => sum + Number(item.value), 0);
+    },
+    // 过滤出故障项(名称不含"正常"且值大于0)
+    activeFaults() {
+      return this.chartData.filter(item => item.name.indexOf('正常') === -1 && Number(item.value) > 0);
+    },
+    // 故障总数
+    totalFaults() {
+      return this.activeFaults.reduce((sum, item) => sum + Number(item.value), 0);
+    },
+    // 饼图实际渲染的数据
+    pieData() {
+      if (this.activeFaults.length > 0) {
+        return this.activeFaults;
+      }
+      // 无故障时,用正常项的颜色画整圆
+      const normalItem = this.chartData.find(item => item.name.indexOf('正常') !== -1);
+      return [{ name: '正常', value: 1, color: normalItem ? normalItem.color : '#A0E551' }];
+    },
+    // 右侧面板:有故障显示故障项,无故障只显示正常项
+    panelData() {
+      if (this.activeFaults.length > 0) return this.activeFaults;
+      return this.chartData.filter(item => item.name.indexOf('正常') !== -1);
     }
   },
   watch: {
@@ -76,10 +102,11 @@ export default {
       const segments = this.$refs.segments;
       if (!segments || !segments.length) return;
 
-      const total = this.total || 1;
+      const data = this.pieData;
+      const total = data.reduce((sum, item) => sum + Number(item.value), 0) || 1;
       let currentOffset = 0;
 
-      this.chartData.forEach((item, index) => {
+      data.forEach((item, index) => {
         const segment = segments[index];
         if (!segment) return;
         const percent = item.value / total;
@@ -180,9 +207,7 @@ export default {
 .center-total {
   font-size: clamp(16px, 3vh, 36px);
   font-weight: 700;
-  background: linear-gradient(90deg, #00ff88, #ff2255);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
+  color: #00ff88;
 }
 .center-label {
   font-size: clamp(10px, 1.2vh, 14px);

+ 2 - 2
src/components/ui/DeviceStatusTabs.vue

@@ -25,8 +25,8 @@ const defaultMockStatusData = {
     centerTitle: '98%',
     centerSubTitle: '980/1000',
     chartData: [
-        { name: '正常', value: 1, color: '#A0E551' }, 
-        { name: '故障', value: 0, color: '#D03030' } 
+        { name: '正常', value: 980, color: '#A0E551' },
+        { name: '故障', value: 0, color: '#D03030' }
     ]
   },
   'detectorStatus': {

+ 252 - 0
src/components/ui/EvaluationTrafficMap.vue

@@ -0,0 +1,252 @@
+<template>
+  <div class="map-wrapper">
+    <div ref="mapContainer" class="map-container"></div>
+  </div>
+</template>
+
+<script>
+import AMapLoader from '@amap/amap-jsapi-loader';
+
+export default {
+  name: "EvaluationTrafficMap",
+  props: {
+    amapKey: { type: String, required: true },
+    securityJsCode: { type: String, required: true },
+    mode: { 
+      type: String, 
+      default: 'overview',
+      validator: (val) => ['overview', 'singlePoint', 'trunkLine', 'area'].includes(val)
+    }
+  },
+  data() {
+    return {
+      AMap: null,
+      map: null,
+      overlays: [], // 统一管理当前地图上的所有覆盖物
+    };
+  },
+  watch: {
+    // 监听模式变化,重新绘制地图元素
+    mode(newMode) {
+      this.updateMapDisplay();
+    }
+  },
+  mounted() {
+    this.initAMap();
+  },
+  beforeDestroy() {
+    if (this.map) {
+      this.map.destroy();
+      this.map = null;
+    }
+  },
+  methods: {
+    async initAMap() {
+      window._AMapSecurityConfig = { securityJsCode: this.securityJsCode };
+      try {
+        const AMap = await AMapLoader.load({
+          key: this.amapKey,
+          version: "2.0",
+        });
+        this.AMap = AMap;
+        this.map = new AMap.Map(this.$refs.mapContainer, {
+          zoom: 13,
+          mapStyle: "amap://styles/darkblue",
+          center: [116.66, 39.91], // 通州区
+        });
+
+        this.map.on('complete', () => {
+          this.updateMapDisplay();
+        });
+      } catch (err) {
+        console.error('地图加载失败:', err);
+      }
+    },
+
+    clearOverlays() {
+      if (this.map && this.overlays.length > 0) {
+        this.map.remove(this.overlays);
+        this.overlays = [];
+      }
+    },
+
+    updateMapDisplay() {
+      if (!this.map || !this.AMap) return;
+      this.clearOverlays();
+
+      switch (this.mode) {
+        case 'overview':
+          this.drawOverview();
+          break;
+        case 'singlePoint':
+          this.drawSinglePoint();
+          break;
+        case 'trunkLine':
+          this.drawTrunkLine();
+          break;
+        case 'area':
+          this.drawArea();
+          break;
+      }
+    },
+
+    // 1. 绘制总览:简单的红黄绿圆点
+    drawOverview() {
+      const mockData = [
+        { pos: [116.65, 39.90], color: '#00FF00' }, { pos: [116.66, 39.91], color: '#FF0000' },
+        { pos: [116.67, 39.89], color: '#FFA500' }, { pos: [116.69, 39.92], color: '#00FF00' },
+        { pos: [116.64, 39.93], color: '#FF0000' }, { pos: [116.70, 39.90], color: '#FFA500' },
+        { pos: [116.68, 39.91], color: '#00FF00' }, { pos: [116.66, 39.93], color: '#FF0000' },
+      ];
+
+      mockData.forEach(item => {
+        const marker = new this.AMap.Marker({
+          position: item.pos,
+          content: `<div style="width: 12px; height: 12px; background-color: ${item.color}; border-radius: 50%; box-shadow: 0 0 5px ${item.color}; border: 1px solid #fff;"></div>`,
+          offset: new this.AMap.Pixel(-6, -6),
+        });
+        this.overlays.push(marker);
+      });
+      this.map.add(this.overlays);
+    },
+
+    // 2. 绘制评价监测-单点:带 A-F 字母和对应颜色的点
+    drawSinglePoint() {
+      const colors = { A: '#8fc31f', B: '#d7df23', C: '#fff200', D: '#f39c12', E: '#e74c3c', F: '#c0392b' };
+      const mockData = [
+        { pos: [116.63, 39.90], level: 'A' }, { pos: [116.65, 39.91], level: 'B' },
+        { pos: [116.66, 39.89], level: 'C' }, { pos: [116.68, 39.88], level: 'D' },
+        { pos: [116.70, 39.91], level: 'E' }, { pos: [116.71, 39.90], level: 'F' },
+        { pos: [116.67, 39.90], level: 'B' }, { pos: [116.69, 39.89], level: 'C' },
+        { pos: [116.65, 39.88], level: 'A' }, { pos: [116.69, 39.87], level: 'D' },
+      ];
+
+      mockData.forEach(item => {
+        const marker = new this.AMap.Marker({
+          position: item.pos,
+          content: `
+            <div style="width: 20px; height: 20px; background-color: ${colors[item.level]}; border-radius: 50%; display: flex; justify-content: center; align-items: center; color: #fff; font-weight: bold; font-size: 12px; border: 2px solid rgba(255,255,255,0.5);">
+              ${item.level}
+            </div>
+          `,
+          offset: new this.AMap.Pixel(-10, -10),
+        });
+        this.overlays.push(marker);
+      });
+      this.map.add(this.overlays);
+    },
+
+    // 3. 绘制评价监测-干线:绿色波段线段
+    drawTrunkLine() {
+      const lines = [
+        [[116.65, 39.91], [116.69, 39.92]],
+        [[116.64, 39.90], [116.70, 39.91]],
+        [[116.65, 39.89], [116.70, 39.89]],
+        [[116.66, 39.88], [116.68, 39.85]],
+      ];
+
+      lines.forEach(path => {
+        // 画线
+        const polyline = new this.AMap.Polyline({
+          path: path,
+          isOutline: true,
+          outlineColor: '#fff',
+          borderWeight: 1,
+          strokeColor: "#a6ce39", 
+          strokeOpacity: 0.9,
+          strokeWeight: 5,
+        });
+        this.overlays.push(polyline);
+
+        // 在线段两端或中间画点作为路口标识
+        path.forEach(pos => {
+          const marker = new this.AMap.Marker({
+            position: pos,
+            content: `<div style="width: 10px; height: 10px; background-color: #fff; border-radius: 50%; border: 2px solid #a6ce39;"></div>`,
+            offset: new this.AMap.Pixel(-5, -5),
+          });
+          this.overlays.push(marker);
+        });
+      });
+      this.map.add(this.overlays);
+    },
+
+    // 4. 绘制评价监测-区域:半透明多边形 + 中心文字
+    drawArea() {
+      const areas = [
+        {
+          path: [[116.67, 39.94], [116.70, 39.94], [116.71, 39.92], [116.70, 39.91], [116.67, 39.91]],
+          color: '#d35400', label: '6.2', center: [116.69, 39.925]
+        },
+        {
+          path: [[116.68, 39.90], [116.72, 39.90], [116.72, 39.88], [116.68, 39.88]],
+          color: '#c0392b', label: '4.5', center: [116.70, 39.89]
+        },
+        {
+          path: [[116.67, 39.87], [116.71, 39.87], [116.71, 39.85], [116.67, 39.85]],
+          color: '#c0392b', label: '4.8', center: [116.69, 39.86]
+        }
+      ];
+
+      areas.forEach(area => {
+        // 绘制多边形
+        const polygon = new this.AMap.Polygon({
+          path: area.path,
+          fillColor: area.color,
+          fillOpacity: 0.4,
+          strokeColor: area.color,
+          strokeWeight: 2,
+        });
+        this.overlays.push(polygon);
+
+        // 绘制中心数字标识
+        const textMarker = new this.AMap.Marker({
+          position: area.center,
+          content: `<div style="color: rgba(255,255,255,0.8); font-size: 24px; font-weight: bold; text-shadow: 1px 1px 2px #000;">${area.label}</div>`,
+          offset: new this.AMap.Pixel(-15, -15),
+        });
+        this.overlays.push(textMarker);
+      });
+
+      // 画一个圆形的区域 (对应图中的绿色 9.1)
+      const circle = new this.AMap.Circle({
+        center: [116.64, 39.88],
+        radius: 1800,
+        fillColor: '#27ae60',
+        fillOpacity: 0.4,
+        strokeColor: '#27ae60',
+        strokeWeight: 2,
+      });
+      this.overlays.push(circle);
+
+      const circleText = new this.AMap.Marker({
+        position: [116.64, 39.88],
+        content: `<div style="color: rgba(255,255,255,0.8); font-size: 24px; font-weight: bold; text-shadow: 1px 1px 2px #000;">9.1</div>`,
+        offset: new this.AMap.Pixel(-15, -15),
+      });
+      this.overlays.push(circleText);
+
+      this.map.add(this.overlays);
+    }
+  }
+};
+</script>
+
+<style scoped>
+.map-wrapper {
+  width: 100%;
+  height: 100vh;
+  position: relative;
+  background: #010813;
+}
+.map-container {
+  width: 100%;
+  height: 100%;
+}
+/* 隐藏高德Logo等 */
+::v-deep .amap-logo,
+::v-deep .amap-copyright,
+::v-deep .amap-copyright-logo {
+  display: none !important;
+}
+</style>

+ 106 - 21
src/components/ui/IntersectionMap.vue

@@ -7,6 +7,43 @@
 <script>
 import Konva from 'konva';
 
+const arrowSvgMap = {
+  S: require('@/assets/images/svg/icon_straight_down.svg'),
+  L: require('@/assets/images/svg/icon_turn_down_left.svg'),
+  U: require('@/assets/images/svg/icon_turn_down_left_uturn.svg'),
+  R: require('@/assets/images/svg/icon_turn_down_left.svg'),
+};
+
+const imgCache = {};
+function loadSvgImage(svgUrl, fillColor) {
+  const cacheKey = svgUrl + '|' + fillColor;
+  if (imgCache[cacheKey]) return Promise.resolve(imgCache[cacheKey]);
+  if (svgUrl.startsWith('data:image/svg+xml;base64,')) {
+    const base64 = svgUrl.split(',')[1];
+    let svgText = atob(base64);
+    svgText = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
+    const encoded = btoa(svgText);
+    const dataUrl = 'data:image/svg+xml;base64,' + encoded;
+    return new Promise(resolve => {
+      const img = new Image();
+      img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
+      img.src = dataUrl;
+    });
+  }
+  return fetch(svgUrl)
+    .then(r => r.text())
+    .then(svgText => {
+      const colored = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
+      const blob = new Blob([colored], { type: 'image/svg+xml' });
+      const url = URL.createObjectURL(blob);
+      return new Promise(resolve => {
+        const img = new Image();
+        img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
+        img.src = url;
+      });
+    });
+}
+
 export default {
   name: 'IntersectionMap',
   props: {
@@ -176,14 +213,30 @@ export default {
     },
 
     createArrowIcon(type, x, y, color = this.C.WHITE) {
-      const group = new Konva.Group({ x, y, scaleX: 0.65, scaleY: 0.65 });
-      group.add(new Konva.Circle({ x: 0, y: -35, radius: 3, fill: color, name: 'colorFill' }));
-      let pathData = '';
-      if (type === 'S') pathData = 'M 0 -35 L 0 0 M -7 -10 L 0 0 L 7 -10';
-      else if (type === 'L') pathData = 'M 0 -35 L 0 -15 Q 0 0 15 0 M 5 -7 L 15 0 L 5 7';
-      else if (type === 'R') pathData = 'M 0 -35 L 0 -15 Q 0 0 -15 0 M -5 -7 L -15 0 L -5 7';
-      else if (type === 'U') pathData = 'M 0 -35 L 0 -15 Q 0 0 14 0 Q 28 0 28 -15 L 28 -25 M 21 -18 L 28 -25 L 35 -18';
-      group.add(new Konva.Path({ data: pathData, stroke: color, strokeWidth: 3, lineCap: 'round', lineJoin: 'round', name: 'colorStroke' }));
+      const maxH = 40;
+      const group = new Konva.Group({ x, y });
+      if (type === 'R') group.scaleX(-1);
+      group._arrowMeta = { type };
+
+      const svgUrl = arrowSvgMap[type];
+      if (svgUrl) {
+        loadSvgImage(svgUrl, color).then(imgObj => {
+          const natW = imgObj.naturalWidth || imgObj.width;
+          const natH = imgObj.naturalHeight || imgObj.height;
+          const scale = Math.min(maxH / natH, 1);
+          const w = Math.round(natW * scale);
+          const h = Math.round(natH * scale);
+          group.add(new Konva.Image({
+            image: imgObj,
+            x: -w / 2,
+            y: -h - 5,
+            width: w,
+            height: h,
+            name: 'arrowImg'
+          }));
+          if (this.layer) this.layer.draw();
+        });
+      }
       return group;
     },
 
@@ -236,29 +289,61 @@ export default {
     updateDynamicSignals() {
       const signals = this.mapData.signals;
       if (!signals) return;
+      const config = this.mapData.armsConfig || {};
 
       const nsColor = signals.ns.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
       const ewColor = signals.ew.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+      const nsActiveTypes = signals.ns.activeArrowTypes || [];
+      const ewActiveTypes = signals.ew.activeArrowTypes || [];
 
-      const dyeArm = (armNode, color) => {
-        armNode.lightGroup.getChildren().forEach(r => r.fill(color));
-        const arrowColor = (color === this.C.SIGNAL_GREEN) ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN;
-        Object.values(armNode.arrowNodes).forEach(arr => {
-          if (arr) {
-            arr.findOne('.colorFill').fill(arrowColor);
-            arr.findOne('.colorStroke').stroke(arrowColor);
-          }
+      const dyeArm = (dir, armNode, signalColor, activeTypes) => {
+        armNode.lightGroup.getChildren().forEach(r => r.fill(signalColor));
+        const lanes = (config[dir] && config[dir].lanes) || [];
+        Object.keys(armNode.arrowNodes).forEach(index => {
+          const arr = armNode.arrowNodes[index];
+          if (!arr) return;
+          const laneType = lanes[index];
+          const isActive = activeTypes.length > 0 && activeTypes.includes(laneType);
+          const targetColor = isActive ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+
+          const meta = arr._arrowMeta;
+          if (!meta) return;
+          const svgUrl = arrowSvgMap[meta.type];
+          if (!svgUrl) return;
+
+          loadSvgImage(svgUrl, targetColor).then(imgObj => {
+            const existing = arr.findOne('.arrowImg');
+            if (existing) {
+              existing.image(imgObj);
+            } else {
+              const maxH = 40;
+              const natW = imgObj.naturalWidth || imgObj.width;
+              const natH = imgObj.naturalHeight || imgObj.height;
+              const scale = Math.min(maxH / natH, 1);
+              const w = Math.round(natW * scale);
+              const h = Math.round(natH * scale);
+              arr.add(new Konva.Image({
+                image: imgObj,
+                x: -w / 2,
+                y: -h - 5,
+                width: w,
+                height: h,
+                name: 'arrowImg'
+              }));
+            }
+            if (this.layer) this.layer.draw();
+          });
         });
       };
 
-      dyeArm(this.armsNodes.N, nsColor);
-      dyeArm(this.armsNodes.S, nsColor);
-      dyeArm(this.armsNodes.E, ewColor);
-      dyeArm(this.armsNodes.W, ewColor);
+      dyeArm('N', this.armsNodes.N, nsColor, nsActiveTypes);
+      dyeArm('S', this.armsNodes.S, nsColor, nsActiveTypes);
+      dyeArm('E', this.armsNodes.E, ewColor, ewActiveTypes);
+      dyeArm('W', this.armsNodes.W, ewColor, ewActiveTypes);
 
       this.panelNodes.nsLabel.text(`${signals.ns.phaseName}:`);
       this.panelNodes.nsVal.text(signals.ns.time.toString().padStart(2, '0')).fill(nsColor);
-      
+
       this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
       this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
 

+ 128 - 22
src/components/ui/IntersectionMapVideos.vue

@@ -56,6 +56,56 @@
 import Konva from 'konva';
 import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 
+// 方向箭头 SVG 路径映射
+// 所有方向臂共用同一套图标(以N方向/向下驶入为基准),由 createRoadArm 的 rotation 自动旋转
+// R(右转)用左转图标水平翻转
+const arrowSvgMap = {
+  S: require('@/assets/images/svg/icon_straight_down.svg'),
+  L: require('@/assets/images/svg/icon_turn_down_left.svg'),
+  U: require('@/assets/images/svg/icon_turn_down_left_uturn.svg'),
+  R: require('@/assets/images/svg/icon_turn_down_left.svg'),  // 右转用左转图标,渲染时水平翻转
+};
+
+// SVG 原始文本映射(内联,避免 webpack loader 问题)
+const svgRawCache = {};
+
+// 首次加载时通过 Image → Canvas 获取不到 SVG 内容,所以直接用内联方式
+// 从 require 得到的 URL(可能是 base64 或路径)加载图片
+const imgCache = {};
+
+function loadSvgImage(svgUrl, fillColor) {
+  const cacheKey = svgUrl + '|' + fillColor;
+  if (imgCache[cacheKey]) return Promise.resolve(imgCache[cacheKey]);
+
+  // 如果是 base64 data URL,解码后替换颜色
+  if (svgUrl.startsWith('data:image/svg+xml;base64,')) {
+    const base64 = svgUrl.split(',')[1];
+    let svgText = atob(base64);
+    svgText = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
+    const encoded = btoa(svgText);
+    const dataUrl = 'data:image/svg+xml;base64,' + encoded;
+    return new Promise(resolve => {
+      const img = new Image();
+      img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
+      img.src = dataUrl;
+    });
+  }
+
+  // 普通 URL,fetch 后替换颜色
+  return fetch(svgUrl)
+    .then(r => r.text())
+    .then(svgText => {
+      const colored = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
+      const blob = new Blob([colored], { type: 'image/svg+xml' });
+      const url = URL.createObjectURL(blob);
+      return new Promise(resolve => {
+        const img = new Image();
+        img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
+        img.src = url;
+      });
+    });
+}
+
 export default {
   name: 'IntersectionMapVideos',
   components: {
@@ -246,14 +296,32 @@ export default {
     },
 
     createArrowIcon(type, x, y, color = this.C.WHITE) {
-      const group = new Konva.Group({ x, y, scaleX: 0.65, scaleY: 0.65 });
-      group.add(new Konva.Circle({ x: 0, y: -35, radius: 3, fill: color, name: 'colorFill' }));
-      let pathData = '';
-      if (type === 'S') pathData = 'M 0 -35 L 0 0 M -7 -10 L 0 0 L 7 -10';
-      else if (type === 'L') pathData = 'M 0 -35 L 0 -15 Q 0 0 15 0 M 5 -7 L 15 0 L 5 7';
-      else if (type === 'R') pathData = 'M 0 -35 L 0 -15 Q 0 0 -15 0 M -5 -7 L -15 0 L -5 7';
-      else if (type === 'U') pathData = 'M 0 -35 L 0 -15 Q 0 0 14 0 Q 28 0 28 -15 L 28 -25 M 21 -18 L 28 -25 L 35 -18';
-      group.add(new Konva.Path({ data: pathData, stroke: color, strokeWidth: 3, lineCap: 'round', lineJoin: 'round', name: 'colorStroke' }));
+      const maxH = 40; // 最大高度
+      const group = new Konva.Group({ x, y });
+      if (type === 'R') group.scaleX(-1);
+      group._arrowMeta = { type };
+
+      const svgUrl = arrowSvgMap[type];
+      if (svgUrl) {
+        loadSvgImage(svgUrl, color).then(imgObj => {
+          // 按原始比例等比缩放,高度不超过 maxH
+          const natW = imgObj.naturalWidth || imgObj.width;
+          const natH = imgObj.naturalHeight || imgObj.height;
+          const scale = Math.min(maxH / natH, 1);
+          const w = Math.round(natW * scale);
+          const h = Math.round(natH * scale);
+          const konvaImg = new Konva.Image({
+            image: imgObj,
+            x: -w / 2,
+            y: -h - 5,
+            width: w,
+            height: h,
+            name: 'arrowImg'
+          });
+          group.add(konvaImg);
+          if (this.layer) this.layer.draw();
+        });
+      }
       return group;
     },
 
@@ -306,29 +374,67 @@ export default {
     updateDynamicSignals() {
       const signals = this.mapData.signals;
       if (!signals) return;
+      const config = this.mapData.armsConfig || {};
 
       const nsColor = signals.ns.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
       const ewColor = signals.ew.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
-
-      const dyeArm = (armNode, color) => {
-        armNode.lightGroup.getChildren().forEach(r => r.fill(color));
-        const arrowColor = (color === this.C.SIGNAL_GREEN) ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN;
-        Object.values(armNode.arrowNodes).forEach(arr => {
-          if (arr) {
-            arr.findOne('.colorFill').fill(arrowColor);
-            arr.findOne('.colorStroke').stroke(arrowColor);
-          }
+      const nsActiveTypes = signals.ns.activeArrowTypes || [];
+      const ewActiveTypes = signals.ew.activeArrowTypes || [];
+
+      const dyeArm = (dir, armNode, pedColor, vehicleColor, activeTypes) => {
+        // 灯带颜色(人行道信号)
+        armNode.lightGroup.getChildren().forEach(r => r.fill(pedColor));
+        // 箭头按 lane type 用不同颜色的 SVG 替换
+        const lanes = (config[dir] && config[dir].lanes) || [];
+        Object.keys(armNode.arrowNodes).forEach(index => {
+          const arr = armNode.arrowNodes[index];
+          if (!arr) return;
+          const laneType = lanes[index];
+          const isActive = activeTypes.length > 0 && activeTypes.includes(laneType);
+          const targetColor = isActive ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
+
+          const meta = arr._arrowMeta;
+          if (!meta) return;
+          const svgUrl = arrowSvgMap[meta.type];
+          if (!svgUrl) return;
+
+          loadSvgImage(svgUrl, targetColor).then(imgObj => {
+            const existing = arr.findOne('.arrowImg');
+            if (existing) {
+              existing.image(imgObj);
+            } else {
+              const maxH = 40;
+              const natW = imgObj.naturalWidth || imgObj.width;
+              const natH = imgObj.naturalHeight || imgObj.height;
+              const scale = Math.min(maxH / natH, 1);
+              const w = Math.round(natW * scale);
+              const h = Math.round(natH * scale);
+              arr.add(new Konva.Image({
+                image: imgObj,
+                x: -w / 2,
+                y: -h - 5,
+                width: w,
+                height: h,
+                name: 'arrowImg'
+              }));
+            }
+            if (this.layer) this.layer.draw();
+          });
         });
       };
 
-      dyeArm(this.armsNodes.N, nsColor);
-      dyeArm(this.armsNodes.S, nsColor);
-      dyeArm(this.armsNodes.E, ewColor);
-      dyeArm(this.armsNodes.W, ewColor);
+      // 灯带代表人行道:车通行时人行道为红,车停时人行道为绿
+      const nsPedColor = signals.ns.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN;
+      const ewPedColor = signals.ew.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN;
+
+      dyeArm('N', this.armsNodes.N, nsPedColor, nsColor, nsActiveTypes);
+      dyeArm('S', this.armsNodes.S, nsPedColor, nsColor, nsActiveTypes);
+      dyeArm('E', this.armsNodes.E, ewPedColor, ewColor, ewActiveTypes);
+      dyeArm('W', this.armsNodes.W, ewPedColor, ewColor, ewActiveTypes);
 
       this.panelNodes.nsLabel.text(`${signals.ns.phaseName}:`);
       this.panelNodes.nsVal.text(signals.ns.time.toString().padStart(2, '0')).fill(nsColor);
-      
+
       this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
       this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
 

+ 226 - 0
src/components/ui/PlanDonutChart.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="dashboard-donut-wrapper" :style="{ gap: uiScale.gap + 'px' }">
+    
+    <div class="chart-container" :style="{ width: uiScale.chartBox + 'px', height: uiScale.chartBox + 'px' }">
+      <div class="chart-dom" ref="chartRef"></div>
+    </div>
+
+    <div class="legend-container">
+      <div v-if="showTotal" class="total-header" :style="{ fontSize: uiScale.totalFont + 'px', marginBottom: uiScale.gap + 'px' }">
+        总时长 <span class="total-num">{{ totalValue }}</span>
+      </div>
+      
+      <div class="legend-list" :style="{ gap: (uiScale.gap * 0.6) + 'px' }">
+        <div 
+          class="legend-item" 
+          v-for="(item, index) in chartData" 
+          :key="index"
+          :style="{ fontSize: uiScale.legendFont + 'px' }"
+        >
+          <i class="color-square" :style="{ 
+            backgroundColor: item.color, 
+            width: uiScale.square + 'px', 
+            height: uiScale.square + 'px',
+            marginRight: (uiScale.gap * 0.6) + 'px'
+          }"></i>
+          <span class="item-label" :style="{ minWidth: uiScale.labelWidth + 'px', marginRight: (uiScale.gap * 0.6) + 'px' }">{{ item.label }}</span>
+          <span class="item-value">{{ item.value }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts';
+import echartsResizeMixin from '@/mixins/echartsResize.js';
+
+export default {
+  name: 'PlanDonutChart',
+  // 仍然保留 mixin 用于监听容器尺寸变化触发重绘
+  mixins: [echartsResizeMixin], 
+  props: {
+    chartData: { type: Array, required: true, default: () => [] },
+    centerValue: { type: [Number, String], default: 0 },
+    centerLabel: { type: String, default: '' },
+    showTotal: { type: Boolean, default: true },
+    totalValue: { type: [Number, String], default: 0 },
+    scale: { type: Number, default: 0 }
+  },
+  data() {
+    return {
+      uiScale: {
+        gap: 12,
+        chartBox: 140, 
+        totalFont: 13,
+        legendFont: 12,
+        square: 10,
+        labelWidth: 65
+      }
+    };
+  },
+  watch: {
+    chartData: {
+      deep: true,
+      handler() {
+        this.updateChart();
+      }
+    },
+    scale() {
+      this.updateChart();
+    }
+  },
+  mounted() {
+    this.initChart();
+  },
+  methods: {
+    initChart() {
+      if (!this.$refs.chartRef) return;
+      this.$_chart = echarts.init(this.$refs.chartRef);
+      this.updateChart();
+    },
+    
+    // 【核心改造】获取真实的容器缩放比例
+    getLocalScale() {
+      // 优先使用父组件传入的 scale prop
+      if (this.scale > 0) return this.scale;
+      if (!this.$el) return 1;
+      // 降级:读取 CSS 变量 --s
+      const sVal = getComputedStyle(this.$el).getPropertyValue('--s');
+      if (sVal && sVal.trim() !== '' && !isNaN(parseFloat(sVal))) {
+        return parseFloat(sVal);
+      }
+      return window.innerWidth / 1920;
+    },
+
+    calcSize(px) {
+      return Math.round(px * this.getLocalScale());
+    },
+
+    updateChart() {
+      // 每次重绘时,获取最新的局部缩放比例 s
+      const s = this.getLocalScale();
+
+      // 同步更新 HTML 元素的尺寸
+      this.uiScale = {
+        gap: Math.round(12 * s),
+        chartBox: Math.round(140 * s),
+        totalFont: Math.round(13 * s),
+        legendFont: Math.round(12 * s),
+        square: Math.round(10 * s),
+        labelWidth: Math.round(65 * s)
+      };
+
+      if (!this.$_chart) return;
+
+      const option = {
+        color: this.chartData.map(item => item.color),
+        graphic: [
+          {
+            type: 'text',
+            left: 'center',
+            top: '38%',
+            style: {
+              text: this.centerValue,
+              fill: '#ffffff',
+              fontSize: Math.round(24 * s),
+              fontWeight: 'bold'
+            }
+          },
+          {
+            type: 'text',
+            left: 'center',
+            top: '60%',
+            style: {
+              text: this.centerLabel,
+              fill: '#a0aec0',
+              fontSize: Math.round(12 * s)
+            }
+          }
+        ],
+        series: [
+          {
+            type: 'pie',
+            radius: [Math.round(50 * s), Math.round(65 * s)],
+            center: ['50%', '50%'],
+            avoidLabelOverlap: false,
+            label: { show: false },
+            labelLine: { show: false },
+            hoverAnimation: false,
+            data: this.chartData.map(item => ({
+              name: item.label,
+              value: item.value
+            }))
+          }
+        ]
+      };
+
+      this.$_chart.setOption(option);
+    }
+  }
+};
+</script>
+
+<style scoped>
+/* 整体容器:水平弹性布局 */
+.dashboard-donut-wrapper {
+  display: flex;
+  align-items: center;
+  background-color: transparent;
+  padding: 0;
+  color: #ffffff;
+  font-family: sans-serif;
+}
+
+.chart-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.chart-dom {
+  width: 100%;
+  height: 100%;
+}
+
+.legend-container {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.total-header {
+  color: #a0aec0;
+}
+
+.total-num {
+  margin-left: 4px;
+  color: #ffffff;
+}
+
+.legend-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  color: #cbd5e1;
+  white-space: nowrap;
+}
+
+.color-square {
+  border-radius: 1px;
+  display: inline-block;
+}
+
+.item-label {
+  display: inline-block;
+}
+
+.item-value {
+  color: #ffffff;
+}
+</style>

+ 2 - 0
src/components/ui/SignalTimingChart.vue

@@ -167,6 +167,7 @@ export default {
           const ratio = ((offset + elapsed) % VISUAL_PERIOD) / VISUAL_PERIOD;
           this.internalTime = ratio * realMax;
           if (this.$_chart) this.updateScanLine();
+          this.$emit('scan-tick', this.internalTime);
         };
         joinSharedTimer(this._scanListener, this.currentTime || 0);
       } else {
@@ -177,6 +178,7 @@ export default {
           this.internalTime += 1;
           if (this.internalTime > realMax) this.internalTime = 0;
           if (this.$_chart) this.updateScanLine();
+          this.$emit('scan-tick', this.internalTime);
         }, 1000);
       }
     },

+ 34 - 16
src/mock/api.js

@@ -37,6 +37,13 @@ function delay(base = 200) { return sleep(base + Math.floor(Math.random() * 200)
 function ok(data) { return { code: 200, message: 'success', data } }
 function fail(msg, code = 400) { return { code, message: msg, data: null } }
 
+/** 根据路口ID生成稳定的设备状态(所有API共用,确保一致) */
+function _getDeviceStatus(id) {
+  const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
+  const statusList = ['在线', '在线', '在线', '在线', '在线', '在线', '在线', '离线']
+  return statusList[seed % statusList.length]
+}
+
 /** 基于当前秒数产生稳定随机(同一秒内多次调用返回相同值) */
 function seededRand(seed) {
   const x = Math.sin(seed) * 10000
@@ -113,11 +120,12 @@ function _makeIntersectionConfig(id, name, { fixedNsGreen } = {}) {
   const armCamTypes = _camerasToArmTypes(cameras)
 
   const lanePresets = [
-    ['U', 'L', 'S', 'R'], [null, 'L', 'S', 'R'],
-    [null, 'L', 'S', null], ['U', 'L', 'S', null],
-  ].sort(() => seededRand(seed) - 0.5)
+    ['U', 'L', 'S', 'R'], ['U', 'L', 'S', 'R'],
+    ['U', 'L', 'S', 'R'], ['U', 'L', 'S', 'R'],
+  ]
 
   return {
+    status: _getDeviceStatus(id),
     signals: {
       ns: { phaseName: phases[0], time: countdown, isGreen: nsGreen },
       ew: { phaseName: phases[1], time: countdown, isGreen: !nsGreen },
@@ -166,6 +174,8 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true) {
     const currentIconPool = (i < 2) ? iconsUD : iconsLR;
 
     // 辅助函数:生成单条轨道的一个阶段
+    // 第8列 [7] 标记方向: 'ns'(南北) 或 'ew'(东西)
+    const direction = (i < 2) ? 'ns' : 'ew';
     const pushTrackData = (trackIdx, phaseNamePrefix) => {
       // 这里的 icon 现在抽出来的是诸如 "STRAIGHT_DOWN,STRAIGHT_UP" 的字符串
       const icon = getRandomIcon(currentIconPool);
@@ -173,22 +183,22 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true) {
       const g = Math.floor(Math.random() * 11) + 20; // 绿灯 20-30s
       const s = 3; // 闪烁/条纹 3s
       const y = 2; // 黄灯 2s
-      
+
       let curT = stageStart;
-      
-      // 1. 绿灯 (第6个索引项传入组装好的成对 icon 字符串)
-      pd.push([trackIdx, curT, curT + g, phaseName, g, 'green', icon]); 
+
+      // 1. 绿灯 (第6个索引项传入组装好的成对 icon 字符串, 第7个索引项标记方向)
+      pd.push([trackIdx, curT, curT + g, phaseName, g, 'green', icon, direction]);
       curT += g;
       // 2. 绿闪/条纹
-      pd.push([trackIdx, curT, curT + s, '', s, 'stripe', null]); 
+      pd.push([trackIdx, curT, curT + s, '', s, 'stripe', null, direction]);
       curT += s;
       // 3. 黄灯
-      pd.push([trackIdx, curT, curT + y, '', y, 'yellow', null]); 
+      pd.push([trackIdx, curT, curT + y, '', y, 'yellow', null, direction]);
       curT += y;
       // 4. 红灯补齐 (确保阶段对齐)
       let remainRed = stageEnd - curT;
       if (remainRed > 0) {
-        pd.push([trackIdx, curT, stageEnd, '', remainRed, 'red', null]);
+        pd.push([trackIdx, curT, stageEnd, '', remainRed, 'red', null, direction]);
       }
     };
 
@@ -328,6 +338,7 @@ export async function apiGetIntersectionData(id, { fixedNsGreen } = {}) {
   const nsGreen = nsGreenVal
 
   const config = base ? {
+    status: base.status || _getDeviceStatus(id),
     signals: {
       ns: { ...base.signals.ns, time: Math.max(1, cycle - elapsed), isGreen: nsGreen },
       ew: { ...base.signals.ew, time: elapsed || 1, isGreen: !nsGreen },
@@ -604,7 +615,7 @@ export async function apiGetCrossingList(params = {}) {
     const phaseData = _makePhaseData(cycleLength, false)
     return {
       ...r,
-      status: statuses[Math.floor(seededRand(i + 42) * statuses.length)],
+      status: _getDeviceStatus(r.id),
       cycle: cycleLength,
       phaseData,
       currentTime: Math.floor(seededRand(i * 31 + page * 97) * cycleLength),
@@ -731,11 +742,9 @@ export async function apiGetSpecialTaskMonitorData(id, { fixedNsGreen } = {}) {
   const colorList = ['#ffaa00', '#00e5ff', '#68e75f', '#00e5ff']
   const nowSec = Math.floor(Date.now() / 1000)
 
+  const allLanes = ['U', 'L', 'S', 'R'];
   const lanePresets = [
-    { N: ['U', 'L', 'S', 'R'], S: [null, 'L', 'S', 'R'], E: [null, 'L', 'S', null], W: ['U', 'L', 'S', null] },
-    { N: ['L', 'S', 'R'], S: ['L', 'S', 'R'], E: ['L', 'S', 'R'], W: ['L', 'S', 'R'] },
-    { N: ['L', 'S', 'S', 'R'], S: ['U', 'L', 'S', 'R'], E: ['L', 'S', null], W: [null, 'S', 'R'] },
-    { N: [null, 'L', 'S', 'R'], S: ['L', 'S', 'R', null], E: ['U', 'L', 'S', 'R'], W: ['L', 'S', 'R'] },
+    { N: allLanes, S: allLanes, E: allLanes, W: allLanes },
   ]
 
   const intersections = keyPoints.map((jnc, i) => {
@@ -788,6 +797,10 @@ export async function apiGetCrossingPanelData(id) {
   const point = DB.points.find(p => p.id === id)
   const config = DB.intersectionConfigs[id] || _makeIntersectionConfig(id, null, { fixedNsGreen: false })
   const seed = id ? id.charCodeAt(id.length - 1) : 0
+  // 确保 config 有 status
+  if (!config.status) {
+    config.status = _getDeviceStatus(id)
+  }
 
   const preset = DB.signalTimings[id]
   const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][Math.abs(seed) % 6]
@@ -818,6 +831,11 @@ export async function apiGetCrossingDetailData(id) {
   // 用 id 的全部字符生成稳定 seed(加权位置避免 charCode 总和碰撞)
   const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
 
+  // 确保 config 有 status 字段(预存配置可能缺失)
+  if (!config.status) {
+    config.status = _getDeviceStatus(id)
+  }
+
   // 从真实阶段数据推导周期和相位
   const preset = DB.signalTimings[id]
   const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][seed % 6]
@@ -1003,7 +1021,7 @@ export async function apiGetDeviceFaultStatus() {
 
   // 从在线数据推算故障数,每次波动
   const smTotal = sm.chartData[0].value + sm.chartData[1].value
-  const smFault = _fluctuate(sm.chartData[1].value, 3)
+  const smFault = 0  // 信号机无故障,用于测试无故障状态
   const dtTotal = dt.chartData[0].value + dt.chartData[1].value
   const dtFault = _fluctuate(dt.chartData[1].value, 5)
   const camTotal = cam.chartData[0].value + cam.chartData[1].value

+ 5 - 5
src/mock/mock_data.json

@@ -29725,7 +29725,7 @@
               "children": [
                 {
                   "id": "trunk_1",
-                  "label": "干线1",
+                  "label": "古城南路与古城大街",
                   "intersections": [
                     "干线1_路口1",
                     "干线1_路口2",
@@ -29783,7 +29783,7 @@
                 },
                 {
                   "id": "trunk_2",
-                  "label": "干线2",
+                  "label": "古城西路东口南一过街",
                   "intersections": [
                     "干线2_路口1",
                     "干线2_路口2",
@@ -29841,7 +29841,7 @@
                 },
                 {
                   "id": "trunk_3",
-                  "label": "干线3",
+                  "label": "古城大街与古城北路",
                   "intersections": [
                     "干线3_路口1",
                     "干线3_路口2",
@@ -29899,7 +29899,7 @@
                 },
                 {
                   "id": "trunk_4",
-                  "label": "干线4",
+                  "label": "八角北路与八角东街",
                   "intersections": [
                     "干线4_路口1",
                     "干线4_路口2",
@@ -29957,7 +29957,7 @@
                 },
                 {
                   "id": "trunk_5",
-                  "label": "干线5",
+                  "label": "古城西路与古城大街",
                   "intersections": [
                     "干线5_路口1",
                     "干线5_路口2",

+ 17 - 6
src/views/DataAnalysis.vue

@@ -11,10 +11,11 @@
 
         <!-- 地图 -->
         <template #map>
-            <TongzhouTrafficMap amapKey="db2da7e3e248c3b2077d53fc809be63f"
+            <EvaluationTrafficMap 
+                amapKey="db2da7e3e248c3b2077d53fc809be63f"
                 securityJsCode="a7413c674852c5eaf01d90813c5b7ef6"
-                :mode="activeLeftTab === 'crossing' ? '路口' : activeLeftTab === 'trunkLine' ? '干线' : activeLeftTab === 'specialDuty' ? '特勤' : ''"
-                @map-crossing-click="handleMapCrossingClick" />
+                :mode="currentMapMode" 
+            />
         </template>
 
         <template #left>
@@ -163,21 +164,21 @@ import DashboardLayout from '@/layouts/DashboardLayout.vue';
 import DateTimeWidget from '@/components/ui/DateTimeWidget.vue';
 import TechTabs from '@/components/ui/TechTabs.vue';
 import TechTabPane from '@/components/ui/TechTabPane.vue';
-import TongzhouTrafficMap from '@/components/TongzhouTrafficMap.vue';
+import EvaluationTrafficMap from '@/components/ui/EvaluationTrafficMap.vue';
 import PanelContainer from '@/components/ui/PanelContainer.vue';
 
 import { apiGetTongzhouMenuTree, } from '@/api';
 
 
 export default {
-    name: "HomePage",
+    name: "DataAnalysis",
     components: {
         DashboardLayout,
         DateTimeWidget,
         TechTabs,
         TechTabPane,
         PanelContainer,
-        TongzhouTrafficMap,
+        EvaluationTrafficMap,
     },
     data() {
         return {
@@ -186,6 +187,16 @@ export default {
             activeTab: 'singlePoint',
         };
     },
+    computed: {
+        currentMapMode() {
+            if (this.activeLeftTab === 'overview') {
+                return 'overview';
+            } else if (this.activeLeftTab === 'evaluation') {
+                return this.activeTab; 
+            }
+            return 'overview'; 
+        }
+    },
     created() {
 
     },

+ 22 - 3
src/views/StatusMonitoring.vue

@@ -388,9 +388,20 @@ export default {
             } catch (e) { /* ignore */ }
         },
         // 显示路口弹窗组(多选分屏)
-        showCrossingDalogs(nodeData) {
+        async showCrossingDalogs(nodeData) {
             console.log('路口多选', nodeData.id, nodeData.label);
 
+            // 0. 离线检查
+            const detailData = await apiGetCrossingDetailData(nodeData.id);
+            if (detailData?.intersectionData?.status !== '在线') {
+                this.$msg({
+                    title: '提示',
+                    message: `路口「${nodeData.label || nodeData.id}」设备离线,无法查看详情`,
+                    duration: 3000,
+                });
+                return;
+            }
+
             // 1. 已选中 → 不重复操作
             const existIndex = this.crossingSelections.findIndex(c => c.id === nodeData.id);
             if (existIndex !== -1) {
@@ -402,8 +413,8 @@ export default {
                 this.crossingSelections.shift();
             }
 
-            // 3. 追加选中
-            this.crossingSelections.push({ ...nodeData });
+            // 3. 追加选中,带上预加载数据避免重复请求
+            this.crossingSelections.push({ ...nodeData, _preloadedData: detailData });
 
             // 4. 打开或更新弹窗
             this.openCrossingMultiView();
@@ -449,6 +460,14 @@ export default {
         async showCrossingDetailDialogs(nodeData) {
             console.log('显示路口详情弹窗组', nodeData.id, nodeData.label);
             const detailData = await apiGetCrossingDetailData(nodeData.id);
+            if (detailData?.intersectionData?.status !== '在线') {
+                this.$msg({
+                    title: '提示',
+                    message: `路口「${nodeData.label || nodeData.id}」设备离线,无法查看详情`,
+                    duration: 3000,
+                });
+                return;
+            }
             const dialogId = 'crossing_detail' + nodeData.id;
             this.$refs.layout.openDialog({
                 id: dialogId,