ソースを参照

Merge branch 'master' of http://121.40.40.223:3000/zizhong.wang/dtScreen

hebotao 2 週間 前
コミット
ff2f5e4c52

+ 2 - 1
package.json

@@ -53,6 +53,7 @@
   "browserslist": [
     "> 1%",
     "last 2 versions",
-    "not dead"
+    "not dead",
+    "ios >= 12"
   ]
 }

BIN
src/assets/login/login-bg.mp4


+ 119 - 60
src/components/ui/CrossingDetailPanel.vue

@@ -12,7 +12,8 @@
                     </div>
                 </div>
 
-                <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
+                <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData"
+                    :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
             </div>
         </div>
 
@@ -23,9 +24,8 @@
                         <div class="control-label-wrap">
                             <span class="control-label">控制方式</span>
                             <div class="control-operation">
-                                <div class="operation-btn" 
-                                     :class="{ 'is-active': isManualMode }"
-                                     @click="toggleManualMode">
+                                <div class="operation-btn" :class="{ 'is-active': isManualMode }"
+                                    @click="toggleManualMode">
                                     {{ isManualMode ? '退出手动控制' : '手动控制' }}
                                 </div>
                             </div>
@@ -37,32 +37,30 @@
                             <SegmentedRadio v-model="currentMethod" :options="controlMethodOptions" size="auto" />
                         </div>
 
-                        <div class="control-scheme">
+                        <div class="control-scheme" :class="{ 'is-disabled': isSchemeDisabled }">
                             <div class="control-label-wrap">
                                 <span class="control-label">控制方案</span>
                                 <DropdownSelect v-model="currentScheme" :options="schemeOptions" size="auto" />
                             </div>
-                            
+
                             <div class="current-stage">
                                 <div class="current-stage-warp">
                                     <div class="current-stage-label">当前阶段:</div>
                                     <div v-for="(item, index) in currentStageList" :key="index" class="stage-item-wrapper">
-                                        <div 
-                                            class="phase-box" 
-                                            :class="{ 'is-active': item.value === currentStage }"
-                                            @click="currentStage = item.value"
-                                        >
+                                        <div class="phase-box" :class="{ 'is-active': item.value === currentStage }"
+                                            @click="currentStage = item.value">
                                             <img :src="item.img" alt="stage" class="phase-image" />
                                         </div>
-                                        
-                                        <input 
-                                            type="number" 
-                                            v-model.number="item.time" 
-                                            class="stage-input"
-                                            :disabled="currentMethod !== 'temp'"
-                                            :title="currentMethod !== 'temp' ? '仅临时方案可修改' : '修改阶段时间'"
-                                        />
-                                        <span class="unit">s</span>
+
+                                        <div class="bottom-controls">
+                                            <div class="input-unit-wrapper">
+                                                <input type="number" v-model.number="item.time" class="stage-input"
+                                                    :disabled="!canEditStage"
+                                                    :title="canEditStage ? '修改阶段时间' : '当前控制方式不可修改'" />
+                                                <span class="unit">s</span>
+                                            </div>
+                                            <span class="percent">{{ stagePercent(item.time) }}</span>
+                                        </div>
                                     </div>
                                 </div>
                             </div>
@@ -71,24 +69,14 @@
                             <div class="donut-row" v-if="!showLockTime">
                                 <div class="donut-item">
                                     <div class="donut-title">实时方案(执行方案3)</div>
-                                    <PlanDonutChart
-                                        :chartData="realtimeDonutData"
-                                        :centerValue="String(realtimeRemaining)"
-                                        centerLabel="剩余时长"
-                                        :showTotal="true"
-                                        :totalValue="cycleLength"
-                                        :scale="panelScale"
-                                    />
+                                    <PlanDonutChart :chartData="realtimeDonutData"
+                                        :centerValue="String(realtimeRemaining)" centerLabel="剩余时长" :showTotal="true"
+                                        :totalValue="cycleLength" :scale="panelScale" />
                                 </div>
                                 <div class="donut-item">
                                     <div class="donut-title">下周期方案</div>
-                                    <PlanDonutChart
-                                        :chartData="nextCycleDonutData"
-                                        :centerValue="String(cycleLength)"
-                                        centerLabel="总时长"
-                                        :showTotal="false"
-                                        :scale="panelScale"
-                                    />
+                                    <PlanDonutChart :chartData="nextCycleDonutData" :centerValue="String(cycleLength)"
+                                        centerLabel="总时长" :showTotal="false" :scale="panelScale" />
                                 </div>
                             </div>
 
@@ -107,8 +95,8 @@
                                         <div class="lock-time-option">
                                             <label>
                                                 <input type="radio" v-model="lockTimeType" value="timer" /> 放行
-                                                <DropdownSelect placeholder="锁定时间" v-model="currentLocktime" :options="locktimeOptions" size="auto"
-                                                    @click.native.prevent />
+                                                <DropdownSelect placeholder="锁定时间" v-model="currentLocktime"
+                                                    :options="locktimeOptions" size="auto" @click.native.prevent />
                                                 秒解锁
                                             </label>
                                         </div>
@@ -186,11 +174,21 @@ export default {
             phaseStages: [],
             currentLocktime: 50,
             locktimeOptions: [],
-            currentStage: '1', 
+            currentStage: '1',
             // 补充了 time 属性,用于双向绑定输入框的时间
             currentStageList: []
         }
     },
