Pārlūkot izejas kodu

路口详情-当前阶段: 4 列 2 行预留 + 超 8 阶段内部滚动 + 多阶段示例数据

  CrossingDetailPanel.vue:
  - .current-stage 从 flex 改 4 列 grid; 始终锁 2 行 + 行间 gap (--stage-reserved-h),
    不足 8 阶段时第 2 行留白, ≥9 阶段时内部 overflow-y: auto 滚动
  - grid-auto-rows: max-content + align-content/items: start: 防 stretch 与
    ResizeObserver 形成"测量→撑大→再测"循环 (会导致几行变十几行)
  - _observeFirstStageItem: ResizeObserver + rAF + 值比较, 测首个 stage-item 高度
    写 --stage-row-h / --stage-reserved-h; watch currentStageList 重挂观察器,
    watch currentStage 自动 scrollIntoView 到当前阶段
  - 删 .current-stage-warp 包裹层和"当前阶段:"标签 (现 .current-stage 直接是 grid)
  - .stage-item-wrapper 新增 --item-max-w (clamp(60, 100s, 110)px),
    phase-box 和 bottom-controls 共用上限同宽对齐
  - donut-row 从 .form-interactive-area 抽出到 .form-group 直接子节点,
    脱离 current-stage 的滚动上下文, 4 阶段时圆饼图始终可见
  - .form-interactive-area flex:1 → flex:0:0:auto + overflow:visible,
    内部只剩 current-stage 自管滚动, donut 紧贴下方
  - .button-group margin-top: auto + position: sticky bottom: 0, 内容溢出
    右栏视口时贴底; 无背景, 与原版视觉一致
  - .current-stage 自定义滚动条样式 (6px 宽, rgba(161,190,255,.45) 浅蓝拇指,
    Firefox scrollbar-color + WebKit ::-webkit-scrollbar-*)
画安 1 mēnesi atpakaļ
vecāks
revīzija
5c5f86b9d3
3 mainītis faili ar 202 papildinājumiem un 63 dzēšanām
  1. 146 60
      src/components/ui/CrossingDetailPanel.vue
  2. 10 3
      src/mock/api.js
  3. 46 0
      src/mock/mock_data.json

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

@@ -58,53 +58,51 @@
                     <div class="form-interactive-area">
                         <div class="form-editable-area" :class="{ 'is-disabled': !isManualMode }">
                             <div class="control-scheme">
-                                <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="onStageClick(item.value)">
-                                                <PhaseDiagram
-                                                    v-if="item.icons && item.icons.length"
-                                                    :icons="item.icons"
-                                                    :no="item.no || item.value"
-                                                    :arrow-color="item.arrowColor"
-                                                    :arrow-colors="item.arrowColors || {}"
-                                                    :bg-color="item.bgColor"
-                                                    :number-color="item.numberColor"
-                                                />
-                                                <img v-else :src="item.img" alt="stage" class="phase-image" />
-                                            </div>
+                                <div class="current-stage" ref="stageGrid">
+                                    <div v-for="(item, index) in currentStageList" :key="index"
+                                        class="stage-item-wrapper">
+                                        <div class="phase-box" :class="{ 'is-active': item.value === currentStage }"
+                                            @click="onStageClick(item.value)">
+                                            <PhaseDiagram
+                                                v-if="item.icons && item.icons.length"
+                                                :icons="item.icons"
+                                                :no="item.no || item.value"
+                                                :arrow-color="item.arrowColor"
+                                                :arrow-colors="item.arrowColors || {}"
+                                                :bg-color="item.bgColor"
+                                                :number-color="item.numberColor"
+                                            />
+                                            <img v-else :src="item.img" alt="stage" class="phase-image" />
+                                        </div>
 
-                                            <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 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>
                         </div>
+                    </div>
 
