Преглед изворни кода

新增CrossingMultiView.vue组件路口多选分屏功能:支持1~4个路口同时查看;

画安 пре 1 месец
родитељ
комит
cd66a0afea

+ 15 - 1
package-lock.json

@@ -20,7 +20,8 @@
         "three": "^0.183.1",
         "vue": "^2.6.14",
         "vue-awesome-swiper": "^4.1.1",
-        "vue-router": "^3.6.5"
+        "vue-router": "^3.6.5",
+        "vuedraggable": "^2.24.3"
       },
       "devDependencies": {
         "@babel/core": "^7.12.16",
@@ -10892,6 +10893,11 @@
         "websocket-driver": "^0.7.4"
       }
     },
+    "node_modules/sortablejs": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
+      "integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
+    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
@@ -11992,6 +11998,14 @@
         "prettier": "^1.18.2 || ^2.0.0"
       }
     },
+    "node_modules/vuedraggable": {
+      "version": "2.24.3",
+      "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.24.3.tgz",
+      "integrity": "sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==",
+      "dependencies": {
+        "sortablejs": "1.10.2"
+      }
+    },
     "node_modules/watchpack": {
       "version": "2.5.1",
       "resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.5.1.tgz",

+ 2 - 1
package.json

@@ -20,7 +20,8 @@
     "three": "^0.183.1",
     "vue": "^2.6.14",
     "vue-awesome-swiper": "^4.1.1",
-    "vue-router": "^3.6.5"
+    "vue-router": "^3.6.5",
+    "vuedraggable": "^2.24.3"
   },
   "devDependencies": {
     "@babel/core": "^7.12.16",

+ 134 - 94
src/components/ui/CrossingDetailPanel.vue

@@ -41,13 +41,13 @@
 
                     <div class="form-interactive-area" :class="{ 'is-disabled': !isManualMode }">
                         <div class="control-method-content">
-                            <SegmentedRadio v-model="currentMethod" :options="controlMethodOptions" />
+                            <SegmentedRadio v-model="currentMethod" :options="controlMethodOptions" size="auto" />
                         </div>
 
                         <div class="control-scheme">
                             <div class="control-label-wrap">
                                 <span class="control-label">控制方案</span>
-                                <DropdownSelect v-model="currentScheme" :options="schemeOptions" />
+                                <DropdownSelect v-model="currentScheme" :options="schemeOptions" size="auto" />
                             </div>
                             
                             <div class="current-stage">
@@ -89,7 +89,7 @@
                                         <div class="lock-time-option">
                                             <label>
                                                 <input type="radio" v-model="lockTimeType" value="timer" /> 放行
-                                                <DropdownSelect placeholder="锁定时间" v-model="currentLocktime" :options="locktimeOptions"
+                                                <DropdownSelect placeholder="锁定时间" v-model="currentLocktime" :options="locktimeOptions" size="auto"
                                                     @click.native.prevent />
                                                 秒解锁
                                             </label>
@@ -171,26 +171,42 @@ export default {
             this.updateSchemeDataByMethod(newVal);
         }
     },
