Kaynağa Gözat

路口详情:摄像头图标点击弹独立视频弹窗,2×2排布右侧控制区,可拖放/自适应multi-view;切检测器/视频互关对方弹窗
- 新增 CameraVideoDialog.vue(下拉切方向 + xgplayer,clamp+cqw 自适应);DashboardLayout.vue 注册
- IntersectionMapVideos.vue:删四角遮罩,摄像头图标 click + 反向旋转;新增
openCameraDialog/closeAllCameraDialogs/repositionOpenCameraDialogs/_calcCameraDialogRect;applyDisplayMode
切模式时互关对方弹窗
- 5 处调用方移除 :videoUrls:CrossingDetailPanel / CrossingPanel / SecurityRoutePanel{,Switch,SwitchSmall}
- mock_data.json:JNC000220 改成 4 路全枪机 + 满 4 车道,避免球机被误认作检测器图标

画安 3 hafta önce
ebeveyn
işleme
155d0e4843

+ 168 - 0
src/components/ui/CameraVideoDialog.vue

@@ -0,0 +1,168 @@
+<template>
+  <div class="camera-video-dialog">
+    <div class="header-row">
+      <DropdownSelect
+        v-model="currentDir"
+        :options="dirOptions"
+        placeholder="选择方向"
+        size="auto"
+        class="dir-select"
+      />
+    </div>
+    <div class="player-wrap">
+      <XgVideoPlayer
+        v-if="videoSrc"
+        :key="videoSrc"
+        :src="videoSrc"
+        :autoplay="true"
+      />
+      <div v-else class="empty">{{ loading ? '加载中…' : '暂无视频' }}</div>
+    </div>
+  </div>
+</template>
+
+<script>
+import DropdownSelect from '@/components/ui/DropdownSelect.vue';
+import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
+import { apiGetCrossingDetailData } from '@/api';
+
+const DIR_LABEL = { N: '北进口', E: '东进口', S: '南进口', W: '西进口' };
+const POSITION_TO_DIR = { '北进口': 'N', '东进口': 'E', '南进口': 'S', '西进口': 'W' };
+// mock 用 nw/ne/sw/se 四角键,按方向顺时针映射到一个角
+const DIR_TO_CORNER = { N: 'nw', E: 'ne', S: 'se', W: 'sw' };
+
+export default {
+  name: 'CameraVideoDialog',
+  components: { DropdownSelect, XgVideoPlayer },
+  props: {
+    intersectionId: { type: [String, Number], default: '' },
+    initialDir: { type: String, default: 'N' },
+    // 父组件直接传入当前路口的摄像头列表(来自 mapData.cameras),避免再发请求拿
+    cameras: { type: Array, default: () => [] },
+  },
+  data() {
+    return {
+      currentDir: this.initialDir,
+      cornerVideos: null,
+      cameraList: this.cameras.slice(),
+      loading: false,
+    };
+  },
+  computed: {
+    dirOptions() {
+      // 优先用父组件传入的 cameras;过滤启用且 cameraType > 0 的
+      const list = (this.cameraList || []).filter(c => {
+        if (c.enabled === false) return false;
+        const t = c.cameraType;
+        // mock 中是字符串 '枪机'/'球机';后端可能返回数字 1/2
+        return t === '枪机' || t === '球机' || t === 1 || t === 2;
+      });
+      const opts = list.map(c => {
+        const dir = POSITION_TO_DIR[c.position];
+        return dir ? { label: c.position || DIR_LABEL[dir], value: dir } : null;
+      }).filter(Boolean);
+      // 如果没拿到 cameras(独立场景),退化成 4 个固定方向
+      if (!opts.length) {
+        return ['N', 'E', 'S', 'W'].map(d => ({ label: DIR_LABEL[d], value: d }));
+      }
+      return opts;
+    },
+    videoSrc() {
+      if (!this.cornerVideos) return '';
+      const corner = DIR_TO_CORNER[this.currentDir] || 'nw';
+      const v = this.cornerVideos[corner];
+      return typeof v === 'string' ? v : (v && v.url) || '';
+    },
+  },
+  watch: {
+    intersectionId: {
+      immediate: true,
+      handler(id) {
+        if (id) this.loadDetail(id);
+      },
+    },
+    initialDir(d) {
+      if (d) this.currentDir = d;
+    },
+    cameras(arr) {
+      this.cameraList = (arr || []).slice();
+    },
+  },
+  methods: {
+    async loadDetail(id) {
+      this.loading = true;
+      try {
+        const data = await apiGetCrossingDetailData(id, { iconMode: 'simple' });
+        const cv = data && data.currentRoute && data.currentRoute.cornerVideos;
+        this.cornerVideos = cv || null;
+        // 父组件没传 cameras 时从 detail 兜底取
+        if (!this.cameras.length && data && data.intersectionData && Array.isArray(data.intersectionData.cameras)) {
+          this.cameraList = data.intersectionData.cameras;
+        }
+      } catch (e) {
+        console.warn('[CameraVideoDialog] load detail failed:', e);
+        this.cornerVideos = null;
+      } finally {
+        this.loading = false;
+      }
+    },
+  },
+};
+</script>
+
+<style scoped>
+.camera-video-dialog {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: clamp(2px, 0.8cqw, 6px);
+  padding: clamp(2px, 0.8cqw, 8px);
+  color: #e0e6f1;
+  box-sizing: border-box;
+  /* 用 cqw 换算字号供子组件 size="auto" 继承(DropdownSelect 走父级 font-size) */
+  font-size: clamp(9px, 1.6cqw, 13px);
+  line-height: 1.2;
+}
+
+.header-row {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.dir-select {
+  flex: 1;
+  min-width: 0;
+}
+
+.player-wrap {
+  flex: 1;
+  min-height: 0;
+  background: #000;
+  border: 1px solid rgba(68, 138, 255, 0.3);
+  border-radius: 2px;
+  overflow: hidden;
+  position: relative;
+}
+
+.empty {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #758599;
+  font-size: clamp(9px, 1.6cqw, 13px);
+}
+
+.player-wrap >>> .xg-video-player,
+.player-wrap >>> .xgplayer {
+  width: 100% !important;
+  height: 100% !important;
+}
+
+.player-wrap >>> .xgplayer video {
+  object-fit: cover;
+}
+</style>

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

@@ -2,7 +2,7 @@
     <div class="crossing-detail-panel">
         <div class="detail-panel-left">
             <div class="intersection-video-wrap">
-                <IntersectionMapVideos :mapData="intersectionData" :videoUrls="currentRoute.cornerVideos" />
+                <IntersectionMapVideos :mapData="intersectionData" />
             </div>
             <div class="signal-timing-wrap">
                 <div class="header">

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

@@ -1,7 +1,7 @@
 <template>
     <div class="crossing-panel">
         <div class="intersection-video-wrap">
-            <IntersectionMapVideos :mapData="intersectionData" :videoUrls="currentRoute.cornerVideos" />
+            <IntersectionMapVideos :mapData="intersectionData" />
         </div>
         <div class="signal-timing-wrap">
             <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />

+ 132 - 201
src/components/ui/IntersectionMapVideos.vue

@@ -5,60 +5,11 @@
     <div v-show="toggleVisible" class="display-mode-toggle" :style="toggleStyle">
       <SegmentedRadio v-model="displayMode" :options="displayModeOptions" size="auto" />
     </div>
-
-    <div class="corner-videos-overlay" v-if="hasAnyVideo" :style="{ width: stageWidth + 'px', height: stageHeight + 'px' }">
-      
-      <div v-if="videoUrls.nw" class="video-corner top-left">
-        <template v-if="activeVideos.nw">
-          <XgVideoPlayer :src="videoUrls.nw" :autoplay="true" />
-          <div class="close-btn" title="关闭视频" @click="closeVideo('nw')">✕</div>
-        </template>
-        <div v-else class="empty-state" @click="openVideo('nw')" title="点击关联视频">
-          <span class="empty-tag">关联视频</span>
-          <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
-        </div>
-      </div>
-
-      <div v-if="videoUrls.ne" class="video-corner top-right">
-        <template v-if="activeVideos.ne">
-          <XgVideoPlayer :src="videoUrls.ne" :autoplay="true" />
-          <div class="close-btn" title="关闭视频" @click="closeVideo('ne')">✕</div>
-        </template>
-        <div v-else class="empty-state" @click="openVideo('ne')" title="点击关联视频">
-          <span class="empty-tag">关联视频</span>
-          <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
-        </div>
-      </div>
-
-      <div v-if="videoUrls.sw" class="video-corner bottom-left">
-        <template v-if="activeVideos.sw">
-          <XgVideoPlayer :src="videoUrls.sw" :autoplay="true" />
-          <div class="close-btn" title="关闭视频" @click="closeVideo('sw')">✕</div>
-        </template>
-        <div v-else class="empty-state" @click="openVideo('sw')" title="点击关联视频">
-          <span class="empty-tag">关联视频</span>
-          <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
-        </div>
-      </div>
-
-      <div v-if="videoUrls.se" class="video-corner bottom-right">
-        <template v-if="activeVideos.se">
-          <XgVideoPlayer :src="videoUrls.se" :autoplay="true" />
-          <div class="close-btn" title="关闭视频" @click="closeVideo('se')">✕</div>
-        </template>
-        <div v-else class="empty-state" @click="openVideo('se')" title="点击关联视频">
-          <span class="empty-tag">关联视频</span>
-          <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
-        </div>
-      </div>
-
-    </div>
   </div>
 </template>
 
 <script>
 import Konva from 'konva';
-import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
 import { apiGetDetectorMonitorData } from '@/api';
 
@@ -118,7 +69,6 @@ function loadSvgImage(svgUrl, fillColor) {
 export default {
   name: 'IntersectionMapVideos',
   components: {
-    XgVideoPlayer,
     SegmentedRadio,
   },
   inject: {
@@ -126,18 +76,11 @@ export default {
     dialogManager: { default: null },
   },
   props: {
-    // 1. 路口数字孪生数据
+    // 路口数字孪生数据
     mapData: {
       type: Object,
       required: true
     },
-    // 2. 【新增】:四路视频地址配置
-    videoUrls: {
-      type: Object,
-      default: () => ({
-        nw: '', ne: '', sw: '', se: ''
-      })
-    }
   },
   data() {
     return {
@@ -160,7 +103,6 @@ export default {
       },
       stageWidth: 900,  // 当前画布缩放后的真实宽度
       stageHeight: 900, // 当前画布缩放后的真实高度
-      activeVideos: { nw: false, ne: false, sw: false, se: false },
       // 视频/检测器 切换
       displayMode: 'video',
       displayModeOptions: [
@@ -183,14 +125,6 @@ export default {
         transformOrigin: 'top right',
       };
     },
-    // 判断是否传入了至少一个视频,如果没有,直接不渲染遮罩层提升性能
-    hasAnyVideo() {
-      if (!this.videoUrls) return false;
-      return ['nw', 'ne', 'sw', 'se'].some(corner => {
-        const v = this.videoUrls[corner];
-        return v && (typeof v === 'string' ? v : v.url);
-      });
-    }
   },
   mounted() {
     this.initKonvaStage();
@@ -203,6 +137,7 @@ export default {
   beforeDestroy() {
     this.stopDetectorPolling();
     this.closeDetectorDialog();
+    this.closeAllCameraDialogs();
     if (this.resizeObserver) this.resizeObserver.disconnect();
     if (this.stage) this.stage.destroy();
   },
@@ -222,12 +157,6 @@ export default {
     },
   },
   methods: {
-    openVideo(corner) {
-      this.$set(this.activeVideos, corner, true);
-    },
-    closeVideo(corner) {
-      this.$set(this.activeVideos, corner, false);
-    },
     // ================= 以下为原有的 Konva 绘制逻辑,完全保持不变 =================
     initKonvaStage() {
       const { stageSize, halfRoad, roadWidth } = this.sizeConfig;
@@ -298,6 +227,8 @@ export default {
       if (this.displayMode === 'detector') {
         this.openDetectorDialog();
       }
+      // 已打开的摄像头视频弹窗按 2×2 网格重排(保留用户的下拉/拖拽不重置)
+      this.repositionOpenCameraDialogs();
     },
 
     createRoadArm(x, y, rotation) {
@@ -522,9 +453,10 @@ export default {
       if (showDetector) {
         this.startDetectorPolling();
         this.openDetectorDialog();
+        this.closeAllCameraDialogs();   // 切到检测器:关掉所有摄像头视频弹窗
       } else {
         this.stopDetectorPolling();
-        this.closeDetectorDialog();
+        this.closeDetectorDialog();     // 切回视频:关检测器弹窗(视频弹窗按需点开,无需自动开)
       }
     },
 
@@ -581,21 +513,133 @@ export default {
       this.dialogManager.closeDialog(`detector-monitor-${this._uid}`);
     },
 
-    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 }));
+    /** 摄像头视频弹窗:每个方向一个独立弹窗(id 含 dir + _uid),最多 4 个并存。
+     *  默认按 2×2 网格摆在 .detail-panel-right 区域内(N→左上 / E→右上 / W→左下 / S→右下),
+     *  跟随 multi-view 布局变化重排,但用户拖拽/缩放/下拉切换都保留不重置。 */
+    openCameraDialog(dir) {
+      if (!this.dialogManager || typeof this.dialogManager.openDialog !== 'function') return;
+
+      const DIR_LABEL = { N: '北进口', E: '东进口', S: '南进口', W: '西进口' };
+      const cfg = {
+        id: `camera-video-${this._uid}-${dir}`,
+        title: `摄像头-${DIR_LABEL[dir] || dir}`,
+        component: 'CameraVideoDialog',
+        noPadding: true,   // 关掉 SmartDialog 默认 padding,组件内自管
+        resizable: true,
+        draggable: true,
+        // 调小弹窗最小尺寸,allow multi-view 4 宫格里的小弹窗不被卡死在 200×150
+        minWidth: 80,
+        minHeight: 60,
+        data: {
+          intersectionId: this.getIntersectionId(),
+          initialDir: dir,
+          cameras: (this.mapData && this.mapData.cameras) || [],
+        },
+      };
+
+      const rect = this._calcCameraDialogRect(dir);
+      if (rect) {
+        cfg.center = false;
+        cfg.position = rect.position;
+        cfg.width = rect.width;
+        cfg.height = rect.height;
+      } else {
+        cfg.width = 380;
+        cfg.height = 280;
+      }
+
+      this.dialogManager.openDialog(cfg);
+    },
+
+    closeAllCameraDialogs() {
+      if (!this.dialogManager || typeof this.dialogManager.closeDialog !== 'function') return;
+      ['N', 'E', 'S', 'W'].forEach(dir => {
+        this.dialogManager.closeDialog(`camera-video-${this._uid}-${dir}`);
+      });
+    },
+
+    /** 仅更新位置/尺寸(不传 data),避免重置用户在弹窗内的下拉选择 */
+    repositionOpenCameraDialogs() {
+      if (!this.dialogManager || typeof this.dialogManager.getDialogs !== 'function') return;
+      const dialogs = this.dialogManager.getDialogs();
+      const prefix = `camera-video-${this._uid}-`;
+      dialogs.forEach(d => {
+        const idStr = String(d.id);
+        if (!idStr.startsWith(prefix) || !d.visible) return;
+        const dir = idStr.slice(prefix.length);
+        const rect = this._calcCameraDialogRect(dir);
+        if (!rect) return;
+        this.dialogManager.openDialog({
+          id: idStr,
+          center: false,
+          position: rect.position,
+          width: rect.width,
+          height: rect.height,
+        });
+      });
+    },
+
+    /** 把 dir 映射成 .detail-panel-right 内的 2×2 单元格,返回设计坐标系下的 position/width/height。
+     *  N→左上, E→右上, W→左下, S→右下,与画面方位一致。 */
+    _calcCameraDialogRect(dir) {
+      const DESIGN_WIDTH = 1920;
+      const scale = window.innerWidth / DESIGN_WIDTH;
+      const root = this.$el && this.$el.closest && this.$el.closest('.crossing-detail-panel');
+      const rightPanel = root && root.querySelector(':scope > .detail-panel-right');
+      if (!(rightPanel && scale > 0)) return null;
+      const rect = rightPanel.getBoundingClientRect();
+      if (!(rect.width > 0 && rect.height > 0)) return null;
+
+      const DIR_TO_CELL = {
+        N: { col: 0, row: 0 },
+        E: { col: 1, row: 0 },
+        W: { col: 0, row: 1 },
+        S: { col: 1, row: 1 },
+      };
+      const GAP = 4;       // 单元格间距(设计像素)
+
+      // 单元尺寸严格按右侧面板的 1/2 切分,不加最小兜底
+      // —— 多窗口下面板可能只剩 ~300×200 设计像素,加 MIN 反而让 4 弹窗溢出
+      const wDesign = rect.width / scale;
+      const hDesign = rect.height / scale;
+      const cellW = Math.max(40, Math.floor((wDesign - GAP) / 2));
+      const cellH = Math.max(40, Math.floor((hDesign - GAP) / 2));
+      const cell = DIR_TO_CELL[dir] || { col: 0, row: 0 };
+      return {
+        position: {
+          x: rect.left / scale + cell.col * (cellW + GAP),
+          y: rect.top / scale + cell.row * (cellH + GAP),
+        },
+        width: cellW,
+        height: cellH,
+      };
+    },
+
+    createCameraIcon(type, x, y, dir, armRotation) {
+      // 反向旋转:让 4 个方向的摄像头图标都正立显示(与检测器图标一致),
+      // 否则 E/S/W 会随 arm 旋转 90/180/270,倒立或横向的 枪机 视觉上像球机/检测器。
+      const group = new Konva.Group({ x, y, rotation: -armRotation });
+      const HIT = 18; // 加大点击命中区,避免只有线条上能点
+
+      if (type === 1) {
+        group.add(new Konva.Line({ points: [-16, -30, 16, -30], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round', hitStrokeWidth: HIT }));
+        group.add(new Konva.Line({ points: [0, -30, 0, -10], stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
         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 }));
+        body.add(new Konva.Rect({ x: -16, y: -8, width: 32, height: 16, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 2, hitStrokeWidth: HIT }));
+        body.add(new Konva.Rect({ x: 16, y: -4, width: 6, height: 8, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 1, hitStrokeWidth: HIT }));
         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 }));
+      } 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, hitStrokeWidth: HIT }));
+        group.add(new Konva.Circle({ x: 0, y: -12, radius: 5, stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
+        group.add(new Konva.Line({ points: [-10, 0, 10, 0], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round', hitStrokeWidth: HIT }));
+        group.add(new Konva.Line({ points: [0, 0, 0, 6], stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
       }
+
+      group.listening(true);
+      const stage = () => this.stage && this.stage.container();
+      group.on('mouseenter', () => { const c = stage(); if (c) c.style.cursor = 'pointer'; });
+      group.on('mouseleave', () => { const c = stage(); if (c) c.style.cursor = 'default'; });
+      group.on('click tap', () => this.openCameraDialog(dir));
       return group;
     },
 
@@ -609,7 +653,8 @@ export default {
 
         if (armNode.cameraNode) armNode.cameraNode.destroy();
         if (armData.cameraType > 0) {
-          const cam = this.createCameraIcon(armData.cameraType, -80, -190);
+          const armRotation = armNode.rotation() || 0;
+          const cam = this.createCameraIcon(armData.cameraType, -80, -190, dir, armRotation);
           armNode.add(cam);
           armNode.cameraNode = cam;
         }
@@ -735,118 +780,4 @@ export default {
   padding: 2px 6px;
   line-height: 1.3;
 }
-
-/* ================= 视频遮罩与挂件 ================= */
-.corner-videos-overlay {
-  position: absolute;
-  /* 【核心修改】:和 Canvas 一样,使用绝对居中对齐 */
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  z-index: 10;
-  pointer-events: none; 
-  border-radius: 2px;
-  overflow: hidden;
-}
-
-.video-corner {
-  position: absolute;
-  /* (900-320)/2 / 900 = 32.222% */
-  width: 32.222%;
-  height: 32.222%;
-  background: #000;
-  pointer-events: auto;
-
-  box-sizing: border-box;
-  border: 1px solid rgba(68, 138, 255, 0.4);
-  overflow: hidden;
-  border-radius: 2px;
-}
-
-/* 四角贴死四个角 */
-.top-left { top: -1px; left: -1px; }
-.top-right { top: -1px; right: -1px; }
-.bottom-left { bottom: -1px; left: -1px; }
-.bottom-right { bottom: -1px; right: -1px; }
-
-/* ================= 关联视频空状态 & 关闭按钮 ================= */
-.close-btn {
-  position: absolute;
-  top: 4%;
-  right: 4%;
-  background: rgba(0, 0, 0, 0.6);
-  color: #fff;
-  width: 8%;
-  height: 8%;
-  min-width: 14px;
-  min-height: 14px;
-  border-radius: 50%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  cursor: pointer;
-  transition: background 0.3s;
-  z-index: 10;
-  font-size: clamp(8px, 4%, 14px);
-}
-.close-btn:hover { background: rgba(0, 0, 0, 1); }
-
-.empty-state {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  height: 100%;
-  cursor: pointer;
-  transition: all 0.3s ease;
-  background: #112445;
-  border-radius: 2px;
-  overflow: hidden;
-}
-.empty-state:hover {
-  background: rgba(68, 138, 255, 0.1);
-}
-.empty-state:hover .empty-tag {
-  background: rgba(68, 138, 255, 1);
-  box-shadow: 0 0 10px rgba(68, 138, 255, 0.5);
-}
-.empty-state:hover .camera-image {
-  transform: scale(1.05);
-  transition: transform 0.3s ease;
-}
-
-.empty-tag {
-  background: rgba(68, 138, 255, 0.8);
-  color: #fff;
-  padding: 2% 6%;
-  border-radius: 4px;
-  font-size: clamp(8px, 5%, 14px);
-  margin-bottom: 6%;
-  letter-spacing: 1px;
-  transition: all 0.3s ease;
-  white-space: nowrap;
-}
-
-.camera-image {
-  width: 30%;
-  max-width: 50px;
-  height: auto;
-  object-fit: contain;
-  opacity: 0.8;
-}
-
-/* xgplayer 填满角落容器 */
-.video-corner .xg-video-player {
-  width: 100%;
-  height: 100%;
-}
-
-.video-corner >>> .xgplayer {
-  width: 100% !important;
-  height: 100% !important;
-}
-
-.video-corner >>> .xgplayer video {
-  object-fit: cover;
-}
 </style>

+ 1 - 9
src/components/ui/SecurityRoutePanel.vue

@@ -9,15 +9,7 @@
                 <div class="route-name">靖远路与北公路交叉口</div>
                 <div class="monitoring-status">
                     <div class="map-container">
-                        <IntersectionMap 
-                            :mapData="intersectionData" 
-                            :videoUrls="{
-                                nw: require('@/assets/videos/video1.mp4'), // 左上
-                                ne: require('@/assets/videos/video2.mp4'), // 右上
-                                sw: require('@/assets/videos/video2.mp4'), // 左下
-                                se: require('@/assets/videos/video1.mp4')  // 右下
-                            }" 
-                        />
+                        <IntersectionMap :mapData="intersectionData" />
                     </div>
                     <div class="status-info">
                         <span>等级: 一级</span>

+ 1 - 4
src/components/ui/SecurityRoutePanelSwitch.vue

@@ -39,10 +39,7 @@
               
               <div class="map-and-info">
                 <div class="map-container">
-                  <IntersectionMap 
-                    :mapData="intersectionData" 
-                    :videoUrls="route.cornerVideos" 
-                  />
+                  <IntersectionMap :mapData="intersectionData" />
                 </div>
                 
                 <div class="info-action-box">

+ 1 - 4
src/components/ui/SecurityRoutePanelSwitchSmall.vue

@@ -44,10 +44,7 @@
               
               <div class="map-and-info">
                 <div class="map-container">
-                  <IntersectionMap 
-                    :mapData="intersectionData" 
-                    :videoUrls="route.cornerVideos" 
-                  />
+                  <IntersectionMap :mapData="intersectionData" />
                 </div>
                 
                 <div class="info-action-box">

+ 3 - 1
src/layouts/DashboardLayout.vue

@@ -112,6 +112,7 @@ import CrossingMultiView from '@/components/ui/CrossingMultiView.vue';
 import CrossingDetailHeader from '@/components/ui/CrossingDetailHeader.vue';
 import OfflineTip from '@/components/ui/OfflineTip.vue';
 import DetectorTable from '@/components/ui/DetectorTable.vue';
+import CameraVideoDialog from '@/components/ui/CameraVideoDialog.vue';
 import brand from '@/utils/brand';
 
 export default {
@@ -143,7 +144,8 @@ export default {
         CrossingMultiView,
         CrossingDetailHeader,
         OfflineTip,
-        DetectorTable
+        DetectorTable,
+        CameraVideoDialog
     },
     provide() {
         return {

+ 8 - 8
src/mock/mock_data.json

@@ -28215,18 +28215,18 @@
           ]
         },
         "S": {
-          "cameraType": 2,
+          "cameraType": 1,
           "lanes": [
             "U",
             "L",
             "S",
-            null
+            "R"
           ]
         },
         "E": {
-          "cameraType": 2,
+          "cameraType": 1,
           "lanes": [
-            null,
+            "U",
             "L",
             "S",
             "R"
@@ -28235,10 +28235,10 @@
         "W": {
           "cameraType": 1,
           "lanes": [
-            null,
+            "U",
             "L",
             "S",
-            null
+            "R"
           ]
         }
       },
@@ -28261,7 +28261,7 @@
           "cameraId": "CAM000220_02",
           "loginName": "admin_0_02",
           "password": "******",
-          "cameraType": "机",
+          "cameraType": "机",
           "port": 555,
           "ip": "192.168.143.101",
           "enabled": true,
@@ -28273,7 +28273,7 @@
           "cameraId": "CAM000220_03",
           "loginName": "admin_0_03",
           "password": "******",
-          "cameraType": "机",
+          "cameraType": "机",
           "port": 556,
           "ip": "192.168.143.102",
           "enabled": true,