-                        <!-- 方案圆饼图 -->
-                        <div class="donut-row">
-                            <div class="donut-item">
-                                <div class="donut-title">实时方案(执行方案3)</div>
-                                <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" />
-                            </div>
+                    <!-- 方案圆饼图: 从 .form-interactive-area 抽出, 作为 .form-group 直接子节点,
+                         脱离 current-stage 的滚动上下文, 保证 4 阶段时圆饼图始终可见 -->
+                    <div class="donut-row">
+                        <div class="donut-item">
+                            <div class="donut-title">实时方案(执行方案3)</div>
+                            <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" />
                         </div>
                     </div>
 
@@ -295,7 +293,15 @@ export default {
 
             // 模拟需求1:根据不同模式,切换对应的控制方案数据 (Mock 逻辑)
             this.updateSchemeDataByMethod(newVal);
-        }
+        },
+        // 阶段列表变化时重挂 ResizeObserver 到新的首个元素 (max-height 跟着重算)
+        currentStageList() {
+            this.$nextTick(() => this._observeFirstStageItem());
+        },
+        // 当前阶段切到第 5+ 个时, 自动滚到可见
+        currentStage() {
+            this.$nextTick(() => this._scrollCurrentStageIntoView());
+        },
     },
     mounted() {
         this.initScaleObserver();
@@ -307,6 +313,8 @@ export default {
     },
     beforeDestroy() {
         if (this._ro) this._ro.disconnect();
+        if (this._stageRO) this._stageRO.disconnect();
+        if (this._stageRaf) cancelAnimationFrame(this._stageRaf);
     },
     methods: {
         // 点击阶段:切换选中,步进模式下同时弹出锁定时间
@@ -462,6 +470,50 @@ export default {
                 }))
             ];
         },
