From f522c9ffa487e3710d691e8b98df8c4f0cfafd43 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 05 九月 2026 20:07:26 +0800
Subject: [PATCH] 支持模拟器逐通道参数与工步方案配置

---
 battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java      |   53 +++++
 ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java                      |   39 ++++
 ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java     |   12 +
 ruoyi-admin/src/main/resources/static/dev/bts-simulator.html                                         |  203 ++++++++++++++++++++--
 battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java  |   31 +++
 battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java     |   72 +++++--
 ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java              |    2 
 ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java |   56 ++++++
 ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java            |   29 +++
 9 files changed, 451 insertions(+), 46 deletions(-)

diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
index f20ee73..d0ba2fb 100644
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
+++ b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
@@ -23,6 +23,7 @@
 import com.battery.device.api.TestDataObservationAdapter;
 import com.battery.device.simulator.BtsSimulatorStateStore;
 import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
+import com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration;
 import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
 import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
 import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
@@ -218,10 +219,26 @@
         if (channelId < 1 || channelId > device.channels().size())
             throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size());
         List<ChannelState> channels = new ArrayList<>(device.channels());
-        channels.set(channelId - 1, new ChannelState(channelId, online, workStatus));
+        channels.set(channelId - 1, new ChannelState(channelId, online, workStatus,
+                channels.get(channelId - 1).configuration()));
         persist(new SimulatorState(state.version(),
                 replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), state.collectors(),
                 state.nextDeviceId(), state.nextCollectorId(), state.tombstones()));
+    }
+
+    /** 保存单通道的当前测量和工步方案,不改写在线或工作状态,不产生观测。 */
+    public synchronized void configureChannel(int deviceId, int channelId, ChannelConfiguration configuration)
+    {
+        if (configuration == null) throw new IllegalArgumentException("通道配置不能为空");
+        DeviceState device = requireDevice(deviceId);
+        if (channelId < 1 || channelId > device.channels().size())
+            throw new IllegalArgumentException("通道不存在");
+        List<ChannelState> channels = new ArrayList<>(device.channels());
+        ChannelState channel = channels.get(channelId - 1);
+        channels.set(channelId - 1, new ChannelState(channelId, channel.online(), channel.workStatus(), configuration));
+        persist(new SimulatorState(state.version(),
+                replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())),
+                state.collectors(), state.nextDeviceId(), state.nextCollectorId(), state.tombstones()));
     }
 
     /** 新增一台与 BTS 检测柜拓扑隔离的开发采集器。 */
@@ -297,14 +314,29 @@
         replaceCollector(DevelopmentCollectorSimulation.configureStatus(requireCollector(collectorId), channelCode, status));
     }
 
-    /** 返回固定的开发计划,不推进工步或生成历史。 */
+    /** 返回通道已配置的开发方案及当前工步位置,不自动推进或生成历史。 */
     @Override
     public synchronized TestPlan testPlan(ChannelIdentity channel)
     {
         if (!canObserve(channel)) return null;
-        return new TestPlan(1, 1, 1, List.of(new PlanRow("STEP", 1, 1, "恒流充电", 60d, 3.7d,
-                requireDevice(channel.devid()).channels().get(channel.chlid() - 1).workStatus() == DeviceWorkStatus.FINISHED
-                        ? "已完成" : "待执行", null, null)));
+        ChannelState source = requireDevice(channel.devid()).channels().get(channel.chlid() - 1);
+        ChannelConfiguration configuration = source.configuration();
+        List<PlanRow> rows = new ArrayList<>();
+        for (int index = 0; index < configuration.steps().size(); index++) {
+            var step = configuration.steps().get(index);
+            String status = index + 1 < configuration.stepNo() ? "已完成" : "待执行";
+            if (index + 1 == configuration.stepNo()) status = switch (source.workStatus()) {
+                case WORKING -> "进行中";
+                case PAUSED -> "暂停";
+                case FINISHED -> "已完成";
+                case PROTECTED -> "异常";
+                default -> "待执行";
+            };
+            // cyclePosition 表示循环内的工步位置,不能使用当前循环号,否则控制台会合并不同工步。
+            rows.add(new PlanRow("STEP", index + 1, index + 1, step.name(), step.durationSeconds(),
+                    step.voltageV(), status, null, null));
+        }
+        return new TestPlan(configuration.cycleNo(), configuration.stepNo(), configuration.plannedCycleCount(), rows);
     }
 
     /** 请求当前检测柜测量;离线或无法观测时不返回缓存值。 */
@@ -324,14 +356,13 @@
         if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) return null;
         Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
         long sequence = nextObservationSequence();
