Просмотр исходного кода

新增交通路口信号相位与流量监控图组件;新增交通路口信号监控和信号灯配时图组合组件;修改弹窗位置动态调整;修改交通时序图的刷新重绘的bug;

画安 месяцев назад: 4
Родитель
Сommit
18acb53f21

+ 20 - 0
package-lock.json

@@ -14,6 +14,7 @@
         "core-js": "^3.8.3",
         "echarts": "^5.6.0",
         "echarts-gl": "^2.0.9",
+        "konva": "^10.2.0",
         "three": "^0.183.1",
         "vue": "^2.6.14",
         "vue-router": "^3.6.5"
@@ -7785,6 +7786,25 @@
         "node": ">= 8"
       }
     },
+    "node_modules/konva": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/konva/-/konva-10.2.0.tgz",
+      "integrity": "sha512-JBoz0Xjbf49UPxCZegZ4WseqOzJ+C4AUDOtJ9eBve5RS5Fcq/u8TdBD5fDl/uPFInpC3a9uycm0sRyZpF4hheg==",
+      "funding": [
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/lavrton"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/konva"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/lavrton"
+        }
+      ]
+    },
     "node_modules/ktx-parse": {
       "version": "0.5.0",
       "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.5.0.tgz",

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
     "core-js": "^3.8.3",
     "echarts": "^5.6.0",
     "echarts-gl": "^2.0.9",
+    "konva": "^10.2.0",
     "three": "^0.183.1",
     "vue": "^2.6.14",
     "vue-router": "^3.6.5"

+ 298 - 0
src/components/IntersectionMap.vue

@@ -0,0 +1,298 @@
+<template>
+    <div ref="konvaContainer" class="intersection-map-container"></div>
+</template>
+
+<script>
+import Konva from 'konva';
+
+export default {
+  name: 'IntersectionMap',
+  props: {
+    // 接收父组件传来的路口数据
+    mapData: {
+      type: Object,
+      required: true
+    }
+  },
+  data() {
+    return {
+      stage: null,
+      layer: null,
+      armsNodes: {}, // 存储四个方向的道路实例
+      panelNodes: {}, // 存储中央面板的文字实例
+      // 颜色配置
+      C: {
+        BG: '#212842', ROAD: '#3d3938', YELLOW: '#D9A73D', WHITE: '#E0E0E0',
+        SIGNAL_RED: '#FF5252', SIGNAL_GREEN: '#8DF582',
+        PANEL_BG: 'rgba(30, 30, 40, 0.85)', BLUE: '#448AFF'
+      },
+      // 尺寸配置
+      sizeConfig: {
+        stageSize: 900,
+        laneWidth: 40,
+        halfRoad: 160,
+        roadWidth: 320,
+        armLength: 350
+      }
+    };
+  },
+  mounted() {
+    this.initKonvaStage();
+    if (this.mapData && Object.keys(this.mapData).length > 0) {
+      this.renderStaticConfig();
+      this.updateDynamicSignals();
+    }
+    // 监听容器尺寸变化,自适应缩放
+    this._roaPending = false;
+    this._resizeObserver = new ResizeObserver(() => {
+      if (!this._roaPending) {
+        this._roaPending = true;
+        requestAnimationFrame(() => {
+          this._roaPending = false;
+          this.fitToContainer();
+        });
+      }
+    });
+    this._resizeObserver.observe(this.$refs.konvaContainer);
+  },
+  beforeDestroy() {
+    if (this._resizeObserver) {
+      this._resizeObserver.disconnect();
+    }
+    if (this.stage) {
+      this.stage.destroy();
+    }
+  },
+  watch: {
+    // 监听数据变化
+    mapData: {
+      handler(newData, oldData) {
+        if (!newData) return;
+        
+        // 简单判断:如果是首次加载数据,或者配置变了,需要重新渲染静态配置
+        if (!oldData || JSON.stringify(newData.armsConfig) !== JSON.stringify(oldData.armsConfig)) {
+          this.renderStaticConfig();
+        }
+        // 每次数据变化都更新信号状态(倒计时和红绿灯)
+        this.updateDynamicSignals();
+      },
+      deep: true // 开启深度监听
+    }
+  },
+  methods: {
+    // ================= 自适应缩放 =================
+    fitToContainer() {
+      if (!this.stage || !this.$refs.konvaContainer) return;
+      const container = this.$refs.konvaContainer;
+      const containerWidth = container.clientWidth;
+      const containerHeight = container.clientHeight;
+      if (containerWidth === 0 || containerHeight === 0) return;
+
+      const designSize = this.sizeConfig.stageSize;
+      const scale = Math.min(containerWidth / designSize, containerHeight / designSize);
+
+      this.stage.width(containerWidth);
+      this.stage.height(containerHeight);
+
+      const offsetX = (containerWidth - designSize * scale) / 2;
+      const offsetY = (containerHeight - designSize * scale) / 2;
+
+      this.layer.scale({ x: scale, y: scale });
+      this.layer.position({ x: offsetX, y: offsetY });
+      this.layer.draw();
+    },
+
+    // ================= 初始化画布 =================
+    initKonvaStage() {
+      const { stageSize, halfRoad, roadWidth } = this.sizeConfig;
+      const center = stageSize / 2;
+
+      const container = this.$refs.konvaContainer;
+      const initWidth = container.clientWidth || stageSize;
+      const initHeight = container.clientHeight || stageSize;
+
+      this.stage = new Konva.Stage({
+        container: this.$refs.konvaContainer,
+        width: initWidth,
+        height: initHeight
+      });
+      this.layer = new Konva.Layer();
+      this.stage.add(this.layer);
+
+      // 绘制背景
+      this.layer.add(new Konva.Rect({ width: stageSize, height: stageSize, fill: this.C.BG }));
+      // 绘制中心路口交叉区
+      this.layer.add(new Konva.Rect({ x: center - halfRoad, y: center - halfRoad, width: roadWidth, height: roadWidth, fill: this.C.ROAD }));
+
+      // 创建四个方向的道路骨架
+      this.armsNodes = {
+        N: this.createRoadArm(center, center - halfRoad, 0),
+        E: this.createRoadArm(center + halfRoad, center, 90),
+        S: this.createRoadArm(center, center + halfRoad, 180),
+        W: this.createRoadArm(center - halfRoad, center, 270)
+      };
+      Object.values(this.armsNodes).forEach(arm => this.layer.add(arm));
+
+      // 创建中央面板
+      this.createCenterPanel(center);
+      this.layer.draw();
+      this.fitToContainer();
+    },
+
+    // ================= 创建道路骨架 (内置绘制逻辑) =================
+    createRoadArm(x, y, rotation) {
+      const { halfRoad, roadWidth, laneWidth } = this.sizeConfig;
+      const group = new Konva.Group({ x, y, rotation });
+      
+      // 1. 路面与线条
+      group.add(new Konva.Rect({ x: -halfRoad, y: -350, width: roadWidth, height: 350, fill: this.C.ROAD }));
+      group.add(new Konva.Line({ points: [0, -350, 0, -35], stroke: this.C.YELLOW, strokeWidth: 3 }));
+      group.add(new Konva.Path({ data: `M -160 -350 L -160 -30 Q -160 0 -180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
+      group.add(new Konva.Path({ data: `M 160 -350 L 160 -30 Q 160 0 180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
+      group.add(new Konva.Line({ points: [-160, -35, 0, -35], stroke: this.C.WHITE, strokeWidth: 4 }));
+      
+      for (let i = 1; i < 4; i++) {
+        let ox = i * laneWidth;
+        group.add(new Konva.Line({ points: [-ox, -35, -ox, -120], stroke: this.C.WHITE, strokeWidth: 2 }));
+        group.add(new Konva.Line({ points: [-ox, -120, -ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
+        group.add(new Konva.Line({ points: [ox, -35, ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
+      }
+      
+      // 2. 信号灯带容器
+      const lightGroup = new Konva.Group();
+      const rectOpts = { y: -16, width: 8, height: 24, cornerRadius: 2, offsetX: 4, offsetY: 12 };
+      for (let lx = -148; lx <= -20; lx += 16) lightGroup.add(new Konva.Rect({ x: lx, ...rectOpts }));
+      for (let rx = 20; rx <= 148; rx += 16) lightGroup.add(new Konva.Rect({ x: rx, ...rectOpts }));
+      group.add(lightGroup);
+      group.lightGroup = lightGroup;
+
+      // 预留动态挂载点
+      group.arrowNodes = { 0: null, 1: null, 2: null, 3: null };
+      group.cameraNode = null;
+
+      return group;
+    },
+
+    createCenterPanel(center) {
+      const panelGroup = new Konva.Group({ x: center - 80, y: center - 45 });
+      panelGroup.add(new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 }));
+      
+      const labelFont = { fontSize: 18, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE }; 
+      const valueFont = { fontSize: 28, fontFamily: 'monospace', fontStyle: 'bold' };              
+      
+      this.panelNodes.nsLabel = new Konva.Text({ ...labelFont, x: 15, y: 22, text: '相位-:' });
+      this.panelNodes.nsVal = new Konva.Text({ ...valueFont, x: 90, y: 15, text: '--', fill: this.C.SIGNAL_GREEN });
+      
+      this.panelNodes.ewLabel = new Konva.Text({ ...labelFont, x: 15, y: 55, text: '相位-:' });
+      this.panelNodes.ewVal = new Konva.Text({ ...valueFont, x: 90, y: 48, text: '--', fill: this.C.SIGNAL_GREEN });
+      
+      panelGroup.add(this.panelNodes.nsLabel, this.panelNodes.nsVal, this.panelNodes.ewLabel, this.panelNodes.ewVal);
+      this.layer.add(panelGroup);
+    },
+
+    // ================= 图标工厂函数 =================
+    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' }));
+      return group;
+    },
+
+    createCameraIcon(type, x, y) {
+      const group = new Konva.Group({ x, y });
+      if (type === 1) { 
+        group.add(new Konva.Line({ points: [-16, -30, 16, -30], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round' }));
+        group.add(new Konva.Line({ points: [0, -30, 0, -10], stroke: this.C.BLUE, strokeWidth: 2.5 }));
+        const body = new Konva.Group({ y: -10, rotation: 15 });
+        body.add(new Konva.Rect({ x: -16, y: -8, width: 32, height: 16, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 2 }));
+        body.add(new Konva.Rect({ x: 16, y: -4, width: 6, height: 8, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 1 }));
+        group.add(body);
+      } else if (type === 2) { 
+        group.add(new Konva.Rect({ x: -14, y: -24, width: 28, height: 24, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 6 }));
+        group.add(new Konva.Circle({ x: 0, y: -12, radius: 5, stroke: this.C.BLUE, strokeWidth: 2.5 }));
+        group.add(new Konva.Line({ points: [-10, 0, 10, 0], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round' }));
+        group.add(new Konva.Line({ points: [0, 0, 0, 6], stroke: this.C.BLUE, strokeWidth: 2.5 }));
+      }
+      return group;
+    },
+
+    // ================= 核心更新逻辑 =================
+    // 渲染或更新静态设备(摄像头、箭头配置)
+    renderStaticConfig() {
+      const config = this.mapData.armsConfig;
+      if (!config) return;
+
+      Object.keys(config).forEach(dir => {
+        const armData = config[dir];
+        const armNode = this.armsNodes[dir];
+
+        // 挂载摄像头
+        if (armNode.cameraNode) armNode.cameraNode.destroy();
+        if (armData.cameraType > 0) {
+          const cam = this.createCameraIcon(armData.cameraType, -80, -190);
+          armNode.add(cam);
+          armNode.cameraNode = cam;
+        }
+
+        // 挂载车道箭头
+        armData.lanes.forEach((type, index) => {
+          if (armNode.arrowNodes[index]) armNode.arrowNodes[index].destroy();
+          if (type) {
+            const lx = -20 - (index * this.sizeConfig.laneWidth);
+            const arrow = this.createArrowIcon(type, lx, -80, this.C.WHITE);
+            armNode.add(arrow);
+            armNode.arrowNodes[index] = arrow;
+          }
+        });
+      });
+      this.layer.draw();
+    },
+
+    // 根据实时倒计时和红绿灯更新颜色
+    updateDynamicSignals() {
+      const signals = this.mapData.signals;
+      if (!signals) return;
+
+      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));
+        Object.values(armNode.arrowNodes).forEach(arr => {
+          if (arr) {
+            arr.findOne('.colorFill').fill(color);
+            arr.findOne('.colorStroke').stroke(color);
+          }
+        });
+      };
+
+      dyeArm(this.armsNodes.N, nsColor);
+      dyeArm(this.armsNodes.S, nsColor);
+      dyeArm(this.armsNodes.E, ewColor);
+      dyeArm(this.armsNodes.W, ewColor);
+
+      // 更新中央面板
+      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);
+
+      this.layer.draw();
+    }
+  }
+};
+</script>
+
+<style scoped>
+.intersection-map-container {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 105 - 0
src/components/IntersectionSignalMonitoring.vue

@@ -0,0 +1,105 @@
+<template>
+    <div class="container">
+        <div>
+            <IntersectionMap :mapData="intersectionData" />
+        </div>
+        <div>
+            <SignalTimingChart 
+                :loading="loading" 
+                :cycle-length="signalTimingData.cycleLength"
+                :current-time="signalTimingData.currentTime" 
+                :phase-data="signalTimingData.phaseData" />
+        </div>
+    </div>
+</template>
+
+<script>
+
+import SignalTimingChart from '@/components/SignalTimingChart.vue';
+import IntersectionMap from '@/components/IntersectionMap.vue';
+import { fetchSignalTimingData, getIntersectionData } from '@/mock/data';
+
+export default {
+    name: "IntersectionSignalMonitoring",
+    components: {
+        SignalTimingChart,
+        IntersectionMap
+    },
+    props: {
+        nodeData: {
+            type: Object,
+            default: () => ({})
+        }
+    },
+    data() {
+        return {
+            signalTimingData: {},
+            loading: false,
+            intersectionData: {},
+            timer: null
+        };
+    },
+    computed: {
+
+    },
+    created() {
+    },
+    async mounted() {
+        this.loading = true;
+        // 1. 发起 API 请求获取初始化数据
+        const signalRes = await fetchSignalTimingData(this.nodeData.id);
+        this.signalTimingData = signalRes.data;
+        this.intersectionData = await getIntersectionData(this.nodeData.id);
+        this.loading = false;
+
+        // 2. 开启前端模拟倒计时(实际项目中这个可能由 WebSocket 每秒推送过来)
+        this.startSimulationTimer();
+    },
+    beforeDestroy() {
+    if (this.timer) clearInterval(this.timer);
+  },
+  methods: {
+    startSimulationTimer() {
+      this.timer = setInterval(() => {
+        // 创建数据的副本以触发 Vue 的响应式更新
+        let newData = JSON.parse(JSON.stringify(this.intersectionData));
+        
+        let ns = newData.signals.ns;
+        let ew = newData.signals.ew;
+
+        // 南北向倒计时
+        ns.time--;
+        if (ns.time < 0) {
+          ns.time = 38;
+          ns.isGreen = !ns.isGreen; // 切换红绿状态
+        }
+
+        // 东西向倒计时
+        ew.time--;
+        if (ew.time < 0) {
+          ew.time = 38;
+          ew.isGreen = !ew.isGreen; // 切换红绿状态
+        }
+
+        // 将新数据赋值回去
+        this.intersectionData = newData;
+      }, 1000);
+    }
+  }
+};
+</script>
+
+<style scoped>
+.container {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    height: 100%;
+    gap: 12px;
+    background-color: #212842;
+}
+.container > div {
+    flex: 1;
+    width: 100%;
+}
+</style>

+ 7 - 4
src/components/MenuItem.vue

@@ -49,14 +49,17 @@ export default {
     }
   },
   methods: {
-    toggle() {
+    toggle(e) {
       if (this.isFolder) {
-        // Vue 2 中需要确保 isOpen 具有响应式,建议在 mock 数据中初始化好
         this.$set(this.model, 'isOpen', !this.model.isOpen);
       } else {
-        // 点击叶子节点,触发事件给父组件(如切换图层、路由跳转)
         console.log('点击了具体项目:', this.model.label);
-        this.$emit('node-click', { id: this.model.id, label: this.model.label });
+        const rect = e.currentTarget.getBoundingClientRect();
+        this.$emit('node-click', {
+          id: this.model.id,
+          label: this.model.label,
+          rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom }
+        });
       }
     }
   }

+ 23 - 4
src/components/SignalTimingChart.vue

@@ -75,9 +75,26 @@ export default {
   mounted() {
     this.initChart();
     window.addEventListener('resize', this.handleResize);
+    // 监听容器尺寸变化,自适应 resize(用 rAF 防抖避免循环)
+    this._resizePending = false;
+    this._resizeObserver = new ResizeObserver(() => {
+      if (!this._resizePending) {
+        this._resizePending = true;
+        requestAnimationFrame(() => {
+          this._resizePending = false;
+          this.handleResize();
+        });
+      }
+    });
+    if (this.$refs.chartDom) {
+      this._resizeObserver.observe(this.$refs.chartDom);
+    }
   },
   beforeDestroy() {
     window.removeEventListener('resize', this.handleResize);
+    if (this._resizeObserver) {
+      this._resizeObserver.disconnect();
+    }
     if (this.chartInstance) {
       this.chartInstance.dispose();
       this.chartInstance = null;
@@ -253,13 +270,15 @@ export default {
 <style scoped>
 .signal-timing-widget {
   width: 100%;
-  background-color: #1e2638; 
+  height: 100%;
+  background-color: transparent;
   padding: 15px 20px;
   border-radius: 4px;
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
   font-family: 'Microsoft YaHei', sans-serif;
   box-sizing: border-box;
   position: relative;
+  display: flex;
+  flex-direction: column;
 }
 
 .header {
@@ -287,10 +306,10 @@ export default {
 }
 .checkbox-mock.is-checked { background-color: #4da8ff; border-color: #4da8ff; }
 
-.chart-container { width: 100%; height: 180px; }
+.chart-container { width: 100%; flex: 1; min-height: 120px; }
 
 .loading-overlay {
-  height: 180px; display: flex; align-items: center; justify-content: center;
+  flex: 1; min-height: 120px; display: flex; align-items: center; justify-content: center;
   color: #758599; font-size: 14px;
 }
 </style>

+ 6 - 2
src/components/SmartDialog.vue

@@ -20,7 +20,7 @@
 
     <div class="dialog-divider"></div>
 
-    <div class="dialog-body">
+    <div class="dialog-body" :class="{ 'no-padding': noPadding }">
       <slot></slot>
     </div>
 
@@ -51,7 +51,8 @@ export default {
     // 功能开关
     draggable: { type: Boolean, default: true }, // 是否允许拖拽
     resizable: { type: Boolean, default: true }, // 是否允许拉伸大小
-    
+    noPadding: { type: Boolean, default: false }, // 内容区无内边距
+
     // 尺寸配置
     defaultWidth: { type: Number, default: 350 },
     defaultHeight: { type: Number, default: 250 },
@@ -317,6 +318,9 @@ export default {
 .dialog-body {
   flex: 1;
   padding: 20px;
+}
+.dialog-body.no-padding {
+  padding: 0;
   color: #e2e8f0;
   overflow: auto;
   cursor: default;

+ 8 - 1
src/components/TrafficTimeSpace.vue

@@ -131,8 +131,15 @@ export default {
 
       // 监听容器尺寸变化(弹窗拉伸、延迟渲染等场景)
       if (typeof ResizeObserver !== 'undefined') {
+        this._roaPending = false;
         this._resizeObserver = new ResizeObserver(() => {
-          this.chart && this.chart.resize();
+          if (!this._roaPending) {
+            this._roaPending = true;
+            requestAnimationFrame(() => {
+              this._roaPending = false;
+              this.chart && this.chart.resize();
+            });
+          }
         });
         this._resizeObserver.observe(this.$refs.chartContainer);
       }

+ 41 - 1
src/mock/data.js

@@ -188,7 +188,8 @@ export function makeTrafficTimeSpaceData(opts = {}) {
 /**
  * 模拟获取交通配时方案数据的 API 请求
  */
-export function fetchSignalTimingData() {
+export function fetchSignalTimingData(id) {
+  console.log('id', id);
   return new Promise((resolve) => {
     // 模拟 500ms 的网络请求延迟
     setTimeout(() => {
@@ -234,6 +235,45 @@ export function fetchSignalTimingData() {
   });
 }
 
+/**
+ * 模拟获取路口信号相位与流量监控图数据的 API 请求
+ */
+export function getIntersectionData(id) {
+  console.log('id', id);
+  return new Promise((resolve) => {
+    // 模拟 500ms 的网络延迟
+    setTimeout(() => {
+      resolve({
+        // 信号灯当前相位与倒计时状态
+        signals: {
+          ns: { phaseName: '相位3', time: 38, isGreen: true },  // 南北向
+          ew: { phaseName: '相位7', time: 38, isGreen: false }  // 东西向
+        },
+        // 交叉路口四个方向的静态配置 (N北, S南, E东, W西)
+        armsConfig: {
+          N: {
+            cameraType: 1, // 1: 枪机, 2: 球机, 0: 无摄像头
+            // 车道指示标,从左到右(从中心黄线到路沿)排列。支持: 'U'调头, 'L'左转, 'S'直行, 'R'右转, null无
+            lanes: ['U', 'L', 'S', 'R'] 
+          },
+          S: {
+            cameraType: 1,
+            lanes: [null, 'L', 'S', 'R'] // 最内侧车道无指示标
+          },
+          E: {
+            cameraType: 2,
+            lanes: [null, 'L', 'S', null]
+          },
+          W: {
+            cameraType: 0,
+            lanes: ['U', 'L', 'S', null]
+          }
+        }
+      });
+    }, 500);
+  });
+}
+
 // ====== 首页 mock 数据 ======
 
 export function makeHomeData() {

+ 56 - 27
src/views/MainWatch.vue

@@ -118,17 +118,20 @@
         :position="dialog.position"
         :draggable="dialog.draggable"
         :resizable="dialog.resizable"
+        :noPadding="dialog.noPadding || false"
         :defaultWidth="dialog.width || 350"
         :defaultHeight="dialog.height || 250"
         @close="removeDialog(dialog.id)"
       >
         <template #header>
           <span class="title" v-if="dialog.componentName === 'TrafficTimeSpace'">{{dialog.title}}</span>
-          <div style="display: flex; align-items: center; gap: 8px;" v-else-if="dialog.componentName === 'SignalTimingChart'">
-            <span style="color: #fff; font-size: 16px;">信号配时图表</span>
+          <div style="display: flex; align-items: center; gap: 8px;" v-else-if="dialog.componentName === 'BigIntersectionSignalMonitoring'">
+            <span style="color: #fff; font-size: 16px;">{{dialog.title}}</span>
             <span style="color: #4da8ff; font-size: 12px;">在线</span>
           </div>
+          <span class="title" v-else>{{dialog.title}}</span>
         </template>
+
         <component v-if="dialog.componentName === 'TrafficTimeSpace'"
           :is="dialog.componentName"
           :intersections="dialog.nodeData.intersections"
@@ -137,13 +140,19 @@
           :green-data="dialog.nodeData.greenData"
           :auto-scroll="false"
         />
-        <component v-else-if="dialog.componentName === 'SignalTimingChart'"
-          :is="dialog.componentName"
-          :loading="isFetchingData"
-          :cycle-length="dialog.nodeData.cycleLength"
-          :current-time="dialog.nodeData.currentTime"
-          :phase-data="dialog.nodeData.phaseData"
-        />
+
+        <template v-if="dialog.componentName === 'BigIntersectionSignalMonitoring'">
+          <div class="big-mointoring-container">
+            <div class="big-mointoring-left">
+              <IntersectionSignalMonitoring
+                :node-data="dialog.nodeData" />
+            </div>
+            <div class="big-mointoring-right">
+              aaaa
+            </div>
+          </div>
+        </template>
+
         <component v-else
           :is="dialog.componentName"
           :node-data="dialog.nodeData"
@@ -156,8 +165,8 @@
 import MenuItem from '@/components/MenuItem.vue';
 import SmartDialog from '@/components/SmartDialog.vue';
 import TrafficTimeSpace from '@/components/TrafficTimeSpace.vue';
-import SignalTimingChart from '@/components/SignalTimingChart.vue';
-import { menuData, makeTrafficTimeSpaceData, fetchSignalTimingData } from '@/mock/data';
+import IntersectionSignalMonitoring from '@/components/IntersectionSignalMonitoring.vue';
+import { menuData, makeTrafficTimeSpaceData} from '@/mock/data';
 
 export default {
   name: "MainWatch",
@@ -165,7 +174,7 @@ export default {
     MenuItem,
     SmartDialog,
     TrafficTimeSpace,
-    SignalTimingChart
+    IntersectionSignalMonitoring,
   },
   data() {
     return {
@@ -231,17 +240,7 @@ export default {
     handleMenuClick(payload) {
       console.log('父组件接收到了参数:', payload.id, payload.label);
       if (this.currentTab === 'crossing') {
-        this.isFetchingData = true;
-        fetchSignalTimingData(payload.id).then(res => {
-          this.openDialog(null, payload.id, payload.label, {
-            ...payload,
-            ...res.data
-          });
-          this.isFetchingData = false;
-        }).catch(() => {
-          this.isFetchingData = false;
-          alert('获取信号配时数据失败');
-        });
+        this.openDialog(null, payload.id, payload.label, payload);
       } else if (this.currentTab === 'arterial') {
         this.openDialog(null, payload.id, payload.label, makeTrafficTimeSpaceData());
       }
@@ -273,10 +272,24 @@ export default {
       }
 
       let currentComponentName = '';
+      let width = 1000;
+      let height = 600;
+      let center = true;
+      let position = {x: 100, y: 100};
       if (this.currentTab === 'arterial') {
         currentComponentName = 'TrafficTimeSpace';
       } else if (this.currentTab === 'crossing') {
-        currentComponentName = 'SignalTimingChart';
+        currentComponentName = 'BigIntersectionSignalMonitoring';
+        width = 1500;
+        center = false;
+        // 弹窗位置:紧贴 MenuItem 右侧,间距 50px
+        if (nodeData.rect) {
+          position = {
+            x: nodeData.rect.right + 50,
+            y: nodeData.rect.y
+          };
+        }
+
       } else {
         currentComponentName = 'TrafficTimeSpace'; // 默认组件
       }
@@ -284,12 +297,14 @@ export default {
       this.activeDialogs.push({
         id: id,
         title: title,
-        center: true,        // 居中
+        center: center,        // 居中
+        position: position,    // 初始位置(仅当 center=false 时生效)
         draggable: false,     // 允许拖拽
         resizable: false,     // 允许拉伸
+        noPadding: currentComponentName === 'BigIntersectionSignalMonitoring',
         visible: true,
-        width: 1000,
-        height: 600,
+        width: width,
+        height: height,
         componentName: currentComponentName, // 子组件名称,需在 components 中注册
         // 把业务数据也存进来,方便传给子组件
         nodeData: nodeData
@@ -1091,4 +1106,18 @@ export default {
 .hide-scrollbar::-webkit-scrollbar {
   display: none; /* Chrome/Safari */
 }
+.big-mointoring-container {
+  display: flex;
+  flex-direction: row;
+  gap: 20px;
+  padding: 20px;
+}
+.big-mointoring-left {
+  flex: 1;
+  max-width: 200px;
+}
+.big-mointoring-right {
+  flex: 1;
+  
+}
 </style>