+        // 观察首个 stage-item-wrapper, 算出 2 行高度(含行间 gap) 写到 CSS 变量。
+        // grid 的 height 锁在 2 行高度: 不足 8 个时下排留白; 超 8 个时按行滚动。
+        // 防 ResizeObserver loop 警告: rAF 推到下一帧 + 值比较切断"测量→撑大→再测"循环。
+        _observeFirstStageItem() {
+            const grid = this.$refs.stageGrid;
+            if (!grid) return;
+            if (this._stageRO) {
+                this._stageRO.disconnect();
+                this._stageRO = null;
+            }
+            const first = grid.firstElementChild;
+            if (!first) return;
+            const schedule = () => {
+                if (this._stageRaf) return;
+                this._stageRaf = requestAnimationFrame(() => {
+                    this._stageRaf = 0;
+                    const f = grid.firstElementChild;
+                    if (!f) return;
+                    const rowH = f.offsetHeight;
+                    if (rowH <= 0) return;
+                    const cs = window.getComputedStyle(grid);
+                    const gap = parseFloat(cs.rowGap || cs.gap || '0') || 0;
+                    const reservedH = rowH * 2 + gap;
+                    if (this._lastRowH === rowH && this._lastReservedH === reservedH) return;
+                    this._lastRowH = rowH;
+                    this._lastReservedH = reservedH;
+                    grid.style.setProperty('--stage-row-h', rowH + 'px');
+                    grid.style.setProperty('--stage-reserved-h', reservedH + 'px');
+                });
+            };
+            this._stageRO = new ResizeObserver(schedule);
+            this._stageRO.observe(first);
+            schedule();
+        },
+        // 当前阶段切换后, 若它在第二行/之后, 自动滚到可见
+        _scrollCurrentStageIntoView() {
+            const grid = this.$refs.stageGrid;
+            if (!grid) return;
+            const idx = this.currentStageList.findIndex(s => s.value === this.currentStage);
+            const el = idx >= 0 ? grid.children[idx] : null;
+            if (el && typeof el.scrollIntoView === 'function') {
+                el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+            }
+        },
         initScaleObserver() {
             const ro = new ResizeObserver(entries => {
                 const { width } = entries[0].contentRect;
@@ -1134,28 +1186,44 @@ export default {
     opacity: 0;
 }
 
+/* 4 列固定网格; 始终锁 2 行 + 行间 gap (不足 8 阶段时第 2 行留白); 超 8 滚动 */
 .current-stage {
-    background-color: rgba(65, 115, 205, 0.2);
-    border: 1px solid #3660a5;
     margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
-    display: flex;
-    justify-content: center;
-}
-
-.current-stage-warp {
-    width: 100%;
-    display: flex;
-    align-items: center;
-    flex-wrap: nowrap;
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    /* 关键: grid 默认 align-content/items: stretch 会把内容拉伸填满 min-height,
+       与 _observeFirstStageItem 测量形成"测量→撑大→再测→更撑大"循环。
+       锁住行集合贴顶 + 行高按内容定, 切断循环。 */
+    grid-auto-rows: max-content;
+    align-content: start;
+    align-items: start;
     gap: clamp(8px, calc(var(--s) * 18px), 24px);
-    padding: clamp(4px, calc(var(--s) * 8px), 16px);
     color: #ffffff;
+    /* --stage-reserved-h 由 JS 测量首个 stage 后写入 (2 行高 + 行间 gap)。
+       不给 fallback: 未写入前 var() 整体无效, min/max-height 退到 initial(0/none) */
+    min-height: var(--stage-reserved-h);
+    max-height: var(--stage-reserved-h);
+    overflow-y: auto;
+    overflow-x: hidden;
+    /* Firefox 滚动条 */
+    scrollbar-width: thin;
+    scrollbar-color: rgba(161, 190, 255, 0.45) rgba(255, 255, 255, 0.04);
 }
 
-.current-stage-label {
-    font-size: clamp(10px, calc(var(--s) * 16px), 16px);
-    white-space: nowrap;
-    flex-shrink: 0;
+/* WebKit / Blink (Chrome / Edge / Electron) 滚动条样式 */
+.current-stage::-webkit-scrollbar {
+    width: 6px;
+}
+.current-stage::-webkit-scrollbar-track {
+    background: rgba(255, 255, 255, 0.04);
+    border-radius: 3px;
+}
+.current-stage::-webkit-scrollbar-thumb {
+    background: rgba(161, 190, 255, 0.45);
+    border-radius: 3px;
+}
+.current-stage::-webkit-scrollbar-thumb:hover {
+    background: rgba(161, 190, 255, 0.75);
 }
 
 .stage-input {
@@ -1171,6 +1239,9 @@ export default {
 .phase-box {
     position: relative;
     width: 100%;
+    /* 尺寸上限由 .stage-item-wrapper 的 --item-max-w 控制, 与 bottom-controls 同宽 */
+    max-width: var(--item-max-w);
+    margin: 0 auto;
     aspect-ratio: 1 / 1;
     background: #E6F0FF;
     border-radius: 4px;
@@ -1259,8 +1330,14 @@ export default {
 .button-group {
     display: flex;
     justify-content: flex-end;
-    margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
     flex-shrink: 0;
+    /* margin-top: auto: 在 .form-group(flex column) 里把按钮推到底, 即使 form 内容不满也贴底 */
+    margin-top: auto;
+    /* sticky 兜底: 内容溢出 .detail-panel-right 时(16/32 阶段且面板较矮), 滚动也能见 */
+    position: sticky;
+    bottom: 0;
+    z-index: 5;
+    padding-top: clamp(4px, calc(var(--s) * 10px), 20px);
 }
 
 .button-group>div {
@@ -1271,10 +1348,12 @@ export default {
 /* 禁用状态 */
 .form-interactive-area {
     transition: opacity 0.3s;
-    flex: 1;
+    /* donut-row 抽出去后, 内部只剩 current-stage (本身已锁 2 行高 + 内部滚动),
+       不再需要 flex:1 撑高; auto 高度让 donut 紧贴 current-stage 下方, 不留空隙 */
+    flex: 0 0 auto;
     min-height: 0;
-    overflow-y: auto;
-    overflow-x: hidden;
+    /* current-stage 内部已经有 overflow-y: auto, 这里不再二次滚动 */
+    overflow: visible;
 }
 
 .form-editable-area.is-disabled {
@@ -1295,6 +1374,8 @@ export default {
     align-items: stretch;
     gap: clamp(2px, calc(var(--s) * 4px), 6px);
     position: relative;
+    /* phase-box 和 bottom-controls 共用同一份上限宽, 保证 svg 框与下面的输入框等宽对齐 */
+    --item-max-w: clamp(60px, calc(var(--s) * 100px), 110px);
 }
 
 .bottom-controls {
@@ -1303,6 +1384,11 @@ export default {
     justify-content: center;
     gap: clamp(4px, calc(var(--s) * 6px), 8px);
     /* 输入框和百分比的间距 */
+    /* 与 phase-box 同宽: 共用 --item-max-w + 居中 */
+    width: 100%;
+    max-width: var(--item-max-w);
+    margin: 0 auto;
+    box-sizing: border-box;
 }
 
 /* 新增包裹层的相对定位 */

+ 10 - 3
src/mock/api.js

@@ -1117,9 +1117,11 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   let phaseData = _makePhaseData(cycleLength, false, 'simple', id)
 
   // 双相位图示例路口:N 阶段(≠4)走独立相位生成器,覆盖外层 phaseData/cycleLength
+  // JNC900008 用来验证 CrossingDetailPanel "8 槽两行刚满, 不出滚动条" 的边界情况
   const _dualSamples = {
     JNC900032: { stageCount: 32, cycleLength: 160, schemeName: '32阶段示范方案' },
     JNC900016: { stageCount: 16, cycleLength: 160, schemeName: '16阶段示范方案' },
+    JNC900008: { stageCount: 8,  cycleLength: 120, schemeName: '8阶段示范方案'  },
   }
   if (_dualSamples[id]) {
     const cfgSample = _dualSamples[id]
@@ -1151,9 +1153,11 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
     phaseData,
   }
 
-  // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0,最多4个)
+  // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0)
+  // 不再硬截 4 个 —— CrossingDetailPanel 现已支持 2 行 8 槽 + 滚动, 配合
+  // _dualSamples (JNC900008/016/032) 可看到 8/16/32 阶段的完整渲染
   const hasTrack1 = phaseData.some(p => p[0] === 1)
-  const greenPhases = phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[5] === 'green').slice(0, 4)
+  const greenPhases = phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[5] === 'green')
   // 每个阶段的锁定时间选项(不同阶段时长不同,锁定选项也不同)
   const stageLockOptions = [
     [{ label: '20', value: 20 }, { label: '30', value: 30 }, { label: '45', value: 45 }, { label: '60', value: 60 }],
@@ -1182,7 +1186,10 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
     { label: '临时方案', value: 'temp' },
   ]
   const methodValues = ['fixed', 'step', 'system', 'sensor', 'temp']
-  const currentMethod = methodValues[seed % methodValues.length]
+  // 示范多阶段路口固定走非-step 方法, 否则 step 模式会隐藏 button-group (取消/确认),
+  // 用户点"手动控制"看不到确认按钮, 误以为坏掉
+  const _isDualSampleId = id && /^JNC9000(?:08|16|32)$/.test(id)
+  const currentMethod = _isDualSampleId ? 'fixed' : methodValues[seed % methodValues.length]
 
   // 控制模式(显示用)
   const controlModes = ['定周期控制', '感应控制', '干线协调', '自适应控制']

+ 46 - 0
src/mock/mock_data.json

@@ -7365,6 +7365,12 @@
                       "lat": 39.93
                     },
                     {
+                      "id": "JNC900008",
+                      "label": "[示例]8阶段双相位图路口",
+                      "lng": 116.73,
+                      "lat": 39.94
+                    },
+                    {
                       "id": "JNC000220",
                       "label": "TZ-367_畅和东路与大营东街路口",
                       "lng": 116.743531,
@@ -11857,6 +11863,12 @@
                       "lat": 39.93
                     },
                     {
+                      "id": "JNC900008",
+                      "label": "[示例]8阶段双相位图路口",
+                      "lng": 116.73,
+                      "lat": 39.94
+                    },
+                    {
                       "id": "JNC000220",
                       "label": "TZ-367_畅和东路与大营东街路口",
                       "lng": 116.743531,
@@ -26431,6 +26443,21 @@
       "isKey": true,
       "lng": 116.72,
       "lat": 39.93
+    },
+    {
+      "id": "JNC900008",
+      "index": 9008,
+      "name": "[示例]8阶段双相位图路口",
+      "subArea": "REG000200",
+      "ip": "192.168.99.208",
+      "status": "在线",
+      "timeOffset": "无偏差",
+      "cycle": 120,
+      "version": "V3.2.0",
+      "node": "通州节点1",
+      "isKey": true,
+      "lng": 116.73,
+      "lat": 39.94
     }
   ],
   "securityRoutes": [
@@ -28758,6 +28785,25 @@
         { "intersection": "[示例]16阶段双相位图路口", "intersectionId": "JNC900016", "cameraId": "CAM900016_S", "loginName": "admin_16_s", "password": "******", "cameraType": "枪机", "port": 556, "ip": "192.168.99.216", "enabled": true, "position": "S", "dirKey": "S" },
         { "intersection": "[示例]16阶段双相位图路口", "intersectionId": "JNC900016", "cameraId": "CAM900016_W", "loginName": "admin_16_w", "password": "******", "cameraType": "枪机", "port": 557, "ip": "192.168.99.216", "enabled": true, "position": "W", "dirKey": "W" }
       ]
+    },
+    "JNC900008": {
+      "signals": {
+        "pedAllRed": false,
+        "ns": { "phaseName": "P1", "time": 6, "isGreen": true,  "activeArrowTypes": ["S"] },
+        "ew": { "phaseName": "P1", "time": 6, "isGreen": false, "activeArrowTypes": [] }
+      },
+      "armsConfig": {
+        "N": { "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "S": { "lanes": ["U", "S", null, null], "cameraType": 1 },
+        "E": { "lanes": ["L", "S", null, null], "cameraType": 1 },
+        "W": { "lanes": ["U", "S", null, null], "cameraType": 1 }
+      },
+      "cameras": [
+        { "intersection": "[示例]8阶段双相位图路口", "intersectionId": "JNC900008", "cameraId": "CAM900008_N", "loginName": "admin_8_n", "password": "******", "cameraType": "枪机", "port": 554, "ip": "192.168.99.208", "enabled": true, "position": "N", "dirKey": "N" },
+        { "intersection": "[示例]8阶段双相位图路口", "intersectionId": "JNC900008", "cameraId": "CAM900008_E", "loginName": "admin_8_e", "password": "******", "cameraType": "枪机", "port": 555, "ip": "192.168.99.208", "enabled": true, "position": "E", "dirKey": "E" },
+        { "intersection": "[示例]8阶段双相位图路口", "intersectionId": "JNC900008", "cameraId": "CAM900008_S", "loginName": "admin_8_s", "password": "******", "cameraType": "枪机", "port": 556, "ip": "192.168.99.208", "enabled": true, "position": "S", "dirKey": "S" },
+        { "intersection": "[示例]8阶段双相位图路口", "intersectionId": "JNC900008", "cameraId": "CAM900008_W", "loginName": "admin_8_w", "password": "******", "cameraType": "枪机", "port": 557, "ip": "192.168.99.208", "enabled": true, "position": "W", "dirKey": "W" }
+      ]
     }
   },
   "trunkLineMenuTree": [