瀏覽代碼

路口详情:控制方式按钮组重构 + 临时修改/修改方案弹窗
- CrossingDetailPanel.vue:删原 SegmentedRadio + temp 内联展开的日期/时长/周期编辑块;
改为 chip 按钮组——三组靠左排:手动控制(切红) | 关灯/黄闪/全红(紧急三色) |
临时修改/修改方案(配时动作蓝);非手动模式紧急/动作 chip 全 disabled 半透明;
inject dialogManager;新增 setControlMethod / openTempSchemeDialog /
openSchemeDialog / onTempSchemeSave / onSchemeSave;同一路口两个弹窗用
scheme-edit-temp- / scheme-edit-permanent- 不同 id 互斥(开新弹窗前 closeDialog
对面);移除未用的 SegmentedRadio import + 控制方式标题文字(按设计简化);保留
inline 当前阶段卡片与控制方案下拉

- SchemeStageEditDialog.vue:新增 content-only 组件,外壳走 SmartDialog
(DashboardLayout 注册 + dialogManager.openDialog 拉起);
inject dialogManager + dialogId prop 自关闭——绕过 Vue 2 组件事件不冒泡到
SmartDialog @close 监听的限制;showTimeRange=true 时多渲染一行 4 个
date/time picker + 时长/周期下拉;阶段表用 vuedraggable 拖拽行 + el-input-number
改锁定时长;周期总时长 inline 编辑应用按 ratio 缩放各阶段;固定周期勾选时
改单段把 delta 按当前比例从其它段扣减/补偿保 sum 不变;保存深拷贝回传,取消
纯丢弃,外部 payload 永不被污染

- DashboardLayout.vue:注册 SchemeStageEditDialog 内容组件

- main.js:补注册 element-ui 的 InputNumber/Checkbox/Button——之前只引入了
DatePicker/TimePicker/Select/Option,模板里 el-input-number/el-checkbox/el-button
全部被 Vue 当未知元素静默跳过,导致弹窗里"锁定时长"输入框、"固定周期"勾选、
"取消/保存"按钮三处都不渲染

- api/index.js + mock/api.js + mockAdapter.js:加 PUT /crossing/temp-scheme/:id
和 PUT /crossing/scheme/:id 两个保存接口;mock 实现含基本校验(阶段非空、
总时长 > 0),返回 cycleTotal/stages/appliedAt 等字段;CrossingDetailPanel
onSave 走 fire-and-forget(失败仅 console.warn 不阻塞 UI)

- 修复 dialogManager 方法名误用:DashboardLayout provide 暴露的对外名是
closeDialog(mixin 内部叫 handleDialogClose),最初代码三处都写成
handleDialogClose 调到 undefined → TypeError 让整条 openDialog 链路死掉
造成"按钮点了没反应",与 IntersectionMapVideos 既有用法对齐改回 closeDialog

画安 1 月之前
父節點
當前提交
a58814d0ee

+ 8 - 0
src/api/index.js

@@ -104,6 +104,14 @@ export const apiGetCrossingPanelData = (id) =>
 export const apiGetCrossingDetailData = (id, { iconMode } = {}) =>
   http.get(`/crossing/detail/${id}`, { params: iconMode ? { iconMode } : undefined })
 
+// 修改临时配时方案:含起止时间 / 时长 / 周期 + 阶段表
+export const apiSaveCrossingTempScheme = (id, payload) =>
+  http.put(`/crossing/temp-scheme/${id}`, payload)
+
+// 修改配时方案:仅阶段表 + 是否固定周期
+export const apiSaveCrossingScheme = (id, payload) =>
+  http.put(`/crossing/scheme/${id}`, payload)
+
 export const apiGetCrossingTopCharts = () =>
   http.get('/crossing/top-charts')
 

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

@@ -23,72 +23,46 @@
             <div class="detail-panel-right">
             <form class="detail-right-form" @submit.prevent>
                 <div class="form-group">
+                    <!-- 新版控制方式按钮组:手动开关 / 紧急模式 / 配时动作 -->
                     <div class="control-method">
