|
|
@@ -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;
|
|
|
}
|
|
|
|
|
|
/* 新增包裹层的相对定位 */
|