-        double wave = Math.sin((sequence + channel.channelId()) * 0.7d);
-        BtsDeviceProfile profile = profileFor(device);
-        double current = channel.workStatus() == DeviceWorkStatus.WORKING
-                ? clamp(profile.currentMaxA().doubleValue() * (0.18d + wave * 0.02d),
-                        profile.currentMinA().doubleValue(), profile.currentMaxA().doubleValue()) : 0d;
-        double voltage = clamp(3.7d + wave * 0.01d, profile.voltageMinV().doubleValue(), profile.voltageMaxV().doubleValue());
-        CabinetRealtime result = new CabinetRealtime(sequence, now, current, voltage, 1.2d, 4.44d,
-                1, 1, "恒流充电", "cc", now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId());
+        ChannelConfiguration configuration = channel.configuration();
+        var step = configuration.steps().get(configuration.stepNo() - 1);
+        // 保存值直接成为当前观测;工作状态和设备量程不反向覆盖操作员配置。
+        CabinetRealtime result = new CabinetRealtime(sequence, now, configuration.currentA().doubleValue(),
+                configuration.voltageV().doubleValue(), configuration.capacityAh().doubleValue(), configuration.energyWh().doubleValue(),
+                configuration.cycleNo(), configuration.stepNo(), step.name(), step.stepType(),
+                now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId());
         return result;
     }
 
@@ -406,7 +437,7 @@
                 .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), device.profile(),
                         device.channels().stream()
                                 .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(),
-                                        channel.workStatus()))
+                                        channel.workStatus(), channel.configuration()))
                                 .toList()))
                 .toList();
         List<CollectorSnapshot> collectorSnapshots = state.collectors().stream()
@@ -581,7 +612,8 @@
                     .append("\" current=\"").append(decimal(current)).append("\" voltage=\"")
                     .append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity))
                     .append("\" energy=\"").append(decimal(energy)).append("\" totaltime=\"")
-                    .append(0).append("\" relativetime=\"").append(0)
+                    .append(channel.configuration().totalTimeSeconds()).append("\" relativetime=\"")
+                    .append(channel.configuration().relativeTimeSeconds())
                     .append("\" open_or_close=\"0\" log_code=\"0\">")
                     // 模拟器返回当前设备方案,供统一观测模型展示;不代表真实 BTS 的完整方案字段。
                     .append("<plan><step step_no=\"").append(stepNo).append("\" step_type=\"")
@@ -662,14 +694,9 @@
         throw new IllegalArgumentException("Unsupported Neware simulator model: " + model);
     }
 
-    private double clamp(double value, double minimum, double maximum)
-    {
-        return Math.max(minimum, Math.min(maximum, value));
-    }
-
     private String decimal(double value)
     {
-        return String.format(java.util.Locale.ROOT, "%.3f", value);
+        return java.math.BigDecimal.valueOf(value).toPlainString();
     }
 
     private String toBtsWorkStatus(DeviceWorkStatus status)
@@ -848,7 +875,8 @@
     public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile,
             List<ChannelSnapshot> channels) { }
 
-    public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { }
+    public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus,
+            ChannelConfiguration configuration) { }
 
     public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name,
             String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java
index f48e4c6..9e7e01d 100644
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java
+++ b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java
@@ -100,9 +100,58 @@
             long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt,
             String metricCode, BigDecimal value, String unit) { }
 