+    computed: {
+        // 黄闪、关灯、全红时禁用控制方案
+        isSchemeDisabled() {
+            return ['yellow_flash', 'lights_off', 'all_red'].includes(this.currentMethod);
+        },
+        // 定周期、中心控制、感应控制、临时方案可编辑当前阶段
+        canEditStage() {
+            return ['fixed', 'system', 'sensor', 'temp'].includes(this.currentMethod);
+        }
+    },
     watch: {
         // 监听控制方式切换
         currentMethod(newVal) {
@@ -217,6 +215,11 @@ export default {
         if (this._ro) this._ro.disconnect();
     },
     methods: {
+        stagePercent(time) {
+            const total = this.currentStageList.reduce((s, item) => s + (item.time || 0), 0);
+            if (!total) return '0%';
+            return Math.round(time / total * 100) + '%';
+        },
         onScanTick(activeTime) {
             if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
             // 只看第一轨道(trackIdx=0)的相位
@@ -247,15 +250,19 @@ export default {
                 activeArrowTypes = [...new Set(activeArrowTypes)];
             }
 
+            // 人行道全红判断:只有 P1/P3 绿灯期间人行道才有绿灯,其余时段全红
+            const pedAllRed = !(type === 'green' && (phaseName === 'P1' || phaseName === 'P3'));
+
             this.$set(this.intersectionData, 'signals', {
+                pedAllRed,
                 ns: {
-                    phaseName: nsGreen ? (phaseName ? `相位${phaseName.replace('P', '')}` : '南北') : (this.intersectionData.signals?.ns?.phaseName || '南北'),
+                    phaseName: nsGreen ? ({ P1: '南北直行', P2: '南北左转' }[phaseName] || '南北') : (this.intersectionData.signals?.ns?.phaseName || '南北'),
                     time: remaining,
                     isGreen: nsGreen,
                     activeArrowTypes: nsGreen ? activeArrowTypes : []
                 },
                 ew: {
-                    phaseName: ewGreen ? (phaseName ? `相位${phaseName.replace('P', '')}` : '东西') : (this.intersectionData.signals?.ew?.phaseName || '东西'),
+                    phaseName: ewGreen ? ({ P3: '东西直行', P4: '东西左转' }[phaseName] || '东西') : (this.intersectionData.signals?.ew?.phaseName || '东西'),
                     time: remaining,
                     isGreen: ewGreen,
                     activeArrowTypes: ewGreen ? activeArrowTypes : []
@@ -366,7 +373,7 @@ export default {
                 this.showLockTime = false;
             }
         },
-        
+
         // 模拟:根据控制方式改变下拉方案的数据
         updateSchemeDataByMethod(method) {
             if (method === 'system') {
@@ -386,14 +393,14 @@ export default {
         },
 
         // 取消按钮
-        onCancel() { 
+        onCancel() {
             this.isManualMode = false;
             this.showLockTime = false;
             // 可以在此添加回滚初始数据的逻辑
         },
 
         // 需求5:点击确认按钮提交 + 表单验证
-        onConfirm() { 
+        onConfirm() {
             // 验证1:临时方案必须检查时间是否有效
             if (this.currentMethod === 'temp') {
                 const isInvalid = this.currentStageList.some(item => !item.time || item.time <= 0);
@@ -423,7 +430,7 @@ export default {
             };
 
             console.log('提交的数据:', submitData);
-            
+
             // 提交完成后可根据业务决定是否退出手动模式
             // this.isManualMode = false;
         }
@@ -596,9 +603,11 @@ export default {
     cursor: pointer;
     user-select: none;
 }
+
 .operation-btn:hover {
     text-decoration: underline;
 }
+
 .operation-btn.is-active {
     text-decoration: underline;
 }
@@ -618,6 +627,11 @@ export default {
     font-size: clamp(9px, calc(var(--s) * 14px), 14px);
 }
 
+.control-scheme.is-disabled {
+    opacity: 0.4;
+    pointer-events: none;
+}
+
 .lock-time {
     width: 80%;
     border-radius: 8px;
@@ -673,14 +687,16 @@ export default {
 }
 
 .current-stage-warp {
+    width: 100%;
     display: flex;
     align-items: center;
-    justify-content: center;
+    justify-content: space-around;
     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: clamp(9px, calc(var(--s) * 14px), 14px);
     width: auto;
@@ -689,7 +705,7 @@ export default {
 
 .stage-input {
     width: clamp(32px, calc(var(--s) * 65px), 65px);
-    border: 1px solid rgba(161,190,255,0.7);
+    border: 1px solid rgba(161, 190, 255, 0.7);
     background-color: transparent;
     padding: clamp(2px, calc(var(--s) * 5px), 5px);
     color: #ffffff;
@@ -698,8 +714,8 @@ export default {
 
 .phase-box {
     position: relative;
-    width: clamp(30px, calc(var(--s) * 65px), 65px);
-    height: clamp(30px, calc(var(--s) * 65px), 65px);
+    width: clamp(30px, calc(var(--s) * 90px), 90px);
+    height: clamp(30px, calc(var(--s) * 90px), 90px);
     background: #E6F0FF;
     border-radius: 4px;
     display: flex;
@@ -755,11 +771,13 @@ export default {
     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);
 }
@@ -769,11 +787,13 @@ export default {
     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;
@@ -810,41 +830,78 @@ export default {
 .stage-item-wrapper {
     display: flex;
     flex-direction: column;
-    align-items: center;
+    align-items: stretch;
     gap: clamp(2px, calc(var(--s) * 4px), 6px);
     position: relative;
 }
 
+.bottom-controls {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: clamp(4px, calc(var(--s) * 6px), 8px); /* 输入框和百分比的间距 */
+}
+
+/* 新增包裹层的相对定位 */
+.input-unit-wrapper {
+    position: relative;
+    display: inline-block;
+}
+
 .stage-input {
     width: clamp(32px, calc(var(--s) * 65px), 65px);
-    border: 1px solid rgba(161,190,255,0.7);
+    border: 1px solid rgba(161, 190, 255, 0.7);
     background-color: transparent;
     padding: clamp(2px, calc(var(--s) * 5px), 5px);
+    /* 给右侧留出空间,防止数字过长被 s 挡住 */
+    padding-right: clamp(10px, calc(var(--s) * 16px), 16px);
     color: #ffffff;
     text-align: center;
     border-radius: 4px;
 }
 
-.stage-input:disabled {
-    border-color: rgba(255, 255, 255, 0.2);
-    color: rgba(255, 255, 255, 0.5);
-    background-color: rgba(0, 0, 0, 0.2);
-}
-
-.stage-item-wrapper .unit {
+/* 修改 s 单位的定位方式为垂直居中 */
+.input-unit-wrapper .unit {
     position: absolute;
-    bottom: clamp(2px, calc(var(--s) * 6px), 6px);
+    top: 50%;
+    transform: translateY(-50%);
     right: clamp(3px, calc(var(--s) * 6px), 8px);
     color: #77A1FF;
     font-size: clamp(8px, calc(var(--s) * 12px), 12px);
     pointer-events: none;
 }
 
+/* 微调百分比的间距,让排版更紧凑 */
+.stage-item-wrapper .percent {
+    color: rgba(255, 255, 255, 0.5);
+    font-size: clamp(8px, calc(var(--s) * 11px), 11px);
+    white-space: nowrap;
+}
+
+.stage-input {
+    width: clamp(32px, calc(var(--s) * 65px), 65px);
+    border: 1px solid rgba(161, 190, 255, 0.7);
+    background-color: transparent;
+    padding: clamp(2px, calc(var(--s) * 5px), 5px);
+    color: #ffffff;
+    text-align: center;
+    border-radius: 4px;
+}
+
+.stage-input:disabled {
+    border-color: rgba(255, 255, 255, 0.2);
+    color: rgba(255, 255, 255, 0.5);
+    background-color: rgba(0, 0, 0, 0.2);
+}
+
 /* 弹窗过渡动画 */
-.fade-enter-active, .fade-leave-active {
+.fade-enter-active,
+.fade-leave-active {
     transition: opacity 0.3s, transform 0.3s;
 }
-.fade-enter, .fade-leave-to {
+
+.fade-enter,
+.fade-leave-to {
     opacity: 0;
     transform: translateY(-10px);
 }
@@ -869,10 +926,12 @@ export default {
     gap: clamp(4px, calc(var(--s) * 16px), 16px);
     width: 100%;
 }
+
 .donut-item {
     flex: 1;
     min-width: 0;
 }
+
 .donut-title {
     font-size: clamp(11px, calc(var(--s) * 13px), 14px);
     color: #a0aec0;

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

@@ -4,7 +4,7 @@
             <IntersectionMapVideos :mapData="intersectionData" :videoUrls="currentRoute.cornerVideos" />
         </div>
         <div class="signal-timing-wrap">
-            <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" />
+            <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
         </div>
     </div>
 </template>
@@ -26,6 +26,7 @@ export default {
     },
     data() {
         return {
+            dataReady: false,
             followPhase: false,
             intersectionData: {},
             currentRoute: {},
@@ -43,8 +44,55 @@ export default {
             this.mockPhaseData = data.phaseData || [];
             if (data.cycleLength) this.cycleLength = data.cycleLength;
             if (data.currentTime !== undefined) this.currentSec = data.currentTime;
+            this.$nextTick(() => { this.dataReady = true; });
         }
     },
+    methods: {
+        onScanTick(activeTime) {
+            if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
+            const phase = this.mockPhaseData.find(p => p[0] === 0 && activeTime >= p[1] && activeTime < p[2]);
+            if (!phase) return;
+
+            const type = phase[5];
+            const iconValue = phase[6];
+            const direction = phase[7];
+            const phaseName = phase[3];
+            const endTime = phase[2];
+            const remaining = Math.max(0, Math.round(endTime - activeTime));
+
+            const nsGreen = (type === 'green' && direction === 'ns');
+            const ewGreen = (type === 'green' && direction === 'ew');
+
+            let activeArrowTypes = [];
+            if ((nsGreen || ewGreen) && iconValue) {
+                const icons = iconValue.split(',');
+                icons.forEach(ic => {
+                    if (ic.includes('UTURN')) activeArrowTypes.push('U');
+                    if (ic.includes('TURN') && !ic.includes('UTURN')) activeArrowTypes.push('L');
+                    if (ic.includes('STRAIGHT')) activeArrowTypes.push('S');
+                });
+                activeArrowTypes = [...new Set(activeArrowTypes)];
+            }
+
+            const pedAllRed = !(type === 'green' && (phaseName === 'P1' || phaseName === 'P3'));
+
+            this.$set(this.intersectionData, 'signals', {
+                pedAllRed,
+                ns: {
+                    phaseName: nsGreen ? ({ P1: '南北直行', P2: '南北左转' }[phaseName] || '南北') : (this.intersectionData.signals && this.intersectionData.signals.ns && this.intersectionData.signals.ns.phaseName || '南北'),
+                    time: remaining,
+                    isGreen: nsGreen,
+                    activeArrowTypes: nsGreen ? activeArrowTypes : []
+                },
+                ew: {
+                    phaseName: ewGreen ? ({ P3: '东西直行', P4: '东西左转' }[phaseName] || '东西') : (this.intersectionData.signals && this.intersectionData.signals.ew && this.intersectionData.signals.ew.phaseName || '东西'),
+                    time: remaining,
+                    isGreen: ewGreen,
+                    activeArrowTypes: ewGreen ? activeArrowTypes : []
+                }
+            });
+        }
+    }
 }
 </script>
 

+ 8 - 7
src/components/ui/DeviceStatusPie.vue

@@ -132,6 +132,8 @@ export default {
   align-items: center;
   gap: clamp(10px, 2vw, 30px);
   padding: 10px;
+  padding: 10px 20px;
+  box-sizing: border-box;
   container-type: size; /* 核心修改:让它成为容器查询的根节点 */
 }
 
@@ -140,8 +142,8 @@ export default {
   position: relative;
   /* 核心修改:直接计算出一个绝对的正方形尺寸!
      取“父级宽度的45%”和“父级高度”中更小的那一个作为边长,彻底杜绝拉伸 */
-  width: min(45cqw, 100cqh - 20px);
-  height: min(45cqw, 100cqh - 20px);
+  width: min(40cqw, 100cqh - 20px);
+  height: min(40cqw, 100cqh - 20px);
   
   flex-shrink: 0;
   display: flex;
@@ -229,9 +231,9 @@ export default {
 .status-panel {
   display: flex;
   flex-direction: column;
-  gap: clamp(6px, 1.5vh, 12px);
-  width: 45%;
-  flex-shrink: 0; /* 核心修改:保证右侧内容不被变形挤压 */
+  gap: clamp(2px, 0.8vh, 4px);
+  flex: 1; /* 让面板自动填满右侧的所有剩余空间 */
+  min-width: 0;
 }
 
 .status-item {
@@ -241,9 +243,7 @@ export default {
   font-size: clamp(11px, 1.4vh, 15px);
   font-weight: 500;
   color: #fff;
-  padding: clamp(5px, 1vh, 10px) clamp(8px, 1.2vw, 14px);
   transition: all 0.3s ease;
-  white-space: nowrap;
 }
 
 .status-item:hover {
@@ -260,6 +260,7 @@ export default {
   flex: 1;
   overflow: hidden;
   text-overflow: ellipsis;
+  white-space: nowrap;
 }
 .status-num {
   font-weight: 700;

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

@@ -1,6 +1,6 @@
 <template>
   <div class="device-status-tabs">
-    <TechTabs v-model="activeTab" :interval="10000" type="segmented" autoPlay @tab-click="handleTabClick">
+    <TechTabs v-model="activeTab" :interval="3000" type="segmented" autoPlay @tab-click="handleTabClick">
       <TechTabPane label="信号机" name="signalMachineStatus">
         <DeviceStatusPie v-if="displayData" :chartData="displayData.chartData" :showCenter="false" />
       </TechTabPane>

+ 223 - 137
src/components/ui/DropdownSelect.vue

@@ -1,26 +1,18 @@
 <template>
-  <div class="custom-dropdown" ref="dropdown" :class="[`theme-${theme}`, { 'size-auto': size === 'auto' }]">
-    <div 
-      class="dropdown-trigger" 
-      :class="{ 'is-open': isOpen }" 
-      @click="toggleDropdown"
-    >
+  <div class="custom-dropdown" ref="dropdown" :class="[`theme-${theme}`, { 'size-auto': size === 'auto' }]"
+    @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
+    <div class="dropdown-trigger" :class="{ 'is-open': isOpen }" @click="toggleDropdown">
       <span class="trigger-text">{{ currentLabel }}</span>
       <i class="arrow-icon"></i>
     </div>
 
     <transition name="fade">
-      <div class="dropdown-menu" v-show="isOpen">
+      <div class="dropdown-menu" :class="`theme-${theme}`" v-show="isOpen" ref="menu" :style="menuStyle"
+        @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
         <div class="menu-arrow"></div>
-        
         <div class="menu-list">
-          <div 
-            class="menu-item" 
-            v-for="item in options" 
-            :key="item.value"
-            :class="{ 'is-active': value === item.value }"
-            @click="selectOption(item)"
-          >
+          <div class="menu-item" v-for="item in options" :key="item.value"
+            :class="{ 'is-active': value === item.value }" @click="selectOption(item)">
             {{ item.label }}
           </div>
         </div>
@@ -32,36 +24,48 @@
 <script>
 export default {
   name: 'DropdownSelect',
-  model: {
-    prop: 'value',
-    event: 'change'
-  },
+  model: { prop: 'value', event: 'change' },
   props: {
-    size: {
-      type: String,
-      default: 'normal'
-    },
-    value: {
-      type: [String, Number],
-      default: ''
-    },
-    options: {
-      type: Array,
-      default: () => []
-    },
-    placeholder: {
-      type: String,
-      default: '请选择'
-    },
-    // 【新增】主题风格:'bordered' (透明边框) | 'solid' (实心深色背景)
+    /**
+     * 尺寸模式
+     * 'normal' - 固定宽度 | 'auto' - 随文字自适应
+     */
+    size: { type: String, default: 'normal' },
+
+    /** 绑定值 */
+    value: { type: [String, Number], default: '' },
+
+    /** 选项列表 [{label: '30s', value: 30}] */
+    options: { type: Array, default: () => [] },
+
+    /** 未选择时的占位符 */
+    placeholder: { type: String, default: '请选择' },
+
+    /**
+     * 主题风格选择
+     * 'bordered' - 【经典版】白色背景,浅蓝色边框,适合常规表单页面。
+     * 'solid'    - 【卡片版】深蓝灰色背景,无边框,完美契合你当前的路口控制卡片。
+     * 'neon'     - 【极客版】新增设计,全透明背景配合青色霓虹发光边框,适合高亮科技感大屏。
+     */
     theme: {
       type: String,
-      default: 'bordered'
-    }
+      default: 'bordered',
+    },
+
+    /** 是否将下拉列表挂载至 body (解决 Swiper 等容器 overflow:hidden 导致的遮挡问题) */
+    appendToBody: { type: Boolean, default: true }
   },
   data() {
     return {
-      isOpen: false
+      isOpen: false,
+      closeTimer: null,
+      menuStyle: {
+        position: 'absolute',
+        top: '0px',
+        left: '0px',
+        zIndex: 9999,
+        width: 'auto'
+      }
     };
   },
   computed: {
@@ -70,25 +74,69 @@ export default {
       return selected ? selected.label : this.placeholder;
     }
   },
+  watch: {
+    isOpen(val) {
+      if (val) {
+        this.$nextTick(() => {
+          this.updatePosition();
+          if (this.appendToBody && this.$refs.menu) {
+            document.body.appendChild(this.$refs.menu);
+          }
+        });
+      }
+    }
+  },
   mounted() {
     document.addEventListener('click', this.handleClickOutside);
+    window.addEventListener('resize', this.updatePosition);
+    window.addEventListener('scroll', this.updatePosition, true);
   },
   beforeDestroy() {
     document.removeEventListener('click', this.handleClickOutside);
+    window.removeEventListener('resize', this.updatePosition);
+    window.removeEventListener('scroll', this.updatePosition, true);
+    if (this.closeTimer) clearTimeout(this.closeTimer);
+    if (this.appendToBody && this.$refs.menu && this.$refs.menu.parentNode === document.body) {
+      document.body.removeChild(this.$refs.menu);
+    }
   },
   methods: {
-    toggleDropdown() {
-      this.isOpen = !this.isOpen;
+    toggleDropdown() { this.isOpen = !this.isOpen; },
+    handleMouseEnter() { if (this.closeTimer) clearTimeout(this.closeTimer); },
+    handleMouseLeave() {
+      this.closeTimer = setTimeout(() => { this.isOpen = false; }, 250);
     },
-    selectOption(item) {
-      if (this.value !== item.value) {
-        this.$emit('change', item.value); 
-        this.$emit('select', item);       
+    updatePosition() {
+      if (!this.isOpen || !this.$refs.dropdown || !this.$refs.menu) return;
+      const triggerRect = this.$refs.dropdown.getBoundingClientRect();
+      const menuHeight = this.$refs.menu.offsetHeight || 200;
+      const viewportHeight = window.innerHeight;
+
+      let top = triggerRect.bottom + window.scrollY + 8;
+      let isTop = false;
+
+      // 空间探测:若下方空间不足且上方充足,则翻转
+      if (triggerRect.bottom + menuHeight > viewportHeight && triggerRect.top > menuHeight) {
+        top = triggerRect.top + window.scrollY - menuHeight - 8;
+        isTop = true;
       }
+
+      this.menuStyle = {
+        position: 'absolute',
+        top: `${top}px`,
+        left: `${triggerRect.left + window.scrollX}px`,
+        width: `${triggerRect.width}px`,
+        zIndex: 9999
+      };
+      this.$refs.menu.setAttribute('data-placement', isTop ? 'top' : 'bottom');
+    },
+    selectOption(item) {
+      this.$emit('change', item.value);
       this.isOpen = false;
     },
     handleClickOutside(event) {
-      if (this.$refs.dropdown && !this.$refs.dropdown.contains(event.target)) {
+      if (this.$refs.dropdown && !this.$refs.dropdown.contains(event.target) &&
+        this.$refs.menu && !this.$refs.menu.contains(event.target)) {
         this.isOpen = false;
       }
     }
@@ -97,163 +145,201 @@ export default {
 </script>
 
 <style scoped>
-/* 最外层容器 */
+/* ================== 核心容器与公共样式 ================== */
 .custom-dropdown {
   position: relative;
   display: inline-block;
   user-select: none;
 }
 
-/* --- 触发器按钮 (基础样式) --- */
 .dropdown-trigger {
   display: flex;
   align-items: center;
   justify-content: space-between;
   padding: 6px 12px;
   min-width: 80px;
-  color: #ffffff;
+  color: #fff;
   font-size: 14px;
   cursor: pointer;
   transition: all 0.2s;
 }
-.trigger-text {
-  margin-right: 12px;
-}
+
 .arrow-icon {
   width: 0;
   height: 0;
   border-left: 4px solid transparent;
   border-right: 4px solid transparent;
-  border-top: 5px solid #ffffff;
-  transition: transform 0.3s ease;
+  border-top: 5px solid #fff;
+  transition: transform 0.3s;
 }
-.dropdown-trigger.is-open .arrow-icon {
+
+.is-open .arrow-icon {
   transform: rotate(180deg);
 }
 
-/* ================== 主题 1:透明边框 (默认原样式) ================== */
+/* ================== 主题样式表 ================== */
+
+/* 1. Bordered 主题 (白色) */
 .theme-bordered .dropdown-trigger {
-  background-color: transparent;
-  border: 1px solid rgba(100, 130, 190, 0.6); 
+  background: transparent;
+  border: 1px solid rgba(100, 130, 190, 0.6);
   border-radius: 2px;
 }
-.theme-bordered .dropdown-trigger:hover,
-.theme-bordered .dropdown-trigger.is-open {
-  border-color: rgba(140, 180, 255, 0.9);
+
+.dropdown-menu.theme-bordered {
+  background: #fff;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.theme-bordered .menu-item {
+  color: #333;
+}
+
+.theme-bordered .menu-item:hover {
+  background: #f0f5ff;
+  color: #4da8ff;
+}
+
+.theme-bordered .menu-item.is-active {
+  color: #4da8ff;
+  font-weight: bold;
+}
+
+.theme-bordered[data-placement="bottom"] .menu-arrow {
+  border-bottom: 6px solid #fff;
+  top: -5px;
+}
+
+.theme-bordered[data-placement="top"] .menu-arrow {
+  border-top: 6px solid #fff;
+  bottom: -5px;
 }
 
-/* ================== 主题 2:实心深色 (还原你的最新截图) ================== */
+/* 2. Solid 主题 (深蓝灰 - 你目前的风格) */
 .theme-solid .dropdown-trigger {
-  background-color: #273444;; /* 截图中的深蓝色底色 */
-  border: 1px solid transparent; /* 占位防止跳动 */
-  border-radius: 4px; /* 稍微圆润的边角 */
+  background: #273444;
+  border-radius: 4px;
   padding: 5px 8px;
 }
-.theme-solid .dropdown-trigger:hover,
-.theme-solid .dropdown-trigger.is-open {
-  background-color: #385180; /* hover 时稍微提亮 */
+
+.theme-solid .dropdown-trigger:hover {
+  background: #385180;
+}
+
+.dropdown-menu.theme-solid {
+  background: #1e2c4a;
+  border: 1px solid rgba(100, 130, 190, 0.3);
 }
 
+.theme-solid .menu-item {
+  color: #c4d7f0;
+}
 
-/* --- 下拉菜单容器 (基础位置与动画) --- */
+.theme-solid .menu-item:hover {
+  background: #2b3f66;
+  color: #32F6F8;
+}
+
+.theme-solid .menu-item.is-active {
+  color: #32F6F8;
+  font-weight: bold;
+}
+
+.theme-solid[data-placement="bottom"] .menu-arrow {
+  border-bottom: 6px solid #1e2c4a;
+  top: -6px;
+}
+
+.theme-solid[data-placement="top"] .menu-arrow {
+  border-top: 6px solid #1e2c4a;
+  bottom: -6px;
+}
+
+/* 3. Neon 主题 (极客霓虹 - 科技蓝版) */
+.theme-neon .dropdown-trigger { 
+  background: rgba(68, 138, 255, 0.05); 
+  border: 1px solid rgba(68, 138, 255, 0.3); 
+  border-radius: 20px; /* 圆角胶囊感 */
+  box-shadow: inset 0 0 8px rgba(68, 138, 255, 0.1);
+}
+.theme-neon .dropdown-trigger:hover,
+.theme-neon .dropdown-trigger.is-open { 
+  border-color: #448AFF; 
+  box-shadow: 0 0 10px rgba(68, 138, 255, 0.4); 
+}
+.dropdown-menu.theme-neon { 
+  background: rgba(10, 20, 40, 0.95); 
+  backdrop-filter: blur(10px); /* 磨砂玻璃效果 */
+  border: 1px solid #448AFF;
+  box-shadow: 0 0 20px rgba(68, 138, 255, 0.2);
+}
+.theme-neon .menu-item { color: rgba(68, 138, 255, 0.7); }
+.theme-neon .menu-item:hover { 
+  background: rgba(68, 138, 255, 0.15); 
+  color: #fff; 
+  text-shadow: 0 0 5px #448AFF; 
+}
+.theme-neon .menu-item.is-active { 
+  color: #fff; 
+  background: rgba(68, 138, 255, 0.25); 
+}
+.theme-neon[data-placement="bottom"] .menu-arrow { 
+  border-bottom: 6px solid #448AFF; 
+  top: -5px; 
+}
+.theme-neon[data-placement="top"] .menu-arrow { 
+  border-top: 6px solid #448AFF; 
+  bottom: -5px; 
+}
+
+/* ================== 通用功能样式 ================== */
 .dropdown-menu {
-  position: absolute;
-  top: calc(100% + 10px);
-  left: 50%;
-  transform: translateX(-50%);
-  min-width: 100%; /* 至少与触发器同宽 */
   border-radius: 6px;
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
-  z-index: 1000; 
+  position: fixed;
+  box-sizing: border-box;
 }
+
+/* 使用 fixed 配合 js 计算 */
 .menu-arrow {
   position: absolute;
-  top: -5px; 
   left: 50%;
   transform: translateX(-50%);
   width: 0;
   height: 0;
   border-left: 6px solid transparent;
   border-right: 6px solid transparent;
+  z-index: 10;
 }
+
 .menu-list {
   padding: 6px 0;
+  max-height: 200px;
+  overflow-y: auto;
 }
+
 .menu-item {
   padding: 8px 16px;
   font-size: 14px;
   text-align: center;
   cursor: pointer;
-  transition: all 0.2s;
-}
-
-/* ================== 下拉菜单配色:主题 1 (白色气泡) ================== */
-.theme-bordered .dropdown-menu {
-  background-color: #ffffff;
-}
-.theme-bordered .menu-arrow {
-  border-bottom: 6px solid #ffffff;
-}
-.theme-bordered .menu-item {
-  color: #333333;
-}
-.theme-bordered .menu-item:hover {
-  background-color: #f0f5ff;
-  color: #4da8ff;
-}
-.theme-bordered .menu-item.is-active {
-  color: #4da8ff;
-  font-weight: bold;
-}
-
-/* ================== 下拉菜单配色:主题 2 (深色科技气泡) ================== */
-.theme-solid .dropdown-menu {
-  background-color: #1e2c4a;
-  border: 1px solid rgba(100, 130, 190, 0.3);
-}
-.theme-solid .menu-arrow {
-  border-bottom: 6px solid #1e2c4a;
-}
-/* 利用伪元素单独画暗色三角形边框,防止悬浮感不足 */
-.theme-solid .menu-arrow::before {
-  content: '';
-  position: absolute;
-  top: 1px;
-  left: -6px;
-  border-left: 6px solid transparent;
-  border-right: 6px solid transparent;
-  border-bottom: 6px solid rgba(100, 130, 190, 0.3);
-  z-index: -1;
-}
-.theme-solid .menu-item {
-  color: #c4d7f0;
-}
-.theme-solid .menu-item:hover {
-  background-color: #2b3f66;
-  color: #32F6F8;
-}
-.theme-solid .menu-item.is-active {
-  color: #32F6F8;
-  font-weight: bold;
+  transition: 0.2s;
+  user-select: none;
 }
 
-/* Vue 过渡动画 */
-.fade-enter-active, .fade-leave-active {
+.fade-enter-active,
+.fade-leave-active {
   transition: opacity 0.2s, transform 0.2s;
 }
-.fade-enter, .fade-leave-to {
+
+.fade-enter,
+.fade-leave-to {
   opacity: 0;
-  transform: translate(-50%, -5px);
+  transform: translateY(-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>

+ 23 - 3
src/components/ui/IntersectionControlCard.vue

@@ -14,6 +14,9 @@
                 <div class="info-item">驻留阶段:<span>{{ data.stage }}</span></div>
                 <div class="info-item">执行方式:<span>{{ data.mode }}</span></div>
                 <div class="info-item">剩余时间:<span class="time">{{ data.timeLeft }}s</span></div>
+                <div class="info-item lock-time-row">
+                    锁定时间:<DropdownSelect v-model="lockTime" :options="lockTimeOptions" size="auto" theme="neon" />
+                </div>
                 <button 
                     @click="$emit('action-click', data)"
                     :class="{'btn btn-view margin-top-auto': data.btnType === 'normal', 'action-btn primary': data.btnType === 'primary'}">
@@ -38,12 +41,22 @@
 
 <script>
 import IntersectionMap from '@/components/ui/IntersectionMap.vue';
+import DropdownSelect from '@/components/ui/DropdownSelect.vue';
 
 export default {
     name: 'IntersectionControlCard',
-    components: { IntersectionMap },
-    props: { 
-        data: { type: Object, required: true } 
+    components: { IntersectionMap, DropdownSelect },
+    props: {
+        data: { type: Object, required: true }
+    },
+    data() {
+        return {
+            lockTime: 30,
+            lockTimeOptions: Array.from({ length: 10 }, (_, i) => {
+                const val = (i + 1) * 30;
+                return { label: val + 's', value: val };
+            }),
+        };
     },
     methods: {
         // 单选逻辑处理
@@ -228,6 +241,13 @@ export default {
 .phase-box.is-active::after {
     opacity: 1; 
 }
+.lock-time-row {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    white-space: nowrap;
+}
+
 .margin-top-auto {
     margin-top: auto;
 }

+ 4 - 3
src/components/ui/IntersectionMapVideos.vue

@@ -423,9 +423,10 @@ export default {
         });
       };
 
-      // 灯带代表人行道:车通行时人行道为红,车停时人行道为绿
-      const nsPedColor = signals.ns.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN;
-      const ewPedColor = signals.ew.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN;
+      // 灯带代表人行道:P1/P3绿灯期间正常(车绿人红、车红人绿),其余时段人行道全红
+      const pedAllRed = signals.pedAllRed || false;
+      const nsPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ns.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
+      const ewPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ew.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
 
       dyeArm('N', this.armsNodes.N, nsPedColor, nsColor, nsActiveTypes);
       dyeArm('S', this.armsNodes.S, nsPedColor, nsColor, nsActiveTypes);

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

@@ -1,6 +1,6 @@
 <template>
   <div class="online-status-tabs">
-    <TechTabs v-model="activeTab" :interval="10000" type="segmented" autoPlay  @tab-click="handleTabClick">
+    <TechTabs v-model="activeTab" :interval="3000" type="segmented" autoPlay  @tab-click="handleTabClick">
       <TechTabPane label="信号机" name="signalMachine">
         <DynamicDonutChart 
           v-if="activeTab === 'signalMachine' && displayData"

+ 28 - 61
src/components/ui/SignalTimingChart.vue

@@ -6,38 +6,28 @@
 import * as echarts from 'echarts';
 import echartsResize from '@/mixins/echartsResize.js';
 
-// 模块级共享定时器:所有实例订阅同一个 tick,保证扫描线完全同步
-let sharedEpoch = 0;
-let sharedTimer = null;
-let sharedListeners = new Set();
-let sharedBatchId = 0; // 批次ID,切换分页时递增
-
-function joinSharedTimer(listener, batchId) {
-  // 新批次:重置定时器和 epoch
-  if (batchId !== sharedBatchId) {
-    sharedBatchId = batchId;
-    if (sharedTimer) { clearInterval(sharedTimer); sharedTimer = null; }
-    sharedListeners.clear();
-    sharedEpoch = Math.floor(Date.now() / 1000);
-  }
-  if (!sharedTimer) {
-    sharedEpoch = Math.floor(Date.now() / 1000);
-    sharedTimer = setInterval(() => {
-      const elapsed = Math.floor(Date.now() / 1000) - sharedEpoch;
-      sharedListeners.forEach(fn => fn(elapsed));
+// 全局心跳定时器:所有 autoScan 实例共享同一个 setInterval
+// 各实例基于 Date.now() 对自身 cycleLength 取模计算位置,天然同步
+let _globalTimer = null;
+let _globalListeners = new Set();
+
+function joinGlobalTimer(listener) {
+  _globalListeners.add(listener);
+  if (!_globalTimer) {
+    _globalTimer = setInterval(() => {
+      const nowSec = Math.floor(Date.now() / 1000);
+      _globalListeners.forEach(fn => fn(nowSec));
     }, 1000);
   }
-  sharedListeners.add(listener);
-  // 立即触发一次,避免 mounted 到首次 tick 之间的闪跳
-  listener(Math.floor(Date.now() / 1000) - sharedEpoch);
+  // 立即触发一次,避免 mounted 到首次 tick 之间的空白
+  listener(Math.floor(Date.now() / 1000));
 }
 
-function leaveSharedTimer(listener) {
-  sharedListeners.delete(listener);
-  if (sharedListeners.size === 0 && sharedTimer) {
-    clearInterval(sharedTimer);
-    sharedTimer = null;
-    sharedEpoch = 0;
+function leaveGlobalTimer(listener) {
+  _globalListeners.delete(listener);
+  if (_globalListeners.size === 0 && _globalTimer) {
+    clearInterval(_globalTimer);
+    _globalTimer = null;
   }
 }
 
@@ -108,8 +98,7 @@ export default {
     showAxis: { type: Boolean, default: true },
     showScanLine: { type: Boolean, default: true },
     showScanLineLabel: { type: Boolean, default: true },
-    autoScan: { type: Boolean, default: false },
-    syncScan: { type: Boolean, default: false }
+    autoScan: { type: Boolean, default: false }
   },
   data() {
     return { scaleFactor: 1, internalTime: 0 };
@@ -131,17 +120,11 @@ export default {
     currentTime(val) {
       if (!this.autoScan) {
         if (this.$_chart) this.updateScanLine();
-      } else {
-        // 页切换时 currentTime 变化,重新加入共享定时器触发 epoch 重置
-        this.startAutoScan();
       }
     },
     autoScan(val) {
       if (val) { this.startAutoScan(); } else { this.stopAutoScan(); }
     },
-    syncScan() {
-      if (this.autoScan) { this.startAutoScan(); }
-    },
     showScanLine(val) {
       this.updateChart();
       if (val && this.autoScan) { this.startAutoScan(); }
@@ -158,33 +141,17 @@ export default {
     },
     startAutoScan() {
       this.stopAutoScan();
-      const VISUAL_PERIOD = 120;
-      if (this.syncScan) {
-        // 共享定时器:所有行扫描线完全同步
-        this._scanListener = (elapsed) => {
-          const realMax = this.getMaxTime();
-          const offset = this.currentTime || 0;
-          const ratio = ((offset + elapsed) % VISUAL_PERIOD) / VISUAL_PERIOD;
-          this.internalTime = ratio * realMax;
-          if (this.$_chart) this.updateScanLine();
-          this.$emit('scan-tick', this.internalTime);
-        };
-        joinSharedTimer(this._scanListener, this.currentTime || 0);
-      } else {
-        // 独立定时器:每行扫描线从 currentTime 位置开始独立移动
-        this.internalTime = this.currentTime || 0;
-        this._soloTimer = setInterval(() => {
-          const realMax = this.getMaxTime();
-          this.internalTime += 1;
-          if (this.internalTime > realMax) this.internalTime = 0;
-          if (this.$_chart) this.updateScanLine();
-          this.$emit('scan-tick', this.internalTime);
-        }, 1000);
-      }
+      // 全局心跳 + 绝对时间取模:相同 cycleLength 的实例扫描线天然同步
+      this._scanListener = (nowSec) => {
+        const realMax = this.getMaxTime();
+        this.internalTime = nowSec % realMax;
+        if (this.$_chart) this.updateScanLine();
+        this.$emit('scan-tick', this.internalTime);
+      };
+      joinGlobalTimer(this._scanListener);
     },
     stopAutoScan() {
-      if (this._scanListener) { leaveSharedTimer(this._scanListener); this._scanListener = null; }
-      if (this._soloTimer) { clearInterval(this._soloTimer); this._soloTimer = null; }
+      if (this._scanListener) { leaveGlobalTimer(this._scanListener); this._scanListener = null; }
     },
     initChart() {
       const chartDom = this.$refs.chartRef;

+ 0 - 5
src/components/ui/SpecialTaskMonitorPanel.vue

@@ -16,7 +16,6 @@
           <IntersectionControlCard 
             v-if="item.card" 
             :data="item.card" 
-            class="margin-top-20"
             @action-click="handleCardAction"
           />
         </div>
@@ -160,10 +159,6 @@ export default {
   box-sizing: border-box;
 }
 
-.margin-top-20 {
-  margin-top: 20px;
-}
-
 /* ================= 自定义左右箭头样式 ================= */
 .swiper-button-prev:after, .swiper-button-next:after { display: none; } /* 隐藏默认箭头 */
 

+ 114 - 59
src/mock/api.js

@@ -50,6 +50,20 @@ function seededRand(seed) {
   return x - Math.floor(x)
 }
 
+/** 根据路口 ID 生成稳定 seed(全字符加权,所有 API 共用) */
+function _idSeed(id) {
+  return id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
+}
+
+/** 根据路口 ID 获取稳定的 cycleLength(优先 preset → crossingList → seed 兜底) */
+function _getCycleLength(id) {
+  const preset = DB.signalTimings[id]
+  if (preset) return preset.data.cycleLength
+  const crossing = DB.crossingList.find(r => r.id === id)
+  if (crossing && crossing.cycle) return crossing.cycle
+  return [100, 120, 130, 140, 150, 160][_idSeed(id) % 6]
+}
+
 /** 当前时间 HH:MM:SS */
 function nowTime() { return new Date().toLocaleTimeString() }
 function nowDate() { return new Date().toLocaleDateString() }
@@ -156,7 +170,14 @@ function _makeIntersectionConfig(id, name, { fixedNsGreen, iconMode = 'default'
  * @param {number} cycleLength 周期总时长
  * @param {boolean} isTwoRows 是否生成上下双排 8 相位 (默认 true)
  */
-function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default') {
+// 相位数据缓存:同一路口 (cycleLength+iconMode) 只生成一次,列表和详情弹窗共享
+const _phaseDataCache = {};
+
+function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default', id = '') {
+  // 缓存 key:用路口 ID(有的话),保证同一路口列表和详情共享同一份数据
+  const cacheKey = id || `${cycleLength}_${iconMode}`;
+  if (_phaseDataCache[cacheKey]) return _phaseDataCache[cacheKey];
+
   const n = 4; // 4个阶段 (S1-S4)
   // 各阶段按比例分配时间,P1/P3较长,P2/P4较短
   const ratios = [0.3, 0.2, 0.3, 0.2];
@@ -165,10 +186,6 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default
   stageTimes[0] += cycleLength - stageTimes.reduce((a, b) => a + b, 0);
   const pd = [];
 
-  // ==========================================
-  // 修改点:将单个图标改为用逗号分隔的"成对图标"字符串
-  // 前端组件会按逗号切割并分别放到对角位置
-  // ==========================================
   // 固定4个阶段的图标和方向:P1南北直行、P2南北左转、P3东西直行、P4东西左转
   const phaseConfigMap = {
     default: [
@@ -218,11 +235,13 @@ function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default
 
     pushTrackData(0, 'P'); // 生成第一排 (P1-P4)
     if (isTwoRows) {
-      pushTrackData(1, 'P'); // 生成第二排 (P5-P8,由于逻辑相同,名称可根据需要改为 i+5)
+      pushTrackData(1, 'P'); // 生成第二排
     }
 
     t = stageEnd;
   }
+
+  _phaseDataCache[cacheKey] = pd;
   return pd;
 }
 
@@ -375,19 +394,11 @@ export async function apiGetIntersectionData(id, { fixedNsGreen } = {}) {
  */
 export async function apiGetSignalTiming(id) {
   await delay(300)
-  const preset = DB.signalTimings[id]
-  if (preset) {
-    const cycleLength = preset.data.cycleLength
-    return {
-      code: 200, message: 'success',
-      data: { ...preset.data, currentTime: Math.floor(Date.now() / 1000) % cycleLength }
-    }
-  }
-  const cycleLength = [100, 120, 130, 140, 150, 160][Math.floor(Math.random() * 6)]
+  const cycleLength = _getCycleLength(id)
   return ok({
     cycleLength,
     currentTime: Math.floor(Date.now() / 1000) % cycleLength,
-    phaseData: _makePhaseData(cycleLength, false),
+    phaseData: _makePhaseData(cycleLength, false, 'simple', id),
   })
 }
 
@@ -438,6 +449,23 @@ export async function apiGetTrunkLineMenuTree() {
  * GET /api/devices/status/summary
  * 在线数每次请求轻微波动
  */
+// ── 信号机故障数缓存:同一秒内多次调用返回同一份数据,保证在线离线与故障同步 ──
+let _smFaultCache = { ts: 0, total: 0, faultTotal: 0 }
+function _getSmFaultSnapshot() {
+  const now = Math.floor(Date.now() / 1000)
+  if (_smFaultCache.ts === now) return _smFaultCache
+  const sm = DB.deviceStatus.signalMachine
+  const total = sm.chartData[0].value + sm.chartData[1].value
+  const yellowFlashMode = DB.homeData.controlModes.find(m => m.name === '黄闪控制')
+  const yellowFlash = yellowFlashMode ? yellowFlashMode.value : 0
+  const ctrlBoard = Math.max(0, _fluctuate(5, 3))
+  const phaseBoard = Math.max(0, _fluctuate(4, 2))
+  const detBoard = Math.max(0, _fluctuate(3, 2))
+  const faultTotal = ctrlBoard + phaseBoard + detBoard + yellowFlash
+  _smFaultCache = { ts: now, total, faultTotal, ctrlBoard, phaseBoard, detBoard, yellowFlash }
+  return _smFaultCache
+}
+
 export async function apiGetDeviceStatus(type) {
   await delay(200)
   function fluctuateStats(base) {
@@ -455,9 +483,24 @@ export async function apiGetDeviceStatus(type) {
       ]
     }
   }
+  // 信号机:离线数 = 故障总数(与设备状态同步)
+  function smStats() {
+    const snap = _getSmFaultSnapshot()
+    const online = snap.total - snap.faultTotal
+    const rate = Math.round(online / snap.total * 100)
+    return {
+      centerTitle: rate + '%',
+      centerSubTitle: `${online}/${snap.total}`,
+      chartData: [
+        { name: '在线', value: online, color: '#32F6F8' },
+        { name: '离线', value: snap.faultTotal, color: '#E4D552' },
+      ]
+    }
+  }
+  if (type === 'signalMachine') return ok(smStats())
   if (type && DB.deviceStatus[type]) return ok(fluctuateStats(DB.deviceStatus[type]))
   return ok({
-    signalMachine: fluctuateStats(DB.deviceStatus.signalMachine),
+    signalMachine: smStats(),
     detector: fluctuateStats(DB.deviceStatus.detector),
     camera: fluctuateStats(DB.deviceStatus.camera),
   })
@@ -488,12 +531,20 @@ export async function apiGetHomeSnapshot() {
   })
 }
 
-/** GET /api/home/control-mode-stats — 控制模式分布(轻微波动) */
+/** GET /api/home/control-mode-stats — 控制模式分布(总数与信号机总数同步,内部重分配) */
 export async function apiGetControlModeStats() {
   await delay(150)
-  return ok(DB.homeData.controlModes.map(m => ({
-    ...m, value: _fluctuate(m.value, Math.ceil(m.value * 0.05)),
-  })))
+  const sm = DB.deviceStatus.signalMachine
+  const total = sm.chartData[0].value + sm.chartData[1].value
+  const modes = DB.homeData.controlModes
+  // 各项按基准值波动
+  const fluctuated = modes.map(m => ({
+    ...m, value: Math.max(0, _fluctuate(m.value, Math.ceil(m.value * 0.05))),
+  }))
+  // 修正总数:将差值补到第一项(定周期控制),保证总数 = 信号机总数
+  const currentSum = fluctuated.reduce((s, m) => s + m.value, 0)
+  fluctuated[0].value = Math.max(0, fluctuated[0].value + (total - currentSum))
+  return ok(fluctuated)
 }
 
 /**
@@ -621,12 +672,8 @@ export async function apiGetCrossingList(params = {}) {
   const page = params.page || 1
   const pageOffset = Math.floor(seededRand(page * 97) * 120)
   let list = DB.crossingList.map((r, i) => {
-    const preset = DB.signalTimings[r.id]
-    const cycleLength = preset ? preset.data.cycleLength : r.cycle
-    // const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength, false)
-
-    // 强制全部用 _makePhaseData 动态生成成对箭头
-    const phaseData = _makePhaseData(cycleLength, false)
+    const cycleLength = _getCycleLength(r.id)
+    const phaseData = _makePhaseData(cycleLength, false, 'simple', r.id)
     return {
       ...r,
       status: _getDeviceStatus(r.id),
@@ -810,15 +857,14 @@ export async function apiGetCrossingPanelData(id) {
   await delay(300)
   const point = DB.points.find(p => p.id === id)
   const config = DB.intersectionConfigs[id] || _makeIntersectionConfig(id, null, { fixedNsGreen: false, iconMode: 'simple' })
-  const seed = id ? id.charCodeAt(id.length - 1) : 0
+  const seed = _idSeed(id)
   // 确保 config 有 status
   if (!config.status) {
     config.status = _getDeviceStatus(id)
   }
 
-  const preset = DB.signalTimings[id]
-  const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][Math.abs(seed) % 6]
-  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength, false, 'simple')
+  const cycleLength = _getCycleLength(id)
+  const phaseData = _makePhaseData(cycleLength, false, 'simple', id)
   const currentTime = Math.floor(Date.now() / 1000) % cycleLength
 
   return ok({
@@ -842,8 +888,7 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   const point = DB.points.find(p => p.id === id)
   const config = DB.intersectionConfigs[id] || _makeIntersectionConfig(id, null, { fixedNsGreen: false, iconMode })
 
-  // 用 id 的全部字符生成稳定 seed(加权位置避免 charCode 总和碰撞)
-  const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
+  const seed = _idSeed(id)
 
   // 确保 config 有 status 字段(预存配置可能缺失)
   if (!config.status) {
@@ -851,9 +896,8 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   }
 
   // 从真实阶段数据推导周期和相位
-  const preset = DB.signalTimings[id]
-  const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][seed % 6]
-  const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength || 140, false, iconMode)
+  const cycleLength = _getCycleLength(id)
+  const phaseData = _makePhaseData(cycleLength, false, 'simple', id)
 
   // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0,最多4个)
   const hasTrack1 = phaseData.some(p => p[0] === 1)
@@ -868,11 +912,12 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
 
   // 控制方式选项 + 根据路口选择不同的当前控制方式
   const allMethods = [
-    { label: '定周期', value: 'fixed' },
-    { label: '黄闪', value: 'yellow_flash' },
     { label: '关灯', value: 'lights_off' },
+    { label: '黄闪', value: 'yellow_flash' },
+    { label: '全红', value: 'all_red' },
+    { label: '定周期', value: 'fixed' },
     { label: '步进', value: 'step' },
-    { label: '系统方案', value: 'system' },
+    { label: '中心控制', value: 'system' },
     { label: '感应控制', value: 'sensor' },
     { label: '临时方案', value: 'temp' },
   ]
@@ -1029,41 +1074,51 @@ export async function apiGetMapLegendConfig() {
  */
 export async function apiGetDeviceFaultStatus() {
   await delay(200)
-  const sm = DB.deviceStatus.signalMachine
   const dt = DB.deviceStatus.detector
   const cam = DB.deviceStatus.camera
 
-  // 从在线数据推算故障数,每次波动
-  const smTotal = sm.chartData[0].value + sm.chartData[1].value
-  const smFault = 0  // 信号机无故障,用于测试无故障状态
+  // ── 信号机故障:从共享缓存获取,确保与在线状态的离线数一致 ──
+  const snap = _getSmFaultSnapshot()
+  const smTotal = snap.total
+  const smFaultTotal = snap.faultTotal
+  const smFaultList = [
+    { name: '正常', value: Math.max(0, smTotal - smFaultTotal), color: '#A0E551' },
+    { name: '控制板报警', value: snap.ctrlBoard, color: '#FF4545' },
+    { name: '相位板报警', value: snap.phaseBoard, color: '#D42A2A' },
+    { name: '检测板报警', value: snap.detBoard, color: '#9B1B1B' },
+    { name: '黄闪报警', value: snap.yellowFlash, color: '#5C0E0E' },
+  ]
+
+  // ── 检测器故障 ──
   const dtTotal = dt.chartData[0].value + dt.chartData[1].value
-  const dtFault = _fluctuate(dt.chartData[1].value, 5)
+  const dtFault = Math.max(0, _fluctuate(dt.chartData[1].value, 5))
+  const dtCommFault = Math.max(0, Math.floor(dtFault * 0.6))
+
+  // ── 红绿灯故障 ──
   const camTotal = cam.chartData[0].value + cam.chartData[1].value
-  const camFault = _fluctuate(cam.chartData[1].value, 2)
+  const camFault = Math.max(0, _fluctuate(cam.chartData[1].value, 2))
+  const camConflict = Math.max(0, Math.floor(camFault * 0.5))
 
   return ok({
     signalMachineStatus: {
-      centerTitle: Math.max(0, smFault) + '',
-      centerSubTitle: `${Math.max(0, smFault)}/${smTotal}`,
-      chartData: [
-        { name: '正常', value: Math.max(0, smTotal - smFault), color: '#A0E551' },
-        { name: '故障', value: Math.max(0, smFault), color: '#D03030' },
-      ]
+      centerTitle: smFaultTotal + '',
+      centerSubTitle: `${smFaultTotal}/${smTotal}`,
+      chartData: smFaultList,
     },
     detectorStatus: {
-      centerTitle: Math.max(0, dtFault) + '',
-      centerSubTitle: `${Math.max(0, dtFault)}/${dtTotal}`,
+      centerTitle: dtFault + '',
+      centerSubTitle: `${dtFault}/${dtTotal}`,
       chartData: [
-        { name: '通信故障', value: Math.max(0, Math.floor(dtFault * 0.6)), color: '#C6302B' },
-        { name: '数据异常', value: Math.max(0, dtFault - Math.floor(dtFault * 0.6)), color: '#faad14' },
+        { name: '通信故障', value: dtCommFault, color: '#C6302B' },
+        { name: '数据异常', value: Math.max(0, dtFault - dtCommFault), color: '#faad14' },
       ]
     },
     trafficLightStatus: {
-      centerTitle: Math.max(0, camFault) + '',
-      centerSubTitle: `${Math.max(0, camFault)}/${camTotal}`,
+      centerTitle: camFault + '',
+      centerSubTitle: `${camFault}/${camTotal}`,
       chartData: [
-        { name: '红绿冲突', value: Math.max(0, Math.floor(camFault * 0.5)), color: '#C6302B' },
-        { name: '红灯故障', value: Math.max(0, camFault - Math.floor(camFault * 0.5)), color: '#8F1E1E' },
+        { name: '红绿冲突', value: camConflict, color: '#C6302B' },
+        { name: '红灯故障', value: Math.max(0, camFault - camConflict), color: '#8F1E1E' },
       ]
     },
   })

+ 2 - 2
src/mock/mock_data.json

@@ -16562,7 +16562,7 @@
     "controlModes": [
       {
         "name": "定周期控制",
-        "value": 394,
+        "value": 396,
         "color": "#33a3ff"
       },
       {
@@ -16572,7 +16572,7 @@
       },
       {
         "name": "干线协调",
-        "value": 179,
+        "value": 180,
         "color": "#10b981"
       },
       {

+ 13 - 6
src/views/SpecialSituationMonitoring.vue

@@ -80,19 +80,19 @@
                 <!-- 总览Tab -->
                 <template v-if="activeLeftTab === 'overview'">
                     <div class="top-chart-box overview-chart-box">
-                        <OnlineStatusTabs />
+                        <OnlineStatusTabs :deviceData="onlineStatusData" />
                     </div>
                     <div class="top-chart-box overview-chart-box">
-                        <DeviceStatusTabs />
+                        <DeviceStatusTabs :statusData="deviceFaultData" />
                     </div>
                 </template>
                 <!-- 路口Tab -->
                 <template v-if="activeLeftTab === 'crossing'">
                     <div class="top-chart-box overview-chart-box">
-                        <OnlineStatusTabs />
+                        <OnlineStatusTabs :deviceData="onlineStatusData" />
                     </div>
                     <div class="top-chart-box overview-chart-box">
-                        <DeviceStatusTabs />
+                        <DeviceStatusTabs :statusData="deviceFaultData" />
                     </div>
                 </template>
             </div>
@@ -113,7 +113,7 @@ import TaskCardList from '@/components/ui/TaskCardList.vue';
 import CrossingListPanel from '@/components/ui/CrossingListPanel.vue';
 import OnlineStatusTabs from '@/components/ui/OnlineStatusTabs.vue';
 import DeviceStatusTabs from '@/components/ui/DeviceStatusTabs.vue';
-import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetCrossingDetailData } from '@/api';
+import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetCrossingDetailData, apiGetDeviceStatus, apiGetDeviceFaultStatus } from '@/api';
 
 
 export default {
@@ -159,6 +159,9 @@ export default {
             // 路口多选分屏
             crossingSelections: [],
             maxCrossingSlots: 4,
+            // 在线状态 & 设备状态数据
+            onlineStatusData: null,
+            deviceFaultData: null,
         };
     },
     watch: {
@@ -175,13 +178,17 @@ export default {
     },
     async mounted() {
         // 加载菜单和任务数据
-        const [menuData, taskData] = await Promise.all([
+        const [menuData, taskData, onlineData, faultData] = await Promise.all([
             apiGetTongzhouMenuTree(),
             apiGetTasks({ pageSize: 5 }),
+            apiGetDeviceStatus(),
+            apiGetDeviceFaultStatus(),
         ]);
         this.menuData = menuData || [];
         this.trunkLineMenuData = [];
         this.tableData = taskData?.list || taskData || [];
+        this.onlineStatusData = onlineData || null;
+        this.deviceFaultData = faultData || null;
 
         // 组件挂载时检查路由
         this.checkRouteParams();

+ 13 - 6
src/views/StatusMonitoring.vue

@@ -80,19 +80,19 @@
                 <!-- 总览Tab -->
                 <template v-if="activeLeftTab === 'overview'">
                     <div class="top-chart-box overview-chart-box">
-                        <OnlineStatusTabs />
+                        <OnlineStatusTabs :deviceData="onlineStatusData" />
                     </div>
                     <div class="top-chart-box overview-chart-box">
-                        <DeviceStatusTabs />
+                        <DeviceStatusTabs :statusData="deviceFaultData" />
                     </div>
                 </template>
                 <!-- 路口Tab -->
                 <template v-if="activeLeftTab === 'crossing'">
                     <div class="top-chart-box overview-chart-box">
-                        <OnlineStatusTabs />
+                        <OnlineStatusTabs :deviceData="onlineStatusData" />
                     </div>
                     <div class="top-chart-box overview-chart-box">
-                        <DeviceStatusTabs />
+                        <DeviceStatusTabs :statusData="deviceFaultData" />
                     </div>
                 </template>
             </div>
@@ -113,7 +113,7 @@ import TaskCardList from '@/components/ui/TaskCardList.vue';
 import CrossingListPanel from '@/components/ui/CrossingListPanel.vue';
 import OnlineStatusTabs from '@/components/ui/OnlineStatusTabs.vue';
 import DeviceStatusTabs from '@/components/ui/DeviceStatusTabs.vue';
-import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetCrossingDetailData } from '@/api';
+import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetCrossingDetailData, apiGetDeviceStatus, apiGetDeviceFaultStatus } from '@/api';
 
 
 export default {
@@ -159,6 +159,9 @@ export default {
             // 路口多选分屏
             crossingSelections: [],
             maxCrossingSlots: 4,
+            // 在线状态 & 设备状态数据
+            onlineStatusData: null,
+            deviceFaultData: null,
         };
     },
     watch: {
@@ -175,13 +178,17 @@ export default {
     },
     async mounted() {
         // 加载菜单和任务数据
-        const [menuData, taskData] = await Promise.all([
+        const [menuData, taskData, onlineData, faultData] = await Promise.all([
             apiGetTongzhouMenuTree(),
             apiGetTasks({ pageSize: 5 }),
+            apiGetDeviceStatus(),
+            apiGetDeviceFaultStatus(),
         ]);
         this.menuData = menuData || [];
         this.trunkLineMenuData = [];
         this.tableData = taskData?.list || taskData || [];
+        this.onlineStatusData = onlineData || null;
+        this.deviceFaultData = faultData || null;
 
         // 组件挂载时检查路由
         this.checkRouteParams();

+ 13 - 6
src/views/TrunkCoordination.vue

@@ -80,19 +80,19 @@
                 <!-- 总览Tab -->
                 <template v-if="activeLeftTab === 'overview'">
                     <div class="top-chart-box overview-chart-box">
-                        <OnlineStatusTabs />
+                        <OnlineStatusTabs :deviceData="onlineStatusData" />
                     </div>
                     <div class="top-chart-box overview-chart-box">
-                        <DeviceStatusTabs />
+                        <DeviceStatusTabs :statusData="deviceFaultData" />
                     </div>
                 </template>
                 <!-- 路口Tab -->
                 <template v-if="activeLeftTab === 'crossing'">
                     <div class="top-chart-box overview-chart-box">
-                        <OnlineStatusTabs />
+                        <OnlineStatusTabs :deviceData="onlineStatusData" />
                     </div>
                     <div class="top-chart-box overview-chart-box">
-                        <DeviceStatusTabs />
+                        <DeviceStatusTabs :statusData="deviceFaultData" />
                     </div>
                 </template>
             </div>
@@ -113,7 +113,7 @@ import TaskCardList from '@/components/ui/TaskCardList.vue';
 import CrossingListPanel from '@/components/ui/CrossingListPanel.vue';
 import OnlineStatusTabs from '@/components/ui/OnlineStatusTabs.vue';
 import DeviceStatusTabs from '@/components/ui/DeviceStatusTabs.vue';
-import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetCrossingDetailData } from '@/api';
+import { apiGetTongzhouMenuTree, apiGetTasks, apiGetTrafficTimeSpace, apiGetCrossingTopCharts, apiGetSpecialTaskMonitorData, apiGetCrossingDetailData, apiGetDeviceStatus, apiGetDeviceFaultStatus } from '@/api';
 
 
 export default {
@@ -159,6 +159,9 @@ export default {
             // 路口多选分屏
             crossingSelections: [],
             maxCrossingSlots: 4,
+            // 在线状态 & 设备状态数据
+            onlineStatusData: null,
+            deviceFaultData: null,
         };
     },
     watch: {
@@ -175,13 +178,17 @@ export default {
     },
     async mounted() {
         // 加载菜单和任务数据
-        const [menuData, taskData] = await Promise.all([
+        const [menuData, taskData, onlineData, faultData] = await Promise.all([
             apiGetTongzhouMenuTree(),
             apiGetTasks({ pageSize: 5 }),
+            apiGetDeviceStatus(),
+            apiGetDeviceFaultStatus(),
         ]);
         this.menuData = menuData || [];
         this.trunkLineMenuData = [];
         this.tableData = taskData?.list || taskData || [];
+        this.onlineStatusData = onlineData || null;
+        this.deviceFaultData = faultData || null;
 
         // 组件挂载时检查路由
         this.checkRouteParams();