-                        <div class="control-label-wrap">
-                            <span class="control-label">控制方式</span>
-                            <div class="control-operation">
-                                <div class="operation-btn" :class="{ 'is-active': isManualMode }"
-                                    @click="toggleManualMode">
-                                    {{ isManualMode ? '退出手动控制' : '手动控制' }}
-                                </div>
+                        <div class="control-label-wrap control-chips-row">
+                            <div class="control-chips">
+                                <!-- 手动开关 -->
+                                <button type="button" class="chip chip-manual"
+                                    :class="{ 'is-active': isManualMode }" @click="toggleManualMode">
+                                    {{ isManualMode ? '解除手动' : '手动控制' }}
+                                </button>
+                                <span class="chip-divider"></span>
+
+                                <!-- 紧急模式:关灯 / 黄闪 / 全红 -->
+                                <button type="button" class="chip chip-mode-off"
+                                    :class="{ 'is-active': currentMethod === 'lights_off' }"
+                                    :disabled="!isManualMode" @click="setControlMethod('lights_off')">关灯</button>
+                                <button type="button" class="chip chip-mode-yellow"
+                                    :class="{ 'is-active': currentMethod === 'yellow_flash' }"
+                                    :disabled="!isManualMode" @click="setControlMethod('yellow_flash')">黄闪</button>
+                                <button type="button" class="chip chip-mode-red"
+                                    :class="{ 'is-active': currentMethod === 'all_red' }"
+                                    :disabled="!isManualMode" @click="setControlMethod('all_red')">全红</button>
+                                <span class="chip-divider"></span>
+
+<!-- 配时动作:弹窗 -->
+                                <button type="button" class="chip chip-action"
+                                    :disabled="!isManualMode" @click="openTempSchemeDialog">临时修改</button>
+                                <button type="button" class="chip chip-action"
+                                    :disabled="!isManualMode" @click="openSchemeDialog">修改方案</button>
                             </div>
                         </div>
                     </div>
 
                     <div class="form-interactive-area">
                         <div class="form-editable-area" :class="{ 'is-disabled': !isManualMode }">
-                            <div class="control-method-content">
-                                <SegmentedRadio v-model="currentMethod" :options="controlMethodOptions" size="auto" />
-                            </div>
-
                             <div class="control-scheme">
                                 <div class="control-label-wrap">
                                     <span class="control-label">控制方案</span>
                                     <DropdownSelect v-model="currentScheme" :options="schemeOptions" size="auto" :class="{'DropdownSelect-is-disabled': !isManualMode }"/>
                                 </div>
 
-                                <div class="time-form-bar" v-if="currentMethod === 'temp'">
-
-                                    <el-date-picker v-model="startDate" type="date" placeholder="选择日期"
-                                        value-format="yyyy-MM-dd" size="small" :append-to-body="true"
-                                        :popper-options="{ boundariesPadding: 0, gpuAcceleration: false }">
-                                    </el-date-picker>
-
-                                    <el-time-picker v-model="startTime" placeholder="选择时间"
-                                        value-format="HH:mm:ss" size="small" :append-to-body="true"
-                                        :popper-options="{ boundariesPadding: 0, gpuAcceleration: false }">
-                                    </el-time-picker>
-
-                                    <el-date-picker v-model="endDate" type="date" placeholder="选择日期"
-                                        value-format="yyyy-MM-dd" size="small" :append-to-body="true"
-                                        :popper-options="{ boundariesPadding: 0, gpuAcceleration: false }">
-                                    </el-date-picker>
-
-                                    <el-time-picker v-model="endTime" placeholder="选择时间"
-                                        value-format="HH:mm:ss" size="small" :append-to-body="true"
-                                        :popper-options="{ boundariesPadding: 0, gpuAcceleration: false }">
-                                    </el-time-picker>
-
-                                    <div class="form-item-labeled">
-                                        <span class="form-label">时长</span>
-                                        <el-select v-model="duration" placeholder="请选择时长" size="small"
-                                            :popper-append-to-body="true">
-                                            <el-option v-for="d in durationOptions" :key="d" :label="d"
-                                                :value="d"></el-option>
-                                        </el-select>
-                                    </div>
-
-                                    <div class="form-item-labeled">
-                                        <span class="form-label">周期</span>
-                                        <el-select v-model="period" placeholder="请选择周期" size="small"
-                                            :popper-append-to-body="true">
-                                            <el-option v-for="p in 8" :key="p" :label="'周期' + p"
-                                                :value="p"></el-option>
-                                        </el-select>
-                                    </div>
-
-                                </div>
-
                                 <div class="current-stage">
                                     <div class="current-stage-warp">
                                         <div class="current-stage-label">当前阶段:</div>
@@ -215,18 +189,16 @@
 <script>
 import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
 import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
-import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
 import DropdownSelect from '@/components/ui/DropdownSelect.vue';
 import PlanDonutChart from '@/components/ui/PlanDonutChart.vue';
 