-    record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { }
+    record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus,
+            ChannelConfiguration configuration)
+    {
+        public ChannelState
+        {
+            configuration = configuration == null ? ChannelConfiguration.defaults() : configuration;
+        }
 
-    /** BTS 检测柜的可配置厂商 profile;参数用于发现展示和边界内的演示数据生成。 */
+        public ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus)
+        { this(channelId, online, workStatus, null); }
+    }
+
+    /** 通道当前观测配置与方案一起保存,避免当前工步引用不存在的方案行。 */
+    record ChannelConfiguration(BigDecimal currentA, BigDecimal voltageV, BigDecimal capacityAh,
+            BigDecimal energyWh, int cycleNo, int stepNo, int plannedCycleCount,
+            long totalTimeSeconds, long relativeTimeSeconds, List<SimulatorPlanStep> steps)
+    {
+        public ChannelConfiguration
+        {
+            for (BigDecimal value : new BigDecimal[] { currentA, voltageV, capacityAh, energyWh })
+                if (value == null || !Double.isFinite(value.doubleValue()))
+                    throw new IllegalArgumentException("通道测量值必须是有效数值");
+            if (steps == null || steps.isEmpty() || steps.stream().anyMatch(java.util.Objects::isNull))
+                throw new IllegalArgumentException("工步方案至少包含一个有效工步");
+            steps = List.copyOf(steps);
+            if (cycleNo < 1 || plannedCycleCount < cycleNo || stepNo < 1 || stepNo > steps.size()
+                    || totalTimeSeconds < 0 || relativeTimeSeconds < 0 || relativeTimeSeconds > totalTimeSeconds)
+                throw new IllegalArgumentException("循环、当前工步或时间配置无效");
+        }
+
+        /** 为新增通道和既有配置补齐固定观测值,不产生成功接收记录。 */
+        public static ChannelConfiguration defaults()
+        {
+            return new ChannelConfiguration(BigDecimal.ZERO, new BigDecimal("3.7"), new BigDecimal("1.2"),
+                    new BigDecimal("4.44"), 1, 1, 1, 0, 0,
+                    List.of(new SimulatorPlanStep("恒流充电", "cc", 60d, 3.7d)));
+        }
+    }
+
+    record SimulatorPlanStep(String name, String stepType, double durationSeconds, double voltageV)
+    {
+        public SimulatorPlanStep
+        {
+            if (name == null || name.isBlank() || stepType == null
+                    || !List.of("cc", "dc", "cv", "rest", "end", "cccv").contains(stepType)
+                    || !Double.isFinite(durationSeconds) || durationSeconds < 0 || !Double.isFinite(voltageV))
+                throw new IllegalArgumentException("工步名称、类型、时间或电压无效");
+            name = name.trim();
+        }
+    }
+
+    /** BTS 检测柜的可配置厂商 profile,用于设备发现元数据展示。 */
     record BtsDeviceProfile(int devtype, int subDeviceId, String name, String manufacturer, String model,
             String serialNumber, String firmwareVersion, int channelCount, BigDecimal currentMinA,
             BigDecimal currentMaxA, BigDecimal voltageMinV, BigDecimal voltageMaxV, int sampleIntervalMs)
diff --git a/battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java b/battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java
index 49d5be2..ed7846e 100644
--- a/battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java
+++ b/battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java
@@ -136,7 +136,7 @@
     }
 
     @Test
-    void developmentSimulatorSupportsNewareProfilesAndDynamicObservation() throws Exception
+    void developmentSimulatorSupportsNewareProfilesAndFixedObservation() throws Exception
     {
         try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
              BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
@@ -168,12 +168,39 @@
             var first = adapter.inquire(channel).values().iterator().next();
             var second = adapter.inquire(channel).values().iterator().next();
             assertEquals(DeviceWorkStatus.WORKING, first.workStatus());
-            assertNotEquals(first.currentA(), second.currentA());
+            assertEquals(first.currentA(), second.currentA());
             assertTrue(first.voltageV().doubleValue() >= 0.0 && first.voltageV().doubleValue() <= 5.0);
         }
     }
 
     @Test
+    void configuredChannelValuesReachBtsInquireWithoutClampingOrRounding() throws Exception
+    {
+        try (var simulator = new BtsDevelopmentSimulator(0);
+             var transport = new BtsTcpTransport("127.0.0.1", simulator.port(), Duration.ofSeconds(1), Duration.ofSeconds(1));
+             DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
+                     new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong")))) {
+            simulator.configureChannel(1, 1, new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration(
+                    new java.math.BigDecimal("-2.123456"), new java.math.BigDecimal("8.125"),
+                    new java.math.BigDecimal("7.2"), new java.math.BigDecimal("29.7"), 2, 2, 5, 120, 30,
+                    List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2),
+                            new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电 <验证>", "dc", 90, 3.0))));
+            var channel = adapter.discoverChannels().stream().filter(item -> item.identity().chlid() == 1)
+                    .findFirst().orElseThrow().identity();
+            var observation = adapter.inquire(Set.of(channel)).get(channel);
+            assertEquals(-2.123456, observation.currentA().doubleValue());
+            assertEquals(8.125, observation.voltageV().doubleValue());
+            assertEquals(7.2, observation.capacityAh().doubleValue());
+            assertEquals(29.7, observation.energyWh().doubleValue());
+            assertEquals(2, observation.cycleNo());
+            assertEquals(2, observation.stepNo());
+            assertEquals("dc", observation.stepType());
+            assertEquals(120L, observation.totalTimeSeconds());
+            assertEquals(30L, observation.relativeTimeSeconds());
+        }
+    }
+
+    @Test
     void developmentSimulatorPersistsNewareProfileMetadata() throws Exception
     {
         InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java
index 8eed74a..4001602 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java
@@ -186,7 +186,7 @@
         String workStatus = object.getString("workStatus");
         if (workStatus == null) throw new IllegalStateException("Missing BTS simulator state field: workStatus");
         return new ChannelState(requiredInteger(object, "channelId"), object.getBooleanValue("online"),
-                DeviceWorkStatus.valueOf(workStatus));
+                DeviceWorkStatus.valueOf(workStatus), object.getObject("configuration", ChannelConfiguration.class));
     }
 
     private ChannelTombstone channelTombstone(Object value)
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java b/ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java
index 56361db..8fd0f5a 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java
@@ -231,6 +231,18 @@
         }
     }
 
