Browse Source

1. 新增 CrossingDetailHeader 作为路口详情弹窗的自定义 header
- DashboardLayout.vue — 注册 CrossingDetailHeader 组件,使其可作为弹窗 headerComponent 使用
- StatusMonitoring.vue — showCrossingDetailDialogs 改为 async,先请求路口详情数据,再以 CrossingDetailHeader 作为
headerComponent 打开弹窗,同时将预加载数据传给 CrossingDetailPanel 避免重复请求
- CrossingMultiView.vue — 多路口分屏视图中,将原来的 cell-title 替换为 CrossingDetailHeader;rebuildSlots 改为 async
加载 header 数据,缓存已加载数据避免重复请求,通过 rebuildVersion 防止异步竞态

2. CrossingDetailPanel 支持预加载数据
- 新增 preloadedData prop,mounted 时优先使用预加载数据,无则自行请求
- 提取 applyData 方法复用数据赋值逻辑

3. SmartDialog 标题字体动态适配
- .smart-dialog 添加 container-type: inline-size,声明为容器查询参照物
- .title 字体从固定 14px 改为 clamp(10px, 2cqw, 18px),根据弹窗自身宽度动态缩放

画安 4 weeks ago
parent
commit
0ab7cad91c

+ 91 - 0
src/components/ui/CrossingDetailHeader.vue

@@ -0,0 +1,91 @@
+<template>
+  <div class="crossing-detail-header">
+    <span class="route-name" :title="currentRoute.name || '未知路口'">
+      {{ currentRoute.name || '未知路口' }}
+    </span>
+    <span class="sep">/</span>
+    <span>{{ currentRoute.mode || '自适应控制' }}</span>
+    <span class="sep">/</span>
+    <span>{{ schemeName }}</span>
+    <span class="sep">/</span>
+    <span>运行时段:{{ currentRoute.runTime || '07:00-09:00' }}</span>
+    <span class="sep">/</span>
+    <span>设备:<span :class="isOnline ? 'online' : 'offline'">{{ intersectionData.status || '离线' }}</span></span>
+    <div class="close-btn" @click="handleClose">✕</div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'CrossingDetailHeader',
+  props: {
+    currentRoute: { type: Object, default: () => ({}) },
+    intersectionData: { type: Object, default: () => ({}) },
+    cycleLength: { type: [Number, String], default: 0 },
+    onClose: { type: Function, default: null }
+  },
+  computed: {
+    isOnline() {
+      return this.intersectionData.status === '在线';
+    },
+    schemeName() {
+      return this.currentRoute.schemeName || '早高峰方案';
+    }
+  },
+  methods: {
+    handleClose() {
+      if (this.onClose) {
+        this.onClose();
+      } else {
+        this.$emit('close');
+      }
+    }
+  }
+};
+</script>
+
+<style scoped>
+.crossing-detail-header {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  min-width: 0;
+  color: #e0e6f1;
+  font-size: clamp(10px, 1cqw, 16px);
+  padding: 10px 0px;
+  white-space: nowrap;
+  width: 100%;             /* 必须加上:撑满可用宽度 */
+  box-sizing: border-box;  /* 必须加上 */
+}
+.route-name {
+  white-space: nowrap;      /* 不换行 */
+  overflow: hidden;         /* 隐藏超出的部分 */
+  text-overflow: ellipsis;  /* 显示 ... */
+  min-width: 0;             /* 关键魔法:允许 Flex 子元素收缩到比内容本身更小 */
+}
+.sep {
+  margin: 0 8px;
+  opacity: 0.4;
+}
+
+.online {
+  color: #67c23a;
+}
+
+.offline {
+  color: #f56c6c;
+}
+
+.close-btn {
+  margin-left: auto;
+  cursor: pointer;
+  color: #e0e6f1;
+  opacity: 0.6;
+  font-size: 14px;
+  padding: 0 4px;
+}
+
+.close-btn:hover {
+  opacity: 1;
+}
+</style>

+ 24 - 14
src/components/ui/CrossingDetailPanel.vue

@@ -129,6 +129,9 @@ export default {
         SegmentedRadio,
         DropdownSelect
     },