-    async mounted() {
-        const nodeId = this.$attrs.id || this.id;
-        const data = await apiGetCrossingDetailData(nodeId);
-        if (data) {
-            this.currentRoute = data.currentRoute || {};
-            this.intersectionData = data.intersectionData || {};
-            this.mockPhaseData = data.phaseData || [];
-            this.cycleLength = data.cycleLength || 140;
-            this.currentSec = data.currentTime || 0;
-            this.phaseDiff = data.phaseDiff || 0;
-            this.coordTime = data.coordTime || 0;
-            this.currentStageList = data.stageList || [];
-            this.schemeOptions = data.schemeOptions || [];
-            if (data.currentScheme) this.currentScheme = data.currentScheme;
-            if (data.controlMethodOptions) this.controlMethodOptions = data.controlMethodOptions;
-            if (data.currentMethod) this.currentMethod = data.currentMethod;
-            if (data.locktimeOptions) this.locktimeOptions = data.locktimeOptions;
-        }
+    mounted() {
+        this.initScaleObserver();
+        this.loadData();
+    },
+    beforeDestroy() {
+        if (this._ro) this._ro.disconnect();
     },
     methods: {
+        initScaleObserver() {
+            const ro = new ResizeObserver(entries => {
+                const { width } = entries[0].contentRect;
+                const s = Math.min(width / 1315, 1);
+                this.$el.style.setProperty('--s', s);
+            });
+            ro.observe(this.$el);
+            this._ro = ro;
+        },
+        async loadData() {
+            const nodeId = this.$attrs.id || this.id;
+            const data = await apiGetCrossingDetailData(nodeId);
+            if (data) {
+                this.currentRoute = data.currentRoute || {};
+                this.intersectionData = data.intersectionData || {};
+                this.mockPhaseData = data.phaseData || [];
+                this.cycleLength = data.cycleLength || 140;
+                this.currentSec = data.currentTime || 0;
+                this.phaseDiff = data.phaseDiff || 0;
+                this.coordTime = data.coordTime || 0;
+                this.currentStageList = data.stageList || [];
+                this.schemeOptions = data.schemeOptions || [];
+                if (data.currentScheme) this.currentScheme = data.currentScheme;
+                if (data.controlMethodOptions) this.controlMethodOptions = data.controlMethodOptions;
+                if (data.currentMethod) this.currentMethod = data.currentMethod;
+                if (data.locktimeOptions) this.locktimeOptions = data.locktimeOptions;
+            }
+        },
         // 切换手动控制模式
         toggleManualMode() {
             this.isManualMode = !this.isManualMode;
@@ -266,28 +282,35 @@ export default {
 
 <style scoped>
 .crossing-detail-panel {
+    --s: 1;
     display: flex;
     flex-direction: row;
-    gap: 20px;
-    /* padding: 20px; */
+    gap: clamp(4px, calc(var(--s) * 12px), 12px);
     height: 100%;
     min-height: 0;
+    overflow: hidden;
 }
 
+/* ===== 左侧:还原原始固定 55% 占比 ===== */
 .detail-panel-left {
     display: flex;
     flex-direction: column;
     flex: 0 0 55%;
     min-height: 0;
+    min-width: 0;
 }
 
+/* ===== 右侧:flex 列容器 + 滚动兜底 ===== */
 .detail-panel-right {
     flex: 1;
     min-width: 0;
     min-height: 0;
+    overflow-y: auto;
+    overflow-x: hidden;
+    display: flex;
+    flex-direction: column;
 }
 
-
 .intersection-video-wrap {
     width: 100%;
     min-height: 0;
@@ -299,7 +322,6 @@ export default {
     min-height: 0;
     height: 120px;
     flex-shrink: 0;
-    --s: 1;
     width: 100%;
     min-width: 0;
     background-color: transparent;
@@ -308,35 +330,35 @@ export default {
     display: flex;
     flex-direction: column;
     overflow: hidden;
-    padding: calc(var(--s) * 10px);
+    padding: clamp(3px, calc(var(--s) * 10px), 10px);
 }
 
 .header {
     display: flex;
     justify-content: space-between;
     align-items: center;
-    margin-bottom: calc(var(--s) * 15px);
+    margin-bottom: clamp(2px, calc(var(--s) * 6px), 15px);
     color: #e0e6f1;
     flex-shrink: 0;
 }
 
 .title-area {
-    font-size: calc(var(--s) * 16px);
+    font-size: clamp(9px, calc(var(--s) * 16px), 16px);
 }
 
 .main-title {
-    font-size: calc(var(--s) * 18px);
+    font-size: clamp(10px, calc(var(--s) * 18px), 18px);
     font-weight: bold;
-    margin-right: calc(var(--s) * 10px);
+    margin-right: clamp(4px, calc(var(--s) * 10px), 10px);
 }
 
 .sub-info {
-    font-size: calc(var(--s) * 12px);
+    font-size: clamp(8px, calc(var(--s) * 12px), 12px);
     opacity: 0.8;
 }
 
 .checkbox-area {
-    font-size: calc(var(--s) * 12px);
+    font-size: clamp(8px, calc(var(--s) * 12px), 12px);
     display: flex;
     align-items: center;
     cursor: pointer;
@@ -349,10 +371,10 @@ export default {
 }
 
 .checkbox-mock {
-    width: calc(var(--s) * 14px);
-    height: calc(var(--s) * 14px);
+    width: clamp(10px, calc(var(--s) * 14px), 14px);
+    height: clamp(10px, calc(var(--s) * 14px), 14px);
     border: 1px solid rgba(255, 255, 255, 0.5);
-    margin-right: calc(var(--s) * 6px);
+    margin-right: clamp(3px, calc(var(--s) * 6px), 6px);
     border-radius: 2px;
     display: flex;
     align-items: center;
@@ -368,7 +390,7 @@ export default {
     width: 100%;
     min-width: 0;
     flex: 1;
-    min-height: 80px;
+    min-height: 50px;
     overflow: hidden;
 }
 
@@ -381,21 +403,38 @@ export default {
     font-size: 14px;
 }
 
+/* ===== 右侧表单内层容器 ===== */
+.detail-right-form {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+}
+
+.form-group {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+}
+
 /** 控制方法 */
 .control-method {
     color: #ffffff;
+    flex-shrink: 0;
 }
 
 .control-label-wrap {
     display: flex;
     align-items: center;
-    margin-bottom: 20px;
-    column-gap: 20px;
+    margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
+    column-gap: clamp(4px, calc(var(--s) * 10px), 20px);
 }
 
 .control-label {
-    font-size: 28px;
+    font-size: clamp(12px, calc(var(--s) * 22px), 28px);
     color: #ffffff;
+    white-space: nowrap;
 }
 
 .control-label-wrap span {
@@ -403,7 +442,7 @@ export default {
 }
 
 .operation-btn {
-    font-size: 14px;
+    font-size: clamp(9px, calc(var(--s) * 14px), 14px);
     cursor: pointer;
     user-select: none;
 }
@@ -418,44 +457,51 @@ export default {
     justify-content: space-between;
 }
 
-.control-scheme {
-    margin-top: 20px;
+/* 控制方式按钮组:设置 font-size 供 SegmentedRadio size="auto" 继承 */
+.control-method-content {
+    font-size: clamp(9px, calc(var(--s) * 14px), 14px);
+}
 
+.control-scheme {
+    margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
+    /* 设置 font-size 供 DropdownSelect size="auto" 继承 */
+    font-size: clamp(9px, calc(var(--s) * 14px), 14px);
 }
 
 .lock-time {
-    width: 40%;
+    width: 80%;
     border-radius: 8px;
     box-shadow:
         inset 0px 0px 10px 0px rgba(88, 146, 255, 0.4),
         inset 20px 0px 30px -10px rgba(88, 146, 255, 0.15);
-
 }
 
 .lock-time-label-wrap {
     display: flex;
     align-items: center;
     justify-content: space-between;
-    padding: 10px;
+    padding: clamp(4px, calc(var(--s) * 8px), 10px);
     border-radius: 8px 8px 0 0;
     color: #ffffff;
 }
 
 .lock-time-label {
-    font-size: 16px;
+    font-size: clamp(10px, calc(var(--s) * 16px), 16px);
     color: #ffffff;
 }
 
 .lock-time-options {
     display: flex;
     flex-direction: column;
-    row-gap: 10px;
-    font-size: 14px;
-    padding: 10px;
+    row-gap: clamp(4px, calc(var(--s) * 10px), 10px);
+    font-size: clamp(9px, calc(var(--s) * 14px), 14px);
+    padding: clamp(4px, calc(var(--s) * 10px), 10px);
     color: #ffffff;
 }
 
-.lock-time-option {}
+.lock-time-option {
+    font-size: clamp(9px, calc(var(--s) * 14px), 14px);
+}
 
 .lock-time-close {
     cursor: pointer;
@@ -465,14 +511,13 @@ export default {
     background: linear-gradient(180deg,
             rgba(65, 115, 205, 0.6) 0%,
             rgba(40, 70, 130, 0.1) 100%);
-
     backdrop-filter: blur(10px);
 }
 
 .current-stage {
     background-color: rgba(65, 115, 205, 0.2);
     border: 1px solid #3660a5;
-    margin-bottom: 20px;
+    margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
     display: flex;
     justify-content: center;
 }
@@ -481,29 +526,31 @@ export default {
     display: flex;
     align-items: center;
     justify-content: center;
-    gap: 10px;
-    padding: 32px;
+    flex-wrap: wrap;
+    gap: clamp(4px, calc(var(--s) * 8px), 10px);
+    padding: clamp(6px, calc(var(--s) * 12px), 32px);
     color: #ffffff;
 }
 .current-stage-label {
-    font-size: 14px;
-    width: 100px;
+    font-size: clamp(9px, calc(var(--s) * 14px), 14px);
+    width: auto;
+    white-space: nowrap;
 }
 
 .stage-input {
-    width: 65px;
+    width: clamp(32px, calc(var(--s) * 65px), 65px);
     border: 1px solid rgba(161,190,255,0.7);
     background-color: transparent;
-    padding: 5px;
+    padding: clamp(2px, calc(var(--s) * 5px), 5px);
     color: #ffffff;
     text-align: center;
 }
 
 .phase-box {
     position: relative;
-    width: 65px;         /* 保持原有的固定尺寸 */
-    height: 65px;
-    background: #E6F0FF; 
+    width: clamp(30px, calc(var(--s) * 65px), 65px);
+    height: clamp(30px, calc(var(--s) * 65px), 65px);
+    background: #E6F0FF;
     border-radius: 4px;
     display: flex;
     align-items: center;
@@ -528,25 +575,24 @@ export default {
     left: 0;
     width: 100%;
     height: 100%;
-    background: rgba(30, 106, 255, 0.5); /* 激活时的蒙层颜色 */
-    opacity: 0; 
+    background: rgba(30, 106, 255, 0.5);
+    opacity: 0;
     transition: opacity 0.3s ease;
-    pointer-events: none; /* 防止阻挡点击事件 */
+    pointer-events: none;
 }
 
 .phase-box.is-active::after {
-    opacity: 1; 
+    opacity: 1;
 }
 
 /** 按钮 */
-/* 按钮基础通用样式 */
 .btn {
     display: inline-flex;
     justify-content: center;
     align-items: center;
-    height: 36px;
-    padding: 0 32px;
-    font-size: 14px;
+    height: clamp(22px, calc(var(--s) * 36px), 36px);
+    padding: 0 clamp(10px, calc(var(--s) * 32px), 32px);
+    font-size: clamp(9px, calc(var(--s) * 14px), 14px);
     border-radius: 4px;
     cursor: pointer;
     user-select: none;
@@ -554,36 +600,30 @@ export default {
     box-sizing: border-box;
 }
 
-/* --- 取消按钮 (幽灵按钮) --- */
 .btn-cancel {
     background-color: transparent;
     color: #d1d5db;
     border: 1px solid rgba(130, 150, 190, 0.4);
 }
-
 .btn-cancel:hover {
     color: #ffffff;
     border-color: rgba(130, 150, 190, 0.8);
     background-color: rgba(255, 255, 255, 0.05);
 }
-
 .btn-cancel:active {
     background-color: rgba(255, 255, 255, 0.1);
 }
 
-/* --- 确认按钮 (实心主按钮) --- */
 .btn-confirm {
     background-color: #3b74ff;
     color: #ffffff;
     border: 1px solid #3b74ff;
 }
-
 .btn-confirm:hover {
     background-color: #5a8bff;
     border-color: #5a8bff;
     box-shadow: 0 2px 8px rgba(59, 116, 255, 0.3);
 }
-
 .btn-confirm:active {
     background-color: #265bed;
     border-color: #265bed;
@@ -593,22 +633,27 @@ export default {
 .button-group {
     display: flex;
     justify-content: flex-end;
-    margin-top: 20px;
+    margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
+    flex-shrink: 0;
 }
 
 .button-group>div {
     display: flex;
-    gap: 12px;
+    gap: clamp(4px, calc(var(--s) * 8px), 12px);
 }
 
-/* 禁用状态:未点击手动控制时,使表单只读 */
+/* 禁用状态 */
 .form-interactive-area {
     transition: opacity 0.3s;
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+    overflow-x: hidden;
 }
 
 .form-interactive-area.is-disabled {
     opacity: 0.6;
-    pointer-events: none; /* 核心:禁止鼠标事件,无法点击下拉框、单选和输入框 */
+    pointer-events: none;
 }
 
 /* 当前阶段输入框微调 */
@@ -616,15 +661,15 @@ export default {
     display: flex;
     flex-direction: column;
     align-items: center;
-    gap: 6px;
+    gap: clamp(2px, calc(var(--s) * 4px), 6px);
     position: relative;
 }
 
 .stage-input {
-    width: 65px;
+    width: clamp(32px, calc(var(--s) * 65px), 65px);
     border: 1px solid rgba(161,190,255,0.7);
     background-color: transparent;
-    padding: 5px;
+    padding: clamp(2px, calc(var(--s) * 5px), 5px);
     color: #ffffff;
     text-align: center;
     border-radius: 4px;
@@ -638,10 +683,10 @@ export default {
 
 .stage-item-wrapper .unit {
     position: absolute;
-    bottom: 6px;
-    right: 8px;
+    bottom: clamp(2px, calc(var(--s) * 6px), 6px);
+    right: clamp(3px, calc(var(--s) * 6px), 8px);
     color: #77A1FF;
-    font-size: 12px;
+    font-size: clamp(8px, calc(var(--s) * 12px), 12px);
     pointer-events: none;
 }
 
@@ -654,22 +699,17 @@ export default {
     transform: translateY(-10px);
 }
 
-/* 原有 lock-time 样式调整使其能绝对定位,类似弹窗 */
+/* lock-time 弹窗补充样式 */
 .lock-time {
-    margin-top: 15px;
-    width: 60%;
-    border-radius: 8px;
-    box-shadow:
-        inset 0px 0px 10px 0px rgba(88, 146, 255, 0.4),
-        inset 20px 0px 30px -10px rgba(88, 146, 255, 0.15);
-    background-color: rgba(20, 30, 50, 0.9); /* 增加背景色防止透明穿透 */
+    margin-top: clamp(4px, calc(var(--s) * 10px), 15px);
+    background-color: rgba(20, 30, 50, 0.9);
 }
 
 /* 单选框基础对齐 */
 .lock-time-option label {
     display: flex;
     align-items: center;
-    gap: 8px;
+    gap: clamp(3px, calc(var(--s) * 6px), 8px);
     cursor: pointer;
 }
 </style>

+ 307 - 0
src/components/ui/CrossingMultiView.vue

@@ -0,0 +1,307 @@
+<template>
+    <div class="crossing-multi-view">
+        <!-- 展开模式下的返回栏 -->
+        <div v-if="expandedId" class="expand-toolbar">
+            <span class="back-btn" @click="exitExpand">&larr; 返回多路口视图</span>
+        </div>
+
+        <draggable
+            v-model="localSlots"
+            class="multi-view-grid"
+            :class="gridClass"
+            :style="gridStyle"
+            :animation="300"
+            :swap-threshold="0.5"
+            ghost-class="drag-ghost"
+            chosen-class="drag-chosen"
+            drag-class="drag-active"
+            handle=".drag-handle"
+            :disabled="!!expandedId"
+            @end="onDragEnd"
+        >
+            <div
+                v-for="(slot, index) in visibleSlots"
+                :key="'panel-' + slot.data.id"
+                class="grid-cell"
+                @dblclick="handleDblClick(slot)"
+            >
+                <div class="cell-header">
+                    <div class="drag-handle" title="拖拽换位" v-if="!expandedId">
+                        <span class="drag-icon">&#x2807;</span>
+                    </div>
+                    <span class="cell-title">{{ slot.data.label || slot.data.name || '' }}</span>
+                    <span class="cell-close" @click.stop="handleRemove(slot.data.id)">&times;</span>
+                </div>
+                <div class="cell-body">
+                    <CrossingDetailPanel
+                        :id="slot.data.id"
+                        v-bind="slot.data"
+                    />
+                </div>
+            </div>
+        </draggable>
+    </div>
+</template>
+
+<script>
+import draggable from 'vuedraggable';
+import CrossingDetailPanel from '@/components/ui/CrossingDetailPanel.vue';
+
+export default {
+    name: 'CrossingMultiView',
+    components: {
+        draggable,
+        CrossingDetailPanel
+    },
+    props: {
+        crossings: {
+            type: Array,
+            default: () => []
+        },
+        maxSlots: {
+            type: Number,
+            default: 1,
+            validator: v => v >= 1 && v <= 6
+        },
+        onRemove: {
+            type: Function,
+            default: null
+        },
+        onReorder: {
+            type: Function,
+            default: null
+        }
+    },
+    data() {
+        return {
+            localSlots: [],
+            expandedId: null
+        };
+    },
+    computed: {
+        // 实际面板数量
+        panelCount() {
+            return this.crossings.length;
+        },
+        visibleSlots() {
+            if (this.expandedId) {
+                const found = this.localSlots.find(
+                    s => s.data && s.data.id === this.expandedId
+                );
+                return found ? [found] : this.localSlots;
+            }
+            return this.localSlots;
+        },
+        // 根据实际选中数量决定网格:1→1x1, 2→2x1, 3~4→2x2
+        gridCols() {
+            if (this.expandedId) return 1;
+            if (this.panelCount <= 1) return 1;
+            return 2;
+        },
+        gridRows() {
+            if (this.expandedId) return 1;
+            if (this.panelCount <= 2) return 1;
+            return 2;
+        },
+        gridStyle() {
+            return {
+                gridTemplateColumns: `repeat(${this.gridCols}, 1fr)`,
+                gridTemplateRows: `repeat(${this.gridRows}, 1fr)`
+            };
+        },
+        gridClass() {
+            if (this.expandedId) return 'slots-1';
+            return 'slots-' + this.panelCount;
+        }
+    },
+    watch: {
+        crossings: {
+            handler() { this.rebuildSlots(); },
+            deep: true,
+            immediate: true
+        },
+        maxSlots() { this.rebuildSlots(); }
+    },
+    methods: {
+        rebuildSlots() {
+            this.localSlots = this.crossings.map(c => ({ type: 'panel', data: c }));
+            // 如果展开的路口被外部移除了,退出展开
+            if (this.expandedId) {
+                const stillExists = this.crossings.find(c => c.id === this.expandedId);
+                if (!stillExists) this.expandedId = null;
+            }
+            this.$nextTick(() => {
+                window.dispatchEvent(new Event('resize'));
+            });
+        },
+
+        handleDblClick(slot) {
+            if (slot.type !== 'panel') return;
+            if (this.panelCount <= 1) return;
+
+            const id = slot.data.id;
+            if (this.expandedId === id) {
+                this.expandedId = null;
+            } else {
+                this.expandedId = id;
+            }
+            this.$nextTick(() => {
+                window.dispatchEvent(new Event('resize'));
+            });
+        },
+
+        exitExpand() {
+            this.expandedId = null;
+            this.$nextTick(() => {
+                window.dispatchEvent(new Event('resize'));
+            });
+        },
+
+        handleRemove(id) {
+            if (this.expandedId === id) {
+                this.expandedId = null;
+            }
+            if (typeof this.onRemove === 'function') {
+                this.onRemove(id);
+            }
+        },
+
+        onDragEnd() {
+            const newOrder = this.localSlots
+                .filter(s => s.type === 'panel')
+                .map(s => s.data);
+            if (typeof this.onReorder === 'function') {
+                this.onReorder(newOrder);
+            }
+            this.$nextTick(() => {
+                window.dispatchEvent(new Event('resize'));
+            });
+        }
+    }
+};
+</script>
+
+<style scoped>
+.crossing-multi-view {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+}
+
+/* ===== 展开模式返回栏 ===== */
+.expand-toolbar {
+    flex-shrink: 0;
+    padding: 6px 12px;
+    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.back-btn {
+    color: #4da8ff;
+    cursor: pointer;
+    font-size: 13px;
+    user-select: none;
+}
+.back-btn:hover {
+    text-decoration: underline;
+}
+
+/* ===== 网格容器 ===== */
+.multi-view-grid {
+    display: grid;
+    gap: 4px;
+    width: 100%;
+    flex: 1;
+    min-height: 0;
+}
+
+.grid-cell {
+    overflow: hidden;
+    min-width: 0;
+    min-height: 0;
+    position: relative;
+    display: flex;
+    flex-direction: column;
+}
+
+/* ===== 面板标题栏 ===== */
+.cell-header {
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    padding: 4px 10px;
+    background: linear-gradient(180deg, rgba(65, 115, 205, 0.4) 0%, transparent 100%);
+    color: #e0e6f1;
+    font-size: 13px;
+    gap: 6px;
+}
+
+.cell-title {
+    flex: 1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-weight: 600;
+    letter-spacing: 1px;
+}
+
+.cell-close {
+    cursor: pointer;
+    opacity: 0.6;
+    font-size: 14px;
+    flex-shrink: 0;
+}
+.cell-close:hover {
+    opacity: 1;
+}
+
+.cell-body {
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+    padding: 6px 10px 10px 10px;
+}
+
+/* ===== 拖拽把手 ===== */
+.drag-handle {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: grab;
+    opacity: 0;
+    transition: opacity 0.2s;
+    flex-shrink: 0;
+}
+
+.cell-header:hover .drag-handle {
+    opacity: 1;
+}
+
+.drag-handle:active {
+    cursor: grabbing;
+}
+
+.drag-icon {
+    color: rgba(255, 255, 255, 0.8);
+    font-size: 14px;
+    line-height: 1;
+}
+
+/* ===== 拖拽状态样式 ===== */
+.drag-ghost {
+    opacity: 0.3;
+    border: 2px dashed rgba(77, 168, 255, 0.6);
+    border-radius: 4px;
+}
+
+.drag-chosen {
+    box-shadow: 0 0 20px rgba(77, 168, 255, 0.4);
+}
+
+.drag-active {
+    opacity: 0.9;
+    box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
+    border-radius: 4px;
+}
+
+</style>

+ 16 - 1
src/components/ui/DropdownSelect.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="custom-dropdown" ref="dropdown" :class="[`theme-${theme}`]">
+  <div class="custom-dropdown" ref="dropdown" :class="[`theme-${theme}`, { 'size-auto': size === 'auto' }]">
     <div 
       class="dropdown-trigger" 
       :class="{ 'is-open': isOpen }" 
@@ -37,6 +37,10 @@ export default {
     event: 'change'
   },
   props: {
+    size: {
+      type: String,
+      default: 'normal'
+    },
     value: {
       type: [String, Number],
       default: ''
@@ -241,4 +245,15 @@ export default {
   opacity: 0;
   transform: translate(-50%, -5px);
 }
+
+/* 自适应模式:继承父级 font-size */
+.size-auto .dropdown-trigger {
+  padding: 0.4em 0.8em;
+  min-width: 5em;
+  font-size: inherit;
+}
+.size-auto .menu-item {
+  padding: 0.5em 1em;
+  font-size: inherit;
+}
 </style>

+ 15 - 2
src/components/ui/SegmentedRadio.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="segmented-radio-group">
+  <div class="segmented-radio-group" :class="{ 'size-auto': size === 'auto' }">
     <div v-for="(item, index) in options" :key="item.value" class="radio-item"
       :class="{ 'is-active': currentValue === item.value }" @click="handleSelect(item.value)">
       {{ item.label }}
@@ -16,6 +16,10 @@ export default {
     event: 'change'
   },
   props: {
+    size: {
+      type: String,
+      default: 'normal'
+    },
     // 接收外部传入的值
     value: {
       type: [String, Number],
@@ -62,7 +66,7 @@ export default {
 /* 容器布局 */
 .segmented-radio-group {
   display: inline-flex;
-  align-items: center;
+  align-items: stretch;
   width: 100%;
 }
 
@@ -84,6 +88,9 @@ export default {
   margin-left: -1px;
   flex: 1;
   text-align: center;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 }
 
 /* 去除第一个元素的负边距 */
@@ -107,4 +114,10 @@ export default {
   z-index: 2;
   color: #ffffff;
 }
+
+/* 自适应模式:继承父级 font-size */
+.segmented-radio-group.size-auto .radio-item {
+  padding: 0.4em 0.35em;
+  font-size: inherit;
+}
 </style>

+ 3 - 1
src/layouts/DashboardLayout.vue

@@ -103,6 +103,7 @@ import DeviceStatusTabs from '@/components/ui/DeviceStatusTabs.vue';
 import TaskMonitorHeader from '@/components/ui/TaskMonitorHeader.vue';
 import SpecialTaskMonitorPanel from '@/components/ui/SpecialTaskMonitorPanel.vue';
 import ChangePassword from '@/components/ui/ChangePassword.vue';
+import CrossingMultiView from '@/components/ui/CrossingMultiView.vue';
 
 export default {
     name: 'DashboardLayout',
@@ -127,7 +128,8 @@ export default {
         DeviceStatusTabs,
         TaskMonitorHeader,
         SpecialTaskMonitorPanel,
-        ChangePassword
+        ChangePassword,
+        CrossingMultiView
     },
     provide() {
         return {

+ 7 - 0
src/mixins/dialogManager.js

@@ -33,6 +33,13 @@ export default {
             );
 
             if (existingDialog) {
+                // 用 $set 更新 data/title,保证 Vue 2 响应式
+                if (config.data !== undefined) {
+                    this.$set(existingDialog, 'data', config.data);
+                }
+                if (config.title !== undefined) {
+                    this.$set(existingDialog, 'title', config.title);
+                }
                 existingDialog.visible = true;
 
                 this.$nextTick(() => {

+ 64 - 4
src/views/StatusMonitoring.vue

@@ -134,6 +134,9 @@ export default {
             ],
 
             tableData: [],
+            // 路口多选分屏
+            crossingSelections: [],
+            maxCrossingSlots: 4,
         };
     },
     watch: {
@@ -191,6 +194,7 @@ export default {
             console.log('你点击了:', item.label);
             this.currentView = item.value;
             this.$refs.layout.clearDialogs(); // 清空全部弹窗
+            this.crossingSelections = [];
             // 列表模式弹窗
             if (this.currentView === 'list-mode') {
                 // this.$refs.layout.openDialog({
@@ -216,6 +220,7 @@ export default {
         handleTabClick(tabName) {
             console.log('父组件接收到了tab点击事件:', tabName);
             this.$refs.layout.clearDialogs(); // 清空全部弹窗
+            this.crossingSelections = [];
             this.showTopChartDalogs(); // 根据当前Tab显示对应的顶部常驻图表
         },
         // 处理菜单点击
@@ -306,19 +311,74 @@ export default {
                 data: {}
             });
         },
-        // 显示路口弹窗组
+        // 显示路口弹窗组(多选分屏)
         showCrossingDalogs(nodeData) {
-            console.log('显示路口弹窗组', nodeData.id, nodeData.label);
+            console.log('路口多选', nodeData.id, nodeData.label);
+
+            // 1. 已选中 → 取消选中
+            const existIndex = this.crossingSelections.findIndex(c => c.id === nodeData.id);
+            if (existIndex !== -1) {
+                this.crossingSelections.splice(existIndex, 1);
+                if (this.crossingSelections.length === 0) {
+                    this.$refs.layout.handleDialogClose('crossing-multi-view');
+                    return;
+                }
+                this.openCrossingMultiView();
+                return;
+            }
+
+            // 2. 已满 → 先进先出
+            if (this.crossingSelections.length >= this.maxCrossingSlots) {
+                this.crossingSelections.shift();
+            }
 
-            this.showCrossingDetailDialogs(nodeData);
+            // 3. 追加选中
+            this.crossingSelections.push({ ...nodeData });
 
+            // 4. 打开或更新弹窗
+            this.openCrossingMultiView();
+        },
 
+        openCrossingMultiView() {
+            this.$refs.layout.openDialog({
+                id: 'crossing-multi-view',
+                title: '路口监控 (' + this.crossingSelections.length + '/' + this.maxCrossingSlots + ')',
+                component: 'CrossingMultiView',
+                width: 1400,
+                height: 700,
+                center: false,
+                position: { x: 500, y: 150 },
+                showClose: true,
+                noPadding: true,
+                enableDblclickExpand: false,
+                data: {
+                    crossings: [...this.crossingSelections],
+                    maxSlots: this.maxCrossingSlots,
+                    onRemove: (id) => this.handleCrossingRemove(id),
+                    onReorder: (newOrder) => {
+                        this.crossingSelections = newOrder;
+                    }
+                },
+                onClose: () => {
+                    this.crossingSelections = [];
+                }
+            });
+        },
+
+        handleCrossingRemove(id) {
+            this.crossingSelections = this.crossingSelections.filter(c => c.id !== id);
+            if (this.crossingSelections.length === 0) {
+                this.$refs.layout.handleDialogClose('crossing-multi-view');
+            } else {
+                this.openCrossingMultiView();
+            }
         },
 
+        // 单个路口详情弹窗(总览双击展开等场景使用)
         showCrossingDetailDialogs(nodeData) {
             console.log('显示路口详情弹窗组', nodeData.id, nodeData.label);
             this.$refs.layout.openDialog({
-                id: 'crossing_detail' + nodeData.id, // 这里的 ID 可以根据实际业务场景动态生成,例如 'dev-security-route' 代表特勤安保路线弹窗
+                id: 'crossing_detail' + nodeData.id,
                 title: nodeData.label || nodeData.name,
                 component: 'CrossingDetailPanel',
                 width: 1315,