+    /** 保存单通道的当前观测参数和工步方案,不改变设备在线或工作状态。 */
+    @PutMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/configuration")
+    public StateSnapshot configureChannel(@PathVariable int deviceId, @PathVariable int channelId,
+            @RequestBody com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration configuration)
+    {
+        try {
+            simulator.configureChannel(deviceId, channelId, configuration);
+            return simulator.snapshot();
+        }
+        catch (IllegalArgumentException exception) { throw badRequest(exception); }
+    }
+
     /** 修改软件可访问性,设备配置继续保留。 */
     @PutMapping("/dev/bts-simulator/software")
     public StateSnapshot software(@RequestBody SoftwareCommand command)
diff --git a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
index b01db74..a0ae882 100644
--- a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
+++ b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -55,6 +55,19 @@
     .empty { height: 96px; color: #7d8992; text-align: center; }
     .error { display: none; margin-bottom: 16px; padding: 11px 14px; color: #8b2635; background: #fff0f2; border: 1px solid #e4a8b0; font-size: 13px; }
     .error.visible { display: block; }
+    dialog { width: min(940px, calc(100% - 24px)); max-height: calc(100% - 32px); padding: 0; border: 1px solid #bdc8d0; border-radius: 4px; color: #26323d; }
+    dialog::backdrop { background: rgb(0 0 0 / 35%); }
+    .editor-body { padding: 16px; }
+    .editor-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
+    .editor-scroll { overflow-x: auto; margin-top: 12px; }
+    .editor-scroll table { min-width: 700px; }
+    .editor-scroll td { padding: 6px; }
+    .editor-scroll th { padding: 8px 6px; white-space: nowrap; }
+    .editor-scroll select { width: 100%; }
+    .editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 16px; border-top: 1px solid #d5dde3; }
+    .icon-button { width: 30px; padding: 0; color: #52616c; background: #fff; border-color: #bdc8d0; }
+    .row-actions { display: flex; gap: 4px; }
+    .editor-error { margin: 0 0 12px; }
     @media (max-width: 760px) {
       header { align-items: flex-start; flex-direction: column; padding: 14px 16px; }
       main { width: calc(100% - 20px); margin-top: 12px; }
@@ -69,6 +82,7 @@
       th, td { padding: 8px; }
       .request-time { width: 130px; }
       .request-command { width: 90px; }
+      .editor-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
     }
   </style>
 </head>
@@ -127,10 +141,33 @@
       </table>
     </section>
   </main>
+  <dialog id="channel-editor" aria-labelledby="editor-title">
+    <form id="editor-form">
+      <div class="section-title"><h3 id="editor-title"></h3><button class="icon-button" type="button" id="editor-close" aria-label="关闭配置" title="关闭配置">×</button></div>
+      <div class="editor-body"><div id="editor-error" class="error editor-error" role="alert"></div><div id="editor-content"></div></div>
+      <div class="editor-actions"><button type="button" id="editor-cancel" class="danger">取消</button><button type="submit" id="editor-save">保存通道配置</button></div>
+    </form>
+  </dialog>
   <script>
     const api = '/dev/bts-simulator';
     const labels = { STOPPED: '空闲', WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', PROTECTED: '异常', UNOBSERVABLE: '无法观测' };
     const errorBox = document.querySelector('#error');
+    const editor = document.querySelector('#channel-editor');
+    const editorForm = document.querySelector('#editor-form');
+    const editorContent = document.querySelector('#editor-content');
+    const stepTypes = { cc: '恒流充电', dc: '恒流放电', cv: '恒压充电', rest: '搁置', end: '结束', cccv: '恒流恒压充电' };
+    const metricLabels = { temperature: '温度', positive_potential: '正电位', negative_potential: '负电位' };
+    let saveEditor = null;
+    document.querySelector('#editor-close').onclick = () => editor.close();
+    document.querySelector('#editor-cancel').onclick = () => editor.close();
+    editorForm.addEventListener('submit', async event => {
+      event.preventDefault();
+      const save = document.querySelector('#editor-save');
+      save.disabled = true;
+      try { const state = await saveEditor(); editor.close(); render(state); }
+      catch (error) { showError(error); }
+      finally { save.disabled = false; }
+    });
 
     async function request(path, options) {
       const response = await fetch(api + path, { ...options, headers: { 'Content-Type': 'application/json' } });
@@ -148,6 +185,11 @@
     function showError(error) {
       errorBox.textContent = error instanceof Error ? error.message : '控制页请求失败';
       errorBox.classList.add('visible');
+      if (editor.open) {
+        const message = document.querySelector('#editor-error');
+        message.textContent = errorBox.textContent;
+        message.classList.add('visible');
+      }
     }
 
     function render(state) {
@@ -242,7 +284,7 @@
         settings.append(ip.label, channelCount.label, model, onlineLabel, save, remove);
 
         const table = document.createElement('table');
-        table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th></tr></thead>';
+        table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th><th>观测配置</th></tr></thead>';
         const body = document.createElement('tbody');
         body.append(...device.channels.map(channel => channelRow(device.deviceId, channel)));
         table.append(body);
@@ -337,18 +379,13 @@
       const identity = document.createElement('td');
       identity.textContent = channel.channelCode;
       const metricsCell = document.createElement('td');
-      const metrics = document.createElement('textarea');
-      metrics.setAttribute('aria-label', `${channel.channelCode} 测量项`);
-      metrics.value = channel.metrics.map(item => `${item.metricCode}=${item.value} ${item.unit}`).join('\n');
-      metricsCell.append(metrics);
+      metricsCell.textContent = channel.metrics.map(item => `${metricLabels[item.metricCode] || item.metricCode} ${item.value} ${item.unit}`).join(' / ');
+      metricsCell.style.overflowWrap = 'anywhere';
       const action = document.createElement('td');
       const save = document.createElement('button');
       save.type = 'button';
-      save.textContent = '保存测量值';
-      save.addEventListener('click', async () => {
-        try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}`, { method: 'PUT', body: JSON.stringify({ metrics: parseMetrics(metrics.value) }) })); }
-        catch (error) { showError(error); }
-      });
+      save.textContent = '编辑测量值';
+      save.addEventListener('click', () => editCollectorChannel(collectorId, channel));
       action.append(save);
       const statusCell = document.createElement('td');
       const status = document.createElement('select');
@@ -362,14 +399,6 @@
       statusCell.append(status);
       row.append(identity, statusCell, metricsCell, action);
       return row;
-    }
-
-    function parseMetrics(text) {
-      return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
-        const match = line.match(/^([^=\s]+)=(-?\d+(?:\.\d+)?)\s+(.+)$/);
-        if (!match) throw new Error(`测量项格式错误:${line}`);
-        return { metricCode: match[1], value: Number(match[2]), unit: match[3] };
-      });
     }
 
     function measurementLines(items) {
@@ -386,6 +415,137 @@
       input.value = value;
       label.append(text, input);
       return { label, input };
+    }
+
+    function openEditor(title) {
+      editorContent.replaceChildren();
+      document.querySelector('#editor-title').textContent = title;
+      document.querySelector('#editor-error').classList.remove('visible');
+      editor.showModal();
+    }
+
+    function numericField(label, value, integer = false) {
+      const result = field(label, 'number', value);
+      result.input.required = true;
+      result.input.step = integer ? '1' : 'any';
+      return result;
+    }
+
+    function iconButton(symbol, label, action) {
+      const button = document.createElement('button');
+      button.type = 'button'; button.className = 'icon-button'; button.textContent = symbol;
+      button.title = label; button.setAttribute('aria-label', label); button.onclick = action;
+      return button;
+    }
+
+    function editCabinetChannel(deviceId, channel) {
+      openEditor(`检测柜 ${deviceId} · 通道 ${channel.channelId} 参数与方案`);
+      const config = channel.configuration;
+      const fields = document.createElement('div'); fields.className = 'editor-fields';
+      const inputs = {};
+      for (const [key, label, integer] of [
+        ['voltageV', '电压 (V)'], ['currentA', '电流 (A)'], ['capacityAh', '容量 (Ah)'], ['energyWh', '能量 (Wh)'],
+        ['cycleNo', '当前循环', true], ['stepNo', '当前工步', true], ['plannedCycleCount', '计划循环数', true],
+        ['totalTimeSeconds', '总时间 (s)', true], ['relativeTimeSeconds', '工步时间 (s)', true],
+      ]) {
+        const item = numericField(label, config[key], integer);
+        if (integer) item.input.min = key.endsWith('Seconds') ? '0' : '1';
+        inputs[key] = item.input; fields.append(item.label);
+      }
+      const heading = document.createElement('div'); heading.className = 'section-title';
+      const title = document.createElement('h3'); title.textContent = '工步方案';
+      const add = document.createElement('button'); add.type = 'button'; add.textContent = '添加工步';
+      heading.append(title, add);
+      const scroll = document.createElement('div'); scroll.className = 'editor-scroll';
+      const table = document.createElement('table');
+      table.innerHTML = '<thead><tr><th style="width:50px">序号</th><th>工步名称</th><th style="width:140px">工步类型</th><th style="width:110px">时长 (s)</th><th style="width:100px">电压 (V)</th><th style="width:112px">操作</th></tr></thead>';
+      const body = document.createElement('tbody'); table.append(body); scroll.append(table);
+      const steps = [];
+      function syncRows() {
+        body.replaceChildren(...steps.map((step, index) => {
+          step.number.textContent = index + 1;
+          step.up.disabled = index === 0; step.down.disabled = index === steps.length - 1;
+          step.remove.disabled = steps.length === 1;
+          return step.row;
+        }));
+        inputs.stepNo.max = String(steps.length);
+        if (Number(inputs.stepNo.value) > steps.length) inputs.stepNo.value = steps.length;
+      }
+      function addStep(value) {
+        const row = document.createElement('tr'); const number = document.createElement('td'); row.append(number);
+        const name = field('工步名称', 'text', value.name).input; name.required = true; name.setAttribute('aria-label', '工步名称');
+        const type = document.createElement('select'); type.setAttribute('aria-label', '工步类型');
+        Object.entries(stepTypes).forEach(([code, label]) => type.add(new Option(label, code))); type.value = value.stepType;
+        const duration = numericField('时长 (s)', value.durationSeconds).input; duration.min = '0'; duration.setAttribute('aria-label', '时长 (s)');
+        const voltage = numericField('电压 (V)', value.voltageV).input; voltage.setAttribute('aria-label', '工步电压 (V)');
+        for (const input of [name, type, duration, voltage]) { const cell = document.createElement('td'); cell.append(input); row.append(cell); }
+        const step = { row, number, name, type, duration, voltage };
+        const move = delta => { const index = steps.indexOf(step); steps.splice(index, 1); steps.splice(index + delta, 0, step); syncRows(); };
+        step.up = iconButton('↑', '上移工步', () => move(-1));
+        step.down = iconButton('↓', '下移工步', () => move(1));
+        step.remove = iconButton('×', '删除工步', () => { steps.splice(steps.indexOf(step), 1); syncRows(); });
+        const actions = document.createElement('td'); const buttons = document.createElement('div'); buttons.className = 'row-actions';
+        buttons.append(step.up, step.down, step.remove); actions.append(buttons); row.append(actions);
+        steps.push(step); syncRows();
+      }
+      config.steps.forEach(addStep);
+      // 初次填充方案行时保留保存过的当前工步位置。
+      inputs.stepNo.value = config.stepNo;
+      add.onclick = () => addStep({ name: '新工步', stepType: 'rest', durationSeconds: 60, voltageV: 0 });
+      editorContent.append(fields, heading, scroll);
+      saveEditor = () => request(`/devices/${deviceId}/channels/${channel.channelId}/configuration`, {
+        method: 'PUT', body: JSON.stringify({
+          ...Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, Number(input.value)])),
+          steps: steps.map(step => ({ name: step.name.value, stepType: step.type.value,
+            durationSeconds: Number(step.duration.value), voltageV: Number(step.voltage.value) })),
+        }),
+      });
+    }
+
+    function editCollectorChannel(collectorId, channel) {
+      openEditor(`采集器 ${collectorId} · ${channel.channelCode} 测量值`);
+      const add = document.createElement('button'); add.type = 'button'; add.textContent = '添加测量项';
+      const scroll = document.createElement('div'); scroll.className = 'editor-scroll';
+      const table = document.createElement('table'); table.style.minWidth = '560px';
+      table.innerHTML = '<thead><tr><th>测量项</th><th>数值</th><th style="width:100px">单位</th><th style="width:50px">操作</th></tr></thead>';
+      const body = document.createElement('tbody'); table.append(body); scroll.append(table);
+      const metrics = [];
+      function addMetric(item) {
+        const row = document.createElement('tr');
+        const kind = document.createElement('select'); kind.setAttribute('aria-label', '测量项');
+        Object.entries(metricLabels).forEach(([code, label]) => kind.add(new Option(label, code)));
+        kind.add(new Option('自定义', 'custom')); kind.value = metricLabels[item.metricCode] ? item.metricCode : 'custom';
+        const code = field('自定义名称', 'text', item.metricCode).input; code.setAttribute('aria-label', '自定义名称');
+        const value = numericField('数值', item.value).input; value.setAttribute('aria-label', '数值');
+        const unit = field('单位', 'text', item.unit).input; unit.required = true; unit.setAttribute('aria-label', '单位');
+        const updateKind = () => {
+          code.hidden = kind.value !== 'custom'; code.required = !code.hidden;
+          unit.readOnly = kind.value !== 'custom';
+          if (kind.value !== 'custom') unit.value = kind.value === 'temperature' ? '℃' : 'V';
+        };
+        code.hidden = kind.value !== 'custom'; code.required = !code.hidden;
+        kind.onchange = updateKind;
+        const identity = document.createElement('td'); identity.append(kind, code); row.append(identity);
+        for (const input of [value, unit]) { const cell = document.createElement('td'); cell.append(input); row.append(cell); }
+        const metric = { row, kind, code, value, unit };
+        const action = document.createElement('td');
+        metric.remove = iconButton('×', '删除测量项', () => {
+          metrics.splice(metrics.indexOf(metric), 1); row.remove(); syncButtons();
+        });
+        action.append(metric.remove); row.append(action); metrics.push(metric); body.append(row); syncButtons();
+      }
+      function syncButtons() { metrics.forEach(item => { item.remove.disabled = metrics.length === 1; }); }
+      channel.metrics.forEach(addMetric);
+      add.onclick = () => addMetric({ metricCode: '', value: 0, unit: 'V' });
+      editorContent.append(add, scroll);
+      saveEditor = () => {
+        const values = metrics.map(item => ({ metricCode: item.kind.value === 'custom' ? item.code.value.trim() : item.kind.value,
+          value: Number(item.value.value), unit: item.unit.value.trim() }));
+        if (new Set(values.map(item => item.metricCode)).size !== values.length) throw new Error('测量项不能重复');
+        return request(`/collectors/${collectorId}/channels/${channel.channelCode}`, {
+          method: 'PUT', body: JSON.stringify({ metrics: values }),
+        });
+      };
     }
 
     function channelRow(deviceId, channel) {
@@ -414,7 +574,11 @@
       online.addEventListener('change', update);
       status.addEventListener('change', update);
       statusCell.append(status);
-      row.append(identity, onlineCell, statusCell);
+      const configCell = document.createElement('td');
+      const configure = document.createElement('button'); configure.type = 'button'; configure.textContent = '参数与方案';
+      configure.onclick = () => editCabinetChannel(deviceId, channel);
+      configCell.append(configure);
+      row.append(identity, onlineCell, statusCell, configCell);
       return row;
     }
 
@@ -449,6 +613,7 @@
     }
 
     async function refresh() {
+      if (editor.open) return;
       if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
       try { render(await request('/state')); } catch (error) { showError(error); }
     }
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java
index 3e3c3ce..ecc55f3 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java
@@ -39,6 +39,45 @@
 class RedisStateStoreTest
 {
     @Test
+    void channelParametersAndPlanSurviveRedisRestartAndOnlineChanges() throws Exception
+    {
+        var stored = new AtomicReference<Object>();
+        var configuration = new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration(
+                new java.math.BigDecimal("-3.123456"), new java.math.BigDecimal("8.125"),
+                new java.math.BigDecimal("7.2"), new java.math.BigDecimal("29.7"), 2, 2, 5, 120, 30,
+                List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2),
+                        new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电 <验证>", "dc", 90, 3.0)));
+        try (var simulator = new BtsDevelopmentSimulator(0, store(stored))) {
+            simulator.configureChannel(1, 1, configuration);
+            simulator.updateChannel(1, 1, false, DeviceWorkStatus.PAUSED);
+            simulator.setAllDevicesPoweredOn(false);
+            simulator.setSoftwareAccessible(false);
+        }
+        try (var simulator = new BtsDevelopmentSimulator(0, store(stored))) {
+            assertEquals(configuration, simulator.snapshot().devices().get(0).channels().get(0).configuration());
+            simulator.setSoftwareAccessible(true);
+            simulator.setAllDevicesPoweredOn(true);
+            simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
+            var identity = simulator.discoverChannels().get(0).identity();
+            assertEquals(-3.123456, simulator.latestCabinet(identity).currentA());
+            assertEquals(8.125, simulator.latestCabinet(identity).voltageV());
+            assertEquals("放电 <验证>", simulator.testPlan(identity).rows().get(1).stepName());
+            assertEquals("进行中", simulator.testPlan(identity).rows().get(1).executionStatus());
+        }
+    }
+
+    @Test
+    void oldChannelStateReceivesDefaultConfigurationWithoutResettingIdentity()
+    {
+        var object = JSON.parseObject(JSON.toJSONString(state(7, "127.0.0.9")));
+        object.getJSONArray("devices").getJSONObject(0).getJSONArray("channels").getJSONObject(0).remove("configuration");
+        var restored = store(new AtomicReference<>(object.toJSONString())).loadOrInitialize(state(1, "127.0.0.2"));
+        assertEquals(7, restored.devices().get(0).deviceId());
+        assertEquals("127.0.0.9", restored.devices().get(0).ip());
+        assertEquals(1, restored.devices().get(0).channels().get(0).configuration().steps().size());
+    }
+
+    @Test
     void initializesOnlyWhenStateIsAbsentAndRestoresSavedState()
     {
         AtomicReference<Object> stored = new AtomicReference<>();
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
index efbae8e..35889f4 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
@@ -38,6 +38,35 @@
 class TTestLiveServiceImplTest
 {
     @Test
+    void consoleReadsConfiguredSimulatorPlanAtCurrentChannelPosition() throws Exception
+    {
+        try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0)) {
+            var test = test("RECEIVING");
+            var binding = test.getBatteries().get(0).getBinding();
+            binding.setDevtype(22); binding.setSubdevid(1);
+            simulator.configureChannel(1, 1, new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration(
+                    BigDecimal.ONE, new BigDecimal("4.125"), BigDecimal.ONE, BigDecimal.ONE, 2, 2, 3, 120, 30,
+                    List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2),
+                            new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电", "dc", 90, 3.0))));
+            simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
+            var tests = mock(ITTestService.class);
+            when(tests.selectTestById(10L)).thenReturn(test);
+            var service = new TTestLiveServiceImpl(tests,
+                    new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), simulator);
+            var plan = service.current(10L).testPlan();
+            assertEquals(2, plan.currentCycleNo());
+            assertEquals(2, plan.currentStepNo());
+            assertEquals(3, plan.plannedCycleCount());
+            assertEquals(List.of(1, 2), plan.rows().stream().map(row -> row.cyclePosition()).toList());
+            assertEquals("放电", plan.rows().get(1).stepName());
+            assertEquals(90d, plan.rows().get(1).durationSeconds());
+            assertEquals("进行中", plan.rows().get(1).executionStatus());
+            simulator.updateChannel(1, 1, false, DeviceWorkStatus.WORKING);
+            org.junit.jupiter.api.Assertions.assertNull(service.current(10L).testPlan());
+        }
+    }
+
+    @Test
     void offlineHintIsIndependentOfHistoricalHandlingStatusAndSurvivesRefresh()
     {
         TTest test = test("RECEIVING");
diff --git a/ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java b/ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java
index 91d10a9..aa11c90 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java
@@ -30,6 +30,62 @@
 class BtsDevelopmentSimulatorControllerTest
 {
     @Test
+    void invalidChannelConfigurationDoesNotReplaceSavedValues() throws Exception
+    {
+        try (var simulator = new BtsDevelopmentSimulator(0)) {
+            var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build();
+            var before = simulator.snapshot().devices().get(0).channels().get(0).configuration();
+            String valid = com.alibaba.fastjson2.JSON.toJSONString(before);
+            for (String property : List.of("stepNo", "cycleNo", "currentA", "steps")) {
+                var invalid = com.alibaba.fastjson2.JSON.parseObject(valid);
+                invalid.put(property, switch (property) {
+                    case "stepNo" -> 2;
+                    case "cycleNo" -> 0;
+                    case "steps" -> List.of();
+                    default -> null;
+                });
+                mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put(
+                        "/dev/bts-simulator/devices/1/channels/1/configuration")
+                        .contentType("application/json").content(invalid.toJSONString()))
+                        .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest());
+                assertEquals(before, simulator.snapshot().devices().get(0).channels().get(0).configuration());
+            }
+        }
+    }
+
+    @Test
+    void channelConfigurationChangesMeasurementsAndPlanOnlyForSelectedChannel() throws Exception
+    {
+        try (var simulator = new BtsDevelopmentSimulator(0)) {
+            var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build();
+            mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put(
+                    "/dev/bts-simulator/devices/1/channels/1/configuration")
+                    .contentType("application/json").content("""
+                    {"currentA":-2.5,"voltageV":4.125,"capacityAh":8.2,"energyWh":33.8,
+                     "cycleNo":2,"stepNo":2,"plannedCycleCount":3,"totalTimeSeconds":120,"relativeTimeSeconds":30,
+                     "steps":[{"name":"充电","stepType":"cc","durationSeconds":60,"voltageV":4.2},
+                              {"name":"放电","stepType":"dc","durationSeconds":90,"voltageV":3.0}]}
+                    """))
+                    .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
+            var identity = simulator.discoverChannels().get(0).identity();
+            var actual = simulator.latestCabinet(identity);
+            assertEquals(-2.5, actual.currentA());
+            assertEquals(4.125, actual.voltageV());
+            assertEquals(8.2, actual.capacityAh());
+            assertEquals(33.8, actual.energyWh());
+            assertEquals(2, actual.cycleNo());
+            assertEquals(2, actual.stepNo());
+            assertEquals("dc", actual.stepType());
+            assertEquals("放电", actual.stepName());
+            assertEquals(2, simulator.testPlan(identity).rows().size());
+            assertEquals(3, simulator.testPlan(identity).plannedCycleCount());
+            var other = simulator.discoverChannels().get(1).identity();
+            assertEquals(1, simulator.testPlan(other).rows().size());
+            assertEquals(1, simulator.latestCabinet(other).stepNo());
+        }
+    }
+
+    @Test
     void restChangesSoftwareAndCollectorChannelFactsAndRetiresPlayback() throws Exception
     {
         try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))

--
Gitblit v1.7.1