+    props: {
+        preloadedData: { type: Object, default: null }
+    },
     data() {
         return {
             // 核心状态控制
@@ -173,7 +176,11 @@ export default {
     },
     mounted() {
         this.initScaleObserver();
-        this.loadData();
+        if (this.preloadedData) {
+            this.applyData(this.preloadedData);
+        } else {
+            this.loadData();
+        }
     },
     beforeDestroy() {
         if (this._ro) this._ro.disconnect();
@@ -192,21 +199,24 @@ export default {
             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;
+                this.applyData(data);
             }
         },
+        applyData(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;

+ 38 - 6
src/components/ui/CrossingMultiView.vue

@@ -29,13 +29,18 @@
                     <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>
+                    <CrossingDetailHeader
+                        :currentRoute="slot.headerData ? slot.headerData.currentRoute : { name: slot.data.label || slot.data.name }"
+                        :intersectionData="slot.headerData ? slot.headerData.intersectionData : {}"
+                        :cycleLength="slot.headerData ? slot.headerData.cycleLength : 0"
+                        :onClose="() => handleRemove(slot.data.id)"
+                    />
                 </div>
                 <div class="cell-body">
                     <CrossingDetailPanel
                         :id="slot.data.id"
                         v-bind="slot.data"
+                        :preloadedData="slot.headerData"
                     />
                 </div>
             </div>
@@ -46,12 +51,15 @@
 <script>
 import draggable from 'vuedraggable';
 import CrossingDetailPanel from '@/components/ui/CrossingDetailPanel.vue';
+import CrossingDetailHeader from '@/components/ui/CrossingDetailHeader.vue';
+import { apiGetCrossingDetailData } from '@/api';
 
 export default {
     name: 'CrossingMultiView',
     components: {
         draggable,
-        CrossingDetailPanel
+        CrossingDetailPanel,
+        CrossingDetailHeader
     },
     props: {
         crossings: {
@@ -75,7 +83,8 @@ export default {
     data() {
         return {
             localSlots: [],
-            expandedId: null
+            expandedId: null,
+            rebuildVersion: 0
         };
     },
     computed: {
@@ -123,8 +132,15 @@ export default {
         maxSlots() { this.rebuildSlots(); }
     },
     methods: {
-        rebuildSlots() {
-            this.localSlots = this.crossings.map(c => ({ type: 'panel', data: c }));
+        async rebuildSlots() {
+            // 保留已加载过的 headerData,避免重复请求
+            const oldMap = {};
+            this.localSlots.forEach(s => {
+                if (s.data && s.headerData) oldMap[s.data.id] = s.headerData;
+            });
+            this.localSlots = this.crossings.map(c => ({
+                type: 'panel', data: c, headerData: oldMap[c.id] || null
+            }));
             // 如果展开的路口被外部移除了,退出展开
             if (this.expandedId) {
                 const stillExists = this.crossings.find(c => c.id === this.expandedId);
@@ -133,6 +149,22 @@ export default {
             this.$nextTick(() => {
                 window.dispatchEvent(new Event('resize'));
             });
+            // 只对还没有 headerData 的路口发请求
+            const needLoad = this.localSlots
+                .map((s, i) => (!s.headerData ? { index: i, id: s.data.id } : null))
+                .filter(Boolean);
+            if (needLoad.length === 0) return;
+            const version = ++this.rebuildVersion;
+            const results = await Promise.all(
+                needLoad.map(item => apiGetCrossingDetailData(item.id).catch(() => null))
+            );
+            if (version !== this.rebuildVersion) return;
+            results.forEach((data, i) => {
+                const slotIndex = needLoad[i].index;
+                if (data && this.localSlots[slotIndex]) {
+                    this.$set(this.localSlots[slotIndex], 'headerData', data);
+                }
+            });
         },
 
         handleDblClick(slot) {

+ 15 - 5
src/components/ui/SmartDialog.vue

@@ -1,5 +1,5 @@
 <template>
-  <div v-show="visible" class="smart-dialog" :style="dialogStyle" @mousedown="bringToFront" @dblclick="handleDoubleClick">
+  <div v-show="visible" class="smart-dialog" :style="dialogStyle" @mousedown="bringToFront" @dblclick="handleDoubleClick" :class="{ 'no-padding': noPadding }">
     <div class="dialog-header" :class="{ 'is-draggable': draggable }" @mousedown="startDrag" v-if="title">
       <div class="title-content">
         <slot name="header">
@@ -10,7 +10,7 @@
     </div>
     <div v-else class="dialog-header not-title" :class="{ 'is-draggable': draggable }" @mousedown="startDrag"></div>
 
-    <div v-if="title && false" class="dialog-divider"></div>
+    <div v-if="title" class="dialog-divider"></div>
 
     <div class="dialog-body" :class="{ 'no-padding': noPadding }">
       <slot></slot>
@@ -290,6 +290,11 @@ export default {
   flex-direction: column;
   overflow: hidden;
   user-select: none;
+  padding: 5px;
+  container-type: inline-size;
+}
+.smart-dialog.no-padding {
+  padding: 0;
 }
 
 .dialog-header {
@@ -298,7 +303,7 @@ export default {
   display: flex;
   justify-content: space-between;
   align-items: center;
-  padding: 10px 10px 0px 10px;
+  padding: 0px 10px 0px 10px;
 }
 
 .dialog-header.not-title {
@@ -307,6 +312,8 @@ export default {
 
 .dialog-header.is-draggable {
   cursor: move;
+}
+.not-title.is-draggable {
   height: 10px;
 }
 
@@ -317,7 +324,7 @@ export default {
 
 .title {
   color: #ffffff;
-  font-size: 14px;
+  font-size: clamp(10px, 2cqw, 18px);
   font-weight: 600;
   letter-spacing: 1px;
 }
@@ -340,7 +347,7 @@ export default {
 .dialog-divider {
   height: 1px;
   background-color: rgba(255, 255, 255, 0.3);
-  margin: 0 20px;
+  margin: 0 5px;
 }
 
 .dialog-body {
@@ -351,6 +358,9 @@ export default {
   display: flex;
   flex-direction: column;
 }
+.dialog-body.no-padding {
+  padding: 0;
+}
 
 .dialog-body.no-padding {
   padding: 0;

+ 3 - 1
src/layouts/DashboardLayout.vue

@@ -104,6 +104,7 @@ 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';
+import CrossingDetailHeader from '@/components/ui/CrossingDetailHeader.vue';
 
 export default {
     name: 'DashboardLayout',
@@ -129,7 +130,8 @@ export default {
         TaskMonitorHeader,
         SpecialTaskMonitorPanel,
         ChangePassword,
-        CrossingMultiView
+        CrossingMultiView,
+        CrossingDetailHeader
     },
     provide() {
         return {

+ 17 - 6
src/views/StatusMonitoring.vue

@@ -119,7 +119,7 @@ import CrossingListPanel from '@/components/ui/CrossingListPanel.vue';
 import OnlineStatusTabs from '@/components/ui/OnlineStatusTabs.vue';
 import DeviceStatusTabs from '@/components/ui/DeviceStatusTabs.vue';
 import RingDonutChart from '@/components/ui/RingDonutChart.vue';
-import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetOverviewTopCharts } from '@/api';
+import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetOverviewTopCharts, apiGetCrossingDetailData } from '@/api';
 
 
 export default {
@@ -374,20 +374,31 @@ export default {
         },
 
         // 单个路口详情弹窗(总览双击展开等场景使用)
-        showCrossingDetailDialogs(nodeData) {
+        async showCrossingDetailDialogs(nodeData) {
             console.log('显示路口详情弹窗组', nodeData.id, nodeData.label);
+            const detailData = await apiGetCrossingDetailData(nodeData.id);
+            const dialogId = 'crossing_detail' + nodeData.id;
             this.$refs.layout.openDialog({
-                id: 'crossing_detail' + nodeData.id,
-                title: nodeData.label || nodeData.name,
+                id: dialogId,
+                title: ' ',
                 component: 'CrossingDetailPanel',
                 width: 1315,
                 height: 682,
                 center: false,
-                showClose: true,
+                showClose: false,
                 position: { x: 500, y: 170 },
                 noPadding: false,
                 enableDblclickExpand: false,
-                data: nodeData
+                data: { ...nodeData, preloadedData: detailData },
+                headerComponent: 'CrossingDetailHeader',
+                headerProps: {
+                    currentRoute: detailData?.currentRoute || {},
+                    intersectionData: detailData?.intersectionData || {},
+                    cycleLength: detailData?.cycleLength || 0,
+                    onClose: () => {
+                        this.$refs.layout.handleDialogClose(dialogId);
+                    }
+                }
             });
         },
         // 路口列表模式下弹窗