瀏覽代碼

调整检测器的十字路口数据和检测弹窗数据同步

画安 4 周之前
父節點
當前提交
7a1bbc7108
共有 5 個文件被更改,包括 155 次插入52 次删除
  1. 4 0
      src/api/index.js
  2. 33 7
      src/components/ui/DetectorTable.vue
  3. 36 21
      src/components/ui/IntersectionMapVideos.vue
  4. 76 24
      src/mock/api.js
  5. 6 0
      src/mock/mockAdapter.js

+ 4 - 0
src/api/index.js

@@ -107,6 +107,10 @@ export const apiGetCrossingDetailData = (id, { iconMode } = {}) =>
 export const apiGetCrossingTopCharts = () =>
   http.get('/crossing/top-charts')
 
+// cancelDuplicate:false —— 画布与表格都会轮询同一接口,让两路并发不互相取消
+export const apiGetDetectorMonitorData = (id) =>
+  http.get(`/detector/monitor/${id || 'default'}`, { cancelDuplicate: false })
+
 export const apiGetOverviewTopCharts = () =>
   http.get('/overview/top-charts')
 

+ 33 - 7
src/components/ui/DetectorTable.vue

@@ -30,19 +30,45 @@
 </template>
 
 <script>
+import { apiGetDetectorMonitorData } from '@/api';
+
 export default {
   name: "DetectorMonitorTable",
+  props: {
+    intersectionId: { type: String, default: '' },
+    pollIntervalMs: { type: Number, default: 5000 },
+  },
   data() {
     return {
       timeTicks: [180, 150, 120, 90, 60, 30],
-      tableData: [
-        { id: 1, name: "东左转", flow: "50", occupancy: "25%" },
-        { id: 2, name: "东直行", flow: "60", occupancy: "35%" },
-        { id: 3, name: "东直行", flow: "70", occupancy: "25%" },
-        { id: 4, name: "西直行", flow: "58", occupancy: "35%" },
-      ]
+      tableData: [],
     };
-  }
+  },
+  mounted() {
+    // pollTimer 直接挂在实例上(不进 data 避免无谓的响应式开销)
+    this.pollTimer = null;
+    this.fetchData();
+    this.pollTimer = setInterval(this.fetchData, this.pollIntervalMs);
+  },
+  beforeDestroy() {
+    if (this.pollTimer) {
+      clearInterval(this.pollTimer);
+      this.pollTimer = null;
+    }
+  },
+  methods: {
+    async fetchData() {
+      try {
+        const res = await apiGetDetectorMonitorData(this.intersectionId);
+        if (res) {
+          if (Array.isArray(res.tableData)) this.tableData = res.tableData;
+          if (Array.isArray(res.timeTicks)) this.timeTicks = res.timeTicks;
+        }
+      } catch (e) {
+        console.warn('[DetectorTable] fetchData failed:', e);
+      }
+    },
+  },
 };
 </script>
 

+ 36 - 21
src/components/ui/IntersectionMapVideos.vue

@@ -60,6 +60,7 @@
 import Konva from 'konva';
 import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
+import { apiGetDetectorMonitorData } from '@/api';
 
 // 检测器锚点 = 原摄像机所在的 world 坐标(armNode 局部 (-80,-190) 经 arm 旋转后)。
 // 图标位于锚点上方,下方一行 [流量/占有率(右对齐) ①]。
@@ -206,7 +207,7 @@ export default {
     this.initResizeObserver();
   },
   beforeDestroy() {
-    this.stopDetectorFluctuation();
+    this.stopDetectorPolling();
     this.closeDetectorDialog();
     if (this.resizeObserver) this.resizeObserver.disconnect();
     if (this.stage) this.stage.destroy();
@@ -454,21 +455,7 @@ export default {
       });
     },
 