-import { apiGetCrossingDetailData } from '@/api';
+import { apiGetCrossingDetailData, apiSaveCrossingTempScheme, apiSaveCrossingScheme } from '@/api';
 
 export default {
     name: 'CrossingPanel',
     components: {
         SignalTimingChart,
         IntersectionMapVideos,
-        SegmentedRadio,
         DropdownSelect,
         PlanDonutChart
     },
@@ -234,6 +206,10 @@ export default {
         preloadedData: { type: Object, default: null },
         iconMode: { type: String, default: 'simple' }  // 'default' | 'simple'
     },
+    // dialogManager 由 DashboardLayout 顶层 provide;用于"临时修改 / 修改方案"弹窗的打开与关闭
+    inject: {
+        dialogManager: { default: null },
+    },
     data() {
         return {
             startDate: '2026-04-13',
@@ -533,6 +509,120 @@ export default {
                 this.showLockTime = false;
             }
         },
+        // 控制方式 chip 按钮点击:切 currentMethod 触发现有 watch(line ~272)逻辑
+        setControlMethod(value) {
+            if (!this.isManualMode) return;
+            this.currentMethod = value;
+        },
+
+        // ====== 配时方案编辑弹窗 ======
+
+        // 当前路口在弹窗里用的稳定 ID(不同路口独立弹窗,同一路口同一类型互斥)
+        _crossingId() {
+            return this.currentRoute?.id || this.$attrs.id || this.id || 'unknown';
+        },
+        _tempDialogId() { return `scheme-edit-temp-${this._crossingId()}`; },
+        _schemeDialogId() { return `scheme-edit-permanent-${this._crossingId()}`; },
+
+        // "临时修改"按钮:打开带时间区段的弹窗(同时关掉"修改方案"避免互相覆盖)
+        openTempSchemeDialog() {
+            if (!this.isManualMode || !this.dialogManager) return;
+            // 互斥:先关掉对面弹窗(DashboardLayout provide 的方法名是 closeDialog,不是 handleDialogClose)
+            this.dialogManager.closeDialog(this._schemeDialogId());
+
+            const id = this._tempDialogId();
+            this.dialogManager.openDialog({
+                id,
+                title: '修改临时配时方案',
+                component: 'SchemeStageEditDialog',
+                width: 720,
+                height: 480,
+                center: true,
+                draggable: true,
+                resizable: true,
+                noPadding: true,         // 内容组件自管 padding
+                showClose: true,
+                data: {
+                    dialogId: id,
+                    showTimeRange: true,
+                    payload: {
+                        stages: JSON.parse(JSON.stringify(this.currentStageList || [])),
+                        timeRange: {
+                            startDate: this.startDate, startTime: this.startTime,
+                            endDate: this.endDate, endTime: this.endTime,
+                            duration: this.duration, period: this.period,
+                        },
+                        isFixedCycle: false,
+                    },
+                    onSave: (updated) => this.onTempSchemeSave(updated),
+                    onCancel: null,
+                },
+            });
+        },
+
+        // "修改方案"按钮:打开不带时间区段的弹窗
+        openSchemeDialog() {
+            if (!this.isManualMode || !this.dialogManager) return;
+            // 互斥:先关掉对面弹窗
+            this.dialogManager.closeDialog(this._tempDialogId());
+
+            const id = this._schemeDialogId();
+            this.dialogManager.openDialog({
+                id,
+                title: '修改配时方案',
+                component: 'SchemeStageEditDialog',
+                width: 660,
+                height: 420,
+                center: true,
+                draggable: true,
+                resizable: true,
+                noPadding: true,
+                showClose: true,
+                data: {
+                    dialogId: id,
+                    showTimeRange: false,
+                    payload: {
+                        stages: JSON.parse(JSON.stringify(this.currentStageList || [])),
+                        isFixedCycle: false,
+                    },
+                    onSave: (updated) => this.onSchemeSave(updated),
+                    onCancel: null,
+                },
+            });
+        },
+
+        async onTempSchemeSave(payload) {
+            const id = this._crossingId();
+            // 调后端保存(mock 当前直接 200,真实后端可能返回 schemeId / appliedAt 等)
+            try {
+                await apiSaveCrossingTempScheme(id, payload);
+            } catch (e) {
+                console.warn('[CrossingDetailPanel] saveTempScheme failed:', e);
+            }
+            // 本地写回(不阻塞 UI)
+            if (Array.isArray(payload.stages)) this.currentStageList = payload.stages;
+            const tr = payload.timeRange || {};
+            if (tr.startDate !== undefined) this.startDate = tr.startDate;
+            if (tr.startTime !== undefined) this.startTime = tr.startTime;
+            if (tr.endDate !== undefined) this.endDate = tr.endDate;
+            if (tr.endTime !== undefined) this.endTime = tr.endTime;
+            if (tr.duration !== undefined) this.duration = tr.duration;
+            if (tr.period !== undefined) this.period = tr.period;
+            this.buildDonutFromPhaseData();
+            this.$emit('confirm', { method: 'temp', scheme: this.currentScheme, stages: this.currentStageList });
+        },
+
+        async onSchemeSave(payload) {
+            const id = this._crossingId();
+            try {
+                await apiSaveCrossingScheme(id, { ...payload, schemeId: this.currentScheme });
+            } catch (e) {
+                console.warn('[CrossingDetailPanel] saveScheme failed:', e);
+            }
+            if (Array.isArray(payload.stages)) this.currentStageList = payload.stages;
+            this.buildDonutFromPhaseData();
+            this.$emit('confirm', { method: this.currentMethod, scheme: this.currentScheme, stages: this.currentStageList });
+        },
 
         // 模拟:根据控制方式改变下拉方案的数据
         updateSchemeDataByMethod(method) {
@@ -868,12 +958,65 @@ export default {
 }
 
 .control-method .control-label-wrap {
-    justify-content: space-between;
+    justify-content: flex-start;
+}
+.control-chips-row {
+    flex-wrap: wrap;
+    gap: clamp(4px, calc(var(--s) * 8px), 10px);
 }
 
-/* 控制方式按钮组:设置 font-size 供 SegmentedRadio size="auto" 继承 */
-.control-method-content {
-    font-size: clamp(10px, calc(var(--s) * 15px), 15px);
+/* ===== 新版控制方式 chip 按钮组 ===== */
+.control-chips {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: clamp(4px, calc(var(--s) * 6px), 8px);
+}
+.chip {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    border: none;
+    outline: none;
+    cursor: pointer;
+    user-select: none;
+    color: #ffffff;
+    font-size: clamp(10px, calc(var(--s) * 14px), 14px);
+    line-height: 1;
+    padding: clamp(4px, calc(var(--s) * 6px), 8px) clamp(8px, calc(var(--s) * 14px), 16px);
+    border-radius: 4px;
+    background: rgba(255, 255, 255, 0.06);
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+    transition: filter 0.15s, opacity 0.15s, transform 0.05s, box-shadow 0.15s;
+    white-space: nowrap;
+}
+.chip:hover:not(:disabled) {
+    filter: brightness(1.15);
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.32);
+}
+.chip:active:not(:disabled) {
+    transform: translateY(1px);
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+}
+.chip:focus-visible:not(:disabled) {
+    box-shadow: 0 0 0 2px #ffffff, 0 1px 2px rgba(0, 0, 0, 0.2);
+}
+.chip:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
+.chip.is-active {
+    box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.55) inset, 0 1px 2px rgba(0, 0, 0, 0.2);
+}
+/* 颜色 token(参考图) */
+.chip-manual { background: #ff6b6b; }                  /* 解除手动 / 手动控制 */
+.chip-mode-off { background: #4dd4ac; }                /* 关灯 */
+.chip-mode-yellow { background: #fbbf24; color: #1f2937; } /* 黄闪:背景亮,文字反深色 */
+.chip-mode-red { background: #ef4444; }                /* 全红 */
+.chip-action { background: #3b82f6; }                  /* 临时修改 / 修改方案 */
+.chip-action:hover:not(:disabled) { background: #2563eb; }
+.chip-divider {
+    width: 1px;
+    height: clamp(14px, calc(var(--s) * 20px), 22px);
+    background: rgba(127, 182, 255, 0.25);
+    margin: 0 clamp(2px, calc(var(--s) * 4px), 6px);
 }
 
 .control-scheme {

+ 377 - 0
src/components/ui/SchemeStageEditDialog.vue

@@ -0,0 +1,377 @@
+<template>
+  <div class="scheme-stage-edit">
+    <!-- ① 时间区段(仅"修改临时配时方案"显示) -->
+    <div v-if="showTimeRange" class="time-range-bar">
+      <el-date-picker class="ssed-field"
+        v-model="form.timeRange.startDate" type="date" size="small" placeholder="选择日期"
+        value-format="yyyy-MM-dd" :clearable="false" :append-to-body="true" />
+      <el-time-picker class="ssed-field"
+        v-model="form.timeRange.startTime" size="small" placeholder="选择时间"
+        value-format="HH:mm:ss" :clearable="false" :append-to-body="true" />
+      <el-date-picker class="ssed-field"
+        v-model="form.timeRange.endDate" type="date" size="small" placeholder="选择日期"
+        value-format="yyyy-MM-dd" :clearable="false" :append-to-body="true" />
+      <el-time-picker class="ssed-field"
+        v-model="form.timeRange.endTime" size="small" placeholder="选择时间"
+        value-format="HH:mm:ss" :clearable="false" :append-to-body="true" />
+      <el-select class="ssed-field"
+        v-model="form.timeRange.duration" size="small" placeholder="请选择时长"
+        :popper-append-to-body="true">
+        <el-option v-for="d in durationOptions" :key="d" :label="d + '分钟'" :value="d" />
+      </el-select>
+      <el-select class="ssed-field"
+        v-model="form.timeRange.period" size="small" placeholder="请选择周期"
+        :popper-append-to-body="true">
+        <el-option v-for="p in 8" :key="p" :label="'周期' + p" :value="p" />
+      </el-select>
+    </div>
+
+    <!-- ② 周期信息 -->
+    <div class="cycle-bar">
+      <el-checkbox v-model="form.isFixedCycle" class="ssed-checkbox">固定周期</el-checkbox>
+      <span class="cycle-total-wrap">
+        <span class="cycle-total-label">周期总时长(s):</span>
+        <template v-if="!cycleEditing">
+          <strong class="cycle-total-value">{{ cycleTotal }}</strong>
+          <a class="cycle-edit-link" @click="startCycleEdit">修改</a>
+        </template>
+        <template v-else>
+          <el-input-number v-model="cycleEditValue" size="mini"
+            :min="cycleEditMin" :max="cycleEditMax" :step="1" controls-position="right" />
+          <el-button size="mini" type="primary" plain @click="applyCycleEdit">应用</el-button>
+          <el-button size="mini" plain @click="cancelCycleEdit">取消</el-button>
+        </template>
+      </span>
+    </div>
+
+    <!-- ③ 阶段表(vuedraggable 排序 + el-input-number 编辑锁定时长) -->
+    <div class="stage-table-wrap">
+      <table class="stage-table">
+        <thead>
+          <tr>
+            <th class="col-idx">阶段号</th>
+            <th class="col-name">阶段名称</th>
+            <th class="col-ratio">绿信比</th>
+            <th class="col-time">锁定时长</th>
+            <th class="col-handle">移动</th>
+          </tr>
+        </thead>
+        <draggable v-model="form.stages" tag="tbody" handle=".drag-handle" :animation="200">
+          <tr v-for="(row, idx) in stagesWithRatio" :key="row.value">
+            <td class="col-idx">{{ idx + 1 }}</td>
+            <td class="col-name">{{ row.phaseName || `P${idx + 1}` }}</td>
+            <td class="col-ratio">{{ row.ratio }}</td>
+            <td class="col-time">
+              <el-input-number
+                v-model="form.stages[idx].time"
+                size="mini" :min="1" :max="180" :step="1"
+                controls-position="right"
+                @change="(cur, old) => onStageTimeChange(idx, cur, old)" />
+            </td>
+            <td class="col-handle"><i class="drag-handle">☰</i></td>
+          </tr>
+        </draggable>
+      </table>
+    </div>
+
+    <!-- ④ 底部按钮 -->
+    <div class="dialog-footer">
+      <el-button size="small" plain @click="handleCancel">取消</el-button>
+      <el-button size="small" type="primary" @click="handleSave">保存</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import draggable from 'vuedraggable';
+
+export default {
+  name: 'SchemeStageEditDialog',
+  components: { draggable },
+  inject: {
+    // DashboardLayout 顶层 provide,组件内通过它做自关闭
+    dialogManager: { default: null },
+  },
+  props: {
+    // 自关闭用:与 dialogManager.openDialog 的 id 同值(通过 data 透传过来)
+    dialogId: { type: String, required: true },
+    // 是否展示顶部时间区段(true=修改临时配时方案 / false=修改配时方案)
+    showTimeRange: { type: Boolean, default: false },
+    // 表单数据
+    payload: {
+      type: Object,
+      required: true,
+      // { stages: [{ value, phaseName, time, img, direction, locktimeOptions }],
+      //   timeRange: { startDate, startTime, endDate, endTime, duration, period },
+      //   isFixedCycle: Boolean }
+    },
+    // 保存/取消回调(外部通过 data 传入)
+    onSave: { type: Function, default: null },
+    onCancel: { type: Function, default: null },
+  },
+  data() {
+    return {
+      form: null,
+      cycleEditing: false,
+      cycleEditValue: 0,
+    };
+  },
+  computed: {
+    cycleTotal() {
+      if (!this.form || !this.form.stages) return 0;
+      return this.form.stages.reduce((a, b) => a + (Number(b.time) || 0), 0);
+    },
+    stagesWithRatio() {
+      if (!this.form || !this.form.stages) return [];
+      const total = this.cycleTotal;
+      return this.form.stages.map((s, i) => ({
+        ...s,
+        idx: i + 1,
+        ratio: total > 0 ? Math.round((s.time / total) * 100) + '%' : '0%',
+      }));
+    },
+    durationOptions() {
+      const list = [];
+      for (let i = 30; i <= 300; i += 30) list.push(i);
+      return list;
+    },
+    cycleEditMin() {
+      // 最小 = 阶段数(每段保留至少 1s)
+      return Math.max(1, (this.form && this.form.stages ? this.form.stages.length : 1));
+    },
+    cycleEditMax() { return 999; },
+  },
+  created() {
+    // 深拷贝 payload,避免污染外部数据
+    this.form = JSON.parse(JSON.stringify(this.payload || {}));
+    if (!this.form.stages) this.form.stages = [];
+    if (this.showTimeRange && !this.form.timeRange) this.form.timeRange = {};
+    if (this.form.isFixedCycle === undefined) this.form.isFixedCycle = false;
+  },
+  methods: {
+    // 自关闭:调 dialogManager.closeDialog 把自己从 activeDialogs 移除
+    // (DashboardLayout provide 的方法名是 closeDialog,对应 mixin 内部的 handleDialogClose)
+    doClose() {
+      if (this.dialogManager && typeof this.dialogManager.closeDialog === 'function') {
+        this.dialogManager.closeDialog(this.dialogId);
+      }
+    },
+    handleSave() {
+      // 锁定时长合法性兜底:保 sum > 0
+      const total = this.cycleTotal;
+      if (total <= 0) return;
+      if (typeof this.onSave === 'function') {
+        // 把 form 深拷贝再回传,保险隔离
+        this.onSave(JSON.parse(JSON.stringify(this.form)));
+      }
+      this.doClose();
+    },
+    handleCancel() {
+      if (typeof this.onCancel === 'function') this.onCancel();
+      this.doClose();
+    },
+
+    startCycleEdit() {
+      this.cycleEditValue = this.cycleTotal;
+      this.cycleEditing = true;
+    },
+    cancelCycleEdit() {
+      this.cycleEditing = false;
+    },
+    applyCycleEdit() {
+      const newTotal = Number(this.cycleEditValue) || 0;
+      const oldTotal = this.cycleTotal;
+      if (newTotal <= 0 || oldTotal <= 0 || newTotal === oldTotal) {
+        this.cycleEditing = false;
+        return;
+      }
+      const ratio = newTotal / oldTotal;
+      // 各阶段按比例缩放
+      this.form.stages.forEach((s, i) => {
+        this.$set(this.form.stages, i, { ...s, time: Math.max(1, Math.round(s.time * ratio)) });
+      });
+      // 处理舍入误差:把差值补到第一段
+      const sumNow = this.form.stages.reduce((a, b) => a + b.time, 0);
+      const diff = newTotal - sumNow;
+      if (diff !== 0 && this.form.stages.length > 0) {
+        this.$set(this.form.stages, 0, {
+          ...this.form.stages[0],
+          time: Math.max(1, this.form.stages[0].time + diff),
+        });
+      }
+      this.cycleEditing = false;
+    },
+
+    /** 固定周期模式下编辑某段:把 delta 从其它段按当前时长比例扣减/补偿,保 sum 不变 */
+    onStageTimeChange(idx, current, old) {
+      if (!this.form.isFixedCycle) return;
+      const cur = Number(current) || 0;
+      const prev = Number(old) || 0;
+      const delta = cur - prev;
+      if (delta === 0) return;
+
+      const others = this.form.stages
+        .map((s, i) => ({ s, i }))
+        .filter(o => o.i !== idx);
+      const otherSum = others.reduce((a, b) => a + b.s.time, 0);
+      if (otherSum <= 0) return;
+
+      let remaining = -delta;
+      others.forEach((o, k) => {
+        let share;
+        if (k === others.length - 1) {
+          share = remaining;            // 最后一个吸收剩余以严格对账
+        } else {
+          share = Math.round(-delta * o.s.time / otherSum);
+          remaining -= share;
+        }
+        const newTime = Math.max(1, o.s.time + share);
+        this.$set(this.form.stages, o.i, { ...o.s, time: newTime });
+      });
+    },
+  },
+};
+</script>
+
+<style scoped>
+.scheme-stage-edit {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  padding: clamp(8px, 1.5cqw, 16px);
+  background: rgba(5, 22, 45, 0.92);
+  color: #e0e6f1;
+  box-sizing: border-box;
+  overflow: hidden;
+  container-type: inline-size;
+}
+
+/* ===== ① 时间区段 ===== */
+.time-range-bar {
+  display: flex;
+  flex-wrap: wrap;
+  gap: clamp(4px, 1cqw, 10px);
+  padding-bottom: clamp(6px, 1cqw, 12px);
+  border-bottom: 1px dashed rgba(127, 182, 255, 0.18);
+  flex-shrink: 0;
+}
+.ssed-field {
+  flex: 1 1 130px;
+  min-width: 110px;
+}
+
+/* ===== ② 周期信息 ===== */
+.cycle-bar {
+  display: flex;
+  align-items: center;
+  gap: clamp(8px, 2cqw, 20px);
+  padding: clamp(6px, 1cqw, 12px) 0;
+  flex-shrink: 0;
+  font-size: clamp(11px, 2.2cqw, 13px);
+}
+.ssed-checkbox >>> .el-checkbox__label {
+  color: #d1d5db;
+  font-size: inherit;
+}
+.cycle-total-wrap {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+.cycle-total-label {
+  color: #9ca3af;
+}
+.cycle-total-value {
+  color: #f8fafc;
+  font-weight: 600;
+}
+.cycle-edit-link {
+  color: #4da8ff;
+  cursor: pointer;
+  margin-left: 2px;
+}
+.cycle-edit-link:hover {
+  text-decoration: underline;
+}
+
+/* ===== ③ 阶段表 ===== */
+.stage-table-wrap {
+  flex: 1 1 0;
+  min-height: 0;
+  overflow: auto;
+  margin-top: clamp(4px, 1cqw, 8px);
+}
+.stage-table-wrap::-webkit-scrollbar { width: 6px; height: 6px; }
+.stage-table-wrap::-webkit-scrollbar-thumb { background: rgba(30, 77, 142, 0.6); border-radius: 3px; }
+.stage-table-wrap::-webkit-scrollbar-track { background: transparent; }
+
+.stage-table {
+  width: 100%;
+  border-collapse: separate;
+  border-spacing: 0;
+  font-size: clamp(11px, 2.2cqw, 13px);
+}
+.stage-table thead tr {
+  background: linear-gradient(180deg, rgba(58, 127, 209, 0.22) 0%, rgba(58, 127, 209, 0.08) 100%);
+}
+.stage-table th {
+  padding: 6px 8px;
+  text-align: center;
+  color: rgba(127, 182, 255, 0.95);
+  font-weight: 600;
+  border-bottom: 1px solid rgba(127, 182, 255, 0.35);
+  white-space: nowrap;
+}
+.stage-table td {
+  padding: 6px 8px;
+  text-align: center;
+  color: #e0e6f1;
+  border-bottom: 1px dashed rgba(30, 77, 142, 0.45);
+  vertical-align: middle;
+}
+.stage-table tbody tr:hover {
+  background: rgba(58, 127, 209, 0.10);
+}
+
+.col-idx { width: 12%; }
+.col-name { width: 32%; text-align: left; padding-left: 14px; }
+.col-ratio { width: 16%; }
+.col-time { width: 28%; }
+.col-handle { width: 12%; }
+
+.drag-handle {
+  display: inline-block;
+  cursor: grab;
+  color: rgba(127, 182, 255, 0.7);
+  font-style: normal;
+  font-size: 16px;
+  user-select: none;
+  padding: 0 4px;
+}
+.drag-handle:active { cursor: grabbing; color: #4da8ff; }
+
+/* ===== ④ 底部按钮 ===== */
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  padding-top: clamp(6px, 1cqw, 12px);
+  border-top: 1px solid rgba(127, 182, 255, 0.15);
+  flex-shrink: 0;
+}
+
+/* ===== element 控件深色适配(局部覆盖默认浅色主题) ===== */
+.scheme-stage-edit >>> .el-input__inner,
+.scheme-stage-edit >>> .el-input-number .el-input__inner {
+  background: rgba(15, 32, 55, 0.6);
+  border-color: rgba(127, 182, 255, 0.30);
+  color: #e0e6f1;
+}
+.scheme-stage-edit >>> .el-input__inner::placeholder {
+  color: rgba(255, 255, 255, 0.35);
+}
+.scheme-stage-edit >>> .el-input__icon {
+  color: rgba(127, 182, 255, 0.7);
+}
+</style>

+ 3 - 1
src/layouts/DashboardLayout.vue

@@ -113,6 +113,7 @@ import CrossingDetailHeader from '@/components/ui/CrossingDetailHeader.vue';
 import OfflineTip from '@/components/ui/OfflineTip.vue';
 import DetectorTable from '@/components/ui/DetectorTable.vue';
 import CameraVideoDialog from '@/components/ui/CameraVideoDialog.vue';
+import SchemeStageEditDialog from '@/components/ui/SchemeStageEditDialog.vue';
 import brand from '@/utils/brand';
 
 export default {
@@ -145,7 +146,8 @@ export default {
         CrossingDetailHeader,
         OfflineTip,
         DetectorTable,
-        CameraVideoDialog
+        CameraVideoDialog,
+        SchemeStageEditDialog
     },
     provide() {
         return {

+ 4 - 1
src/main.js

@@ -3,7 +3,7 @@ import App from "./App.vue";
 import router from "./router";
 import "./styles/base.css";
 import '@/utils/rem.js';
-import { DatePicker, TimePicker, Select, Option } from "element-ui";
+import { DatePicker, TimePicker, Select, Option, InputNumber, Checkbox, Button } from "element-ui";
 import PopupManager from "element-ui/lib/utils/popup/popup-manager";
 
 PopupManager.zIndex = 3000;
@@ -12,6 +12,9 @@ Vue.use(DatePicker);
 Vue.use(TimePicker);
 Vue.use(Select);
 Vue.use(Option);
+Vue.use(InputNumber);
+Vue.use(Checkbox);
+Vue.use(Button);
 
 // Mock 拦截器:由 VUE_APP_USE_MOCK 控制(默认开启;切真实后端时设为 false)
 if (process.env.VUE_APP_USE_MOCK !== 'false') {

+ 44 - 0
src/mock/api.js

@@ -1275,6 +1275,50 @@ export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}
   })
 }
 
+/**
+ * PUT /api/crossing/temp-scheme/:id — 修改临时配时方案
+ * 入参:{ stages, timeRange, isFixedCycle }
+ * 当前 mock 仅做参数校验和成功返回;真实后端要把 timeRange 转成排程任务、stages 落库
+ */
+export async function apiSaveCrossingTempScheme(id, payload = {}) {
+  await delay(200)
+  if (!id) return fail('缺少路口 ID')
+  const stages = Array.isArray(payload.stages) ? payload.stages : []
+  if (stages.length === 0) return fail('阶段列表不能为空')
+  const total = stages.reduce((a, b) => a + (Number(b.time) || 0), 0)
+  if (total <= 0) return fail('阶段总时长必须 > 0')
+  return ok({
+    id,
+    method: 'temp',
+    cycleTotal: total,
+    stages: stages.map((s, i) => ({ ...s, idx: i + 1 })),
+    timeRange: payload.timeRange || null,
+    isFixedCycle: !!payload.isFixedCycle,
+    appliedAt: new Date().toISOString(),
+  })
+}
+
+/**
+ * PUT /api/crossing/scheme/:id — 修改配时方案(永久)
+ * 入参:{ stages, isFixedCycle };schemeId 由前端 currentScheme 决定,约定走 query 或 body 字段
+ */
+export async function apiSaveCrossingScheme(id, payload = {}) {
+  await delay(200)
+  if (!id) return fail('缺少路口 ID')
+  const stages = Array.isArray(payload.stages) ? payload.stages : []
+  if (stages.length === 0) return fail('阶段列表不能为空')
+  const total = stages.reduce((a, b) => a + (Number(b.time) || 0), 0)
+  if (total <= 0) return fail('阶段总时长必须 > 0')
+  return ok({
+    id,
+    method: 'scheme',
+    cycleTotal: total,
+    stages: stages.map((s, i) => ({ ...s, idx: i + 1 })),
+    isFixedCycle: !!payload.isFixedCycle,
+    appliedAt: new Date().toISOString(),
+  })
+}
+
 /** 多路口(非 NESW key)按实际 armsConfig 生成 per-arm 检测器数据 */
 function _detectorPerArmSnapshot(id, armsConfig, bucketIdx) {
   const seed = _idSeed(id || '')

+ 14 - 0
src/mock/mockAdapter.js

@@ -196,6 +196,20 @@ mock.onGet(/\/crossing\/detail\/(.+)/).reply(async (config) => {
   return [200, res]
 })
 
+mock.onPut(/\/crossing\/temp-scheme\/(.+)/).reply(async (config) => {
+  const id = config.url.match(/\/crossing\/temp-scheme\/(.+)/)[1]
+  const payload = config.data ? JSON.parse(config.data) : {}
+  const res = await mockApi.apiSaveCrossingTempScheme(id, payload)
+  return [200, res]
+})
+
+mock.onPut(/\/crossing\/scheme\/(.+)/).reply(async (config) => {
+  const id = config.url.match(/\/crossing\/scheme\/(.+)/)[1]
+  const payload = config.data ? JSON.parse(config.data) : {}
+  const res = await mockApi.apiSaveCrossingScheme(id, payload)
+  return [200, res]
+})
+
 mock.onGet('/crossing/top-charts').reply(async () => {
   const res = await mockApi.apiGetCrossingTopCharts()
   return [200, res]