|
|
@@ -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>
|