-    /** 检测器模式:每 5s 让 flow ±15%、occupancy ±5 绝对值围绕基础值随机走步 */
-    fluctuateDetectors() {
-      ['N', 'E', 'S', 'W'].forEach(dir => {
-        const base = this.detectorBase[dir];
-        const node = this.detectorNodes[dir];
-        if (!base || !node) return;
-        const flow = Math.max(0, Math.round(base.flow + (Math.random() - 0.5) * 2 * base.flow * 0.15));
-        const occupancy = Math.max(0, Math.min(100, Math.round(base.occupancy + (Math.random() - 0.5) * 10)));
-        node.flowText.text(`流量:${flow}`);
-        node.occText.text(`占有率:${occupancy}%`);
-      });
-      if (this.layer) this.layer.batchDraw();
-    },
-
-    /** 用基础值刷一次显示文本(进入检测器模式或 mapData 切换时调用) */
+    /** 用 detectorBase 当前值刷一次显示文本 */
     updateDetectorTexts() {
       ['N', 'E', 'S', 'W'].forEach(dir => {
         const base = this.detectorBase[dir];
@@ -480,12 +467,31 @@ export default {
       if (this.layer) this.layer.batchDraw();
     },
 
-    startDetectorFluctuation() {
+    /** 同源轮询:拉 apiGetDetectorMonitorData,把 armsDetector 写回 detectorBase 并刷新文字。
+     *  与 DetectorTable 用同一接口,画布每 5s 跳到与表格同源的当前桶值。 */
+    async pollDetectorData() {
+      try {
+        const id = this.getIntersectionId();
+        const res = await apiGetDetectorMonitorData(id);
+        const arms = res && res.armsDetector;
+        if (!arms) return;
+        ['N', 'E', 'S', 'W'].forEach(dir => {
+          const d = arms[dir];
+          if (d) this.detectorBase[dir] = { flow: d.flow, occupancy: d.occupancy };
+        });
+        this.updateDetectorTexts();
+      } catch (e) {
+        console.warn('[IntersectionMapVideos] poll detector failed:', e);
+      }
+    },
+
+    startDetectorPolling() {
       if (this.detectorTimer) return;
-      this.detectorTimer = setInterval(this.fluctuateDetectors, 5000);
+      this.pollDetectorData();  // 进入检测器模式立即拉一次,避免空 5s 等待
+      this.detectorTimer = setInterval(this.pollDetectorData, 5000);
     },
 
-    stopDetectorFluctuation() {
+    stopDetectorPolling() {
       if (this.detectorTimer) {
         clearInterval(this.detectorTimer);
         this.detectorTimer = null;
@@ -502,14 +508,22 @@ export default {
       });
       if (this.layer) this.layer.batchDraw();
       if (showDetector) {
-        this.startDetectorFluctuation();
+        this.startDetectorPolling();
         this.openDetectorDialog();
       } else {
-        this.stopDetectorFluctuation();
+        this.stopDetectorPolling();
         this.closeDetectorDialog();
       }
     },
 
+    /** 从 mapData 推导出当前路口 id(detectors / cameras 数组里都带 intersectionId) */
+    getIntersectionId() {
+      const m = this.mapData || {};
+      if (Array.isArray(m.detectors) && m.detectors.length) return m.detectors[0].intersectionId || '';
+      if (Array.isArray(m.cameras) && m.cameras.length) return m.cameras[0].intersectionId || '';
+      return '';
+    },
+
     /** 检测器模式下打开弹窗:DetectorTable 居中展示「检测器运行数据监视」 */
     openDetectorDialog() {
       if (!this.dialogManager || typeof this.dialogManager.openDialog !== 'function') return;
@@ -520,6 +534,7 @@ export default {
         width: 620,
         height: 360,
         noPadding: false,
+        data: { intersectionId: this.getIntersectionId() },
         // 用户手动关闭弹窗时把按钮切回视频模式,保持二者状态同步
         onClose: () => {
           if (this.displayMode === 'detector') this.displayMode = 'video';

+ 76 - 24
src/mock/api.js

@@ -141,40 +141,68 @@ function _camerasToArmTypes(cameras) {
 }
 
 /**
- * 生成检测器模拟数据:四方向各 1 个,编号 1-4 顺时针(N=1, E=2, S=3, W=4)。
- * flow / occupancy 给基础值;客户端在检测器模式下会基于此值做 ±15% / ±5 的随机走步。
+ * 检测器单一数据源:4 方向各 1 条(与画布 ① ② ③ ④ 一一对应)。
+ * 编号 1=北、2=东、3=南、4=西。
+ * bucketIdx(5s 桶)决定确定性噪声,同桶内多次调用返回相同值,让画布与表格读数同步。
  */
-function _makeDetectors(id, name, seed) {
+function _detectorBucketSnapshot(id, bucketIdx, name) {
+  const seed = _idSeed(id || '')
   const dirs = [
-    { dir: 'N', label: '北进口', index: 1 },
-    { dir: 'E', label: '东进口', index: 2 },
-    { dir: 'S', label: '南进口', index: 3 },
-    { dir: 'W', label: '西进口', index: 4 },
+    { dir: 'N', label: '北', index: 1 },
+    { dir: 'E', label: '东', index: 2 },
+    { dir: 'S', label: '南', index: 3 },
+    { dir: 'W', label: '西', index: 4 },
   ]
-  return dirs.map(p => ({
-    intersection: name || id,
-    intersectionId: id,
-    detectorId: `DT${(id || '000').slice(-6)}_${String(p.index).padStart(2, '0')}`,
-    index: p.index,
-    position: p.label,
-    direction: p.dir,
-    flow: 600 + ((seed * (p.index + 7)) % 1500),       // 600~2100
-    occupancy: 15 + ((seed * (p.index + 3)) % 60),     // 15~75 (%)
-    enabled: true,
-  }))
+  return dirs.map(d => {
+    const i = d.index
+    const baseFlow = 60 + ((seed * (i * 7 + 13)) % 160)   // 60~220
+    const baseOcc = 15 + ((seed * (i * 11 + 17)) % 50)    // 15~65
+    // 桶内确定性噪声(无 Math.random,保证同 bucketIdx 多次调用结果一致)
+    const flowNoise01 = (((seed ^ (i * 257) ^ (bucketIdx * 9176)) >>> 0) % 10000) / 10000
+    const occNoise01  = (((seed ^ (i * 521) ^ (bucketIdx * 4093)) >>> 0) % 10000) / 10000
+    const flow = Math.max(0, Math.round(baseFlow * (1 + (flowNoise01 - 0.5) * 0.3)))       // ±15%
+    const occupancy = Math.max(0, Math.min(100, Math.round(baseOcc + (occNoise01 - 0.5) * 10))) // ±5
+    return {
+      intersection: name || id,
+      intersectionId: id,
+      detectorId: `DT${(id || '000').slice(-6)}_${String(i).padStart(2, '0')}`,
+      index: i,
+      position: `${d.label}进口`,
+      direction: d.dir,
+      name: `${d.label}进口`,
+      flow,
+      occupancy,
+      enabled: true,
+    }
+  })
 }
 
-/** 从检测器列表推导各方向 { index, flow, occupancy } */
-function _detectorsToArmConfig(detectors) {
+/** 4 条 lane → 4 方向 armsDetector(一一对应,无需挑选) */
+function _bucketArmsDetector(lanes) {
   const out = { N: null, S: null, E: null, W: null }
-  detectors.forEach(d => {
-    if (d.direction && !out[d.direction]) {
-      out[d.direction] = { index: d.index, detectorId: d.detectorId, flow: d.flow, occupancy: d.occupancy }
+  for (const lane of lanes) {
+    if (!out[lane.direction]) {
+      out[lane.direction] = {
+        index: lane.index,
+        detectorId: lane.detectorId,
+        flow: lane.flow,
+        occupancy: lane.occupancy,
+      }
     }
-  })
+  }
   return out
 }
 
+// 保留旧入口名,让调用方零修改(_makeIntersectionConfig 等仍能用)
+function _makeDetectors(id, name /* seed 不再使用:bucket 自己用 _idSeed */) {
+  const bucketIdx = Math.floor(Date.now() / 5000)
+  return _detectorBucketSnapshot(id, bucketIdx, name)
+}
+
+function _detectorsToArmConfig(detectors) {
+  return _bucketArmsDetector(detectors)
+}
+
 function _makeIntersectionConfig(id, name, { fixedNsGreen, iconMode = 'default' } = {}) {
   const phases = ['南北直行', '东西直行', '北单放', '东单放']
   const nsGreen = fixedNsGreen !== undefined ? fixedNsGreen : false
@@ -1094,6 +1122,30 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
 }
 
 /**
+ * GET /api/detector/monitor/:id — 检测器运行数据监视
+ * 返回单一数据源的两种视图:
+ *   - armsDetector: { N/E/S/W → {index, flow, occupancy} } 给画布 ① ② ③ ④ 用
+ *   - tableData:    8 条 lane-level 数据 给弹窗表格用
+ * 同 5s 桶内重复调用返回相同值(确定性噪声),保证画布和弹窗轮询在同一窗口内值一致。
+ */
+export async function apiGetDetectorMonitorData(id) {
+  await delay(150)
+  const bucketIdx = Math.floor(Date.now() / 5000)
+  const lanes = _detectorBucketSnapshot(id, bucketIdx)
+  const armsDetector = _bucketArmsDetector(lanes)
+  return ok({
+    timeTicks: [180, 150, 120, 90, 60, 30],
+    tableData: lanes.map(l => ({
+      id: l.index,
+      name: l.name,
+      flow: l.flow,
+      occupancy: `${l.occupancy}%`,
+    })),
+    armsDetector,
+  })
+}
+
+/**
  * GET /api/crossing/top-charts — 路口Tab顶部圆环图(动态波动)
  */
 export async function apiGetCrossingTopCharts() {

+ 6 - 0
src/mock/mockAdapter.js

@@ -201,6 +201,12 @@ mock.onGet('/crossing/top-charts').reply(async () => {
   return [200, res]
 })
 
+mock.onGet(/\/detector\/monitor\/(.+)/).reply(async (config) => {
+  const id = config.url.match(/\/detector\/monitor\/(.+)/)[1]
+  const res = await mockApi.apiGetDetectorMonitorData(id)
+  return [200, res]
+})
+
 mock.onGet('/overview/top-charts').reply(async () => {
   const res = await mockApi.apiGetOverviewTopCharts()
   return [200, res]