From a23f23570935850192099133509e90c33769d26c Mon Sep 17 00:00:00 2001
From: Administrator <15274802129@163.com>
Date: Thu, 13 Aug 2026 15:08:51 +0800
Subject: [PATCH] feat(station): 添加策略监控中心功能

---
 src/main/resources/static/station-dashboard.html                                    | 1146 ++++++++++++++++++++++++++++++++++
 src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java           |   93 ++
 src/main/resources/mapper/station/StrategyEventLogDao.xml                           |   16 
 src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java            |   61 +
 src/main/resources/mapper/station/StrategyStatusDao.xml                             |    9 
 src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java |  228 ++++++
 src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java       |   85 ++
 src/main/java/com/xcong/excoin/modules/station/service/StationService.java          |   36 +
 src/main/resources/db/station-schema.sql                                            |   71 ++
 src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java       |    1 
 src/main/java/com/xcong/excoin/modules/station/controller/StationController.java    |   72 ++
 src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java    |   69 ++
 src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java           |   16 
 src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java         |   33 +
 src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java         |   23 
 15 files changed, 1,959 insertions(+), 0 deletions(-)

diff --git a/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java b/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
index 68196a0..f8ba049 100644
--- a/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
+++ b/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
@@ -55,6 +55,7 @@
                 .antMatchers("/api/orderCoin/findCollect").permitAll()
                 .antMatchers("/api/gate/**").permitAll()
                 .antMatchers("/gate-config.html").permitAll()
+                .antMatchers("/station-dashboard.html").permitAll()
                 .anyRequest().authenticated()
                 .and().apply(securityConfiguereAdapter());
     }
diff --git a/src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java b/src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java
new file mode 100644
index 0000000..5fa4ce8
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java
@@ -0,0 +1,69 @@
+package com.xcong.excoin.modules.station.consumer;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.xcong.excoin.configurations.RabbitMqConfig;
+import com.xcong.excoin.modules.station.model.CmdAckMsg;
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+import com.xcong.excoin.modules.station.model.HeartbeatMsg;
+import com.xcong.excoin.modules.station.registry.InstanceRegistry;
+import com.xcong.excoin.modules.station.service.StationService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.rabbit.annotation.RabbitListener;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * Station 消费心跳 + ACK + 策略事件 — 均复用到 QUEUE_GATE_HEARTBEAT 和 QUEUE_GATE_STATS
+ */
+@Slf4j
+@Component
+public class GateMessageConsumer {
+
+    @Resource
+    private InstanceRegistry instanceRegistry;
+
+    @Resource
+    private StationService stationService;
+
+    /**
+     * 消费心跳 + ACK(都路由到 heartbeat 队列)
+     */
+    @RabbitListener(queues = RabbitMqConfig.QUEUE_GATE_HEARTBEAT)
+    public void onHeartbeat(String msg) {
+        try {
+            JSONObject json = JSON.parseObject(msg);
+            String type = json.getString("type");
+            String apiKeyMd5 = json.getString("apiKeyMd5");
+
+            if ("HEARTBEAT".equals(type)) {
+                HeartbeatMsg hb = JSON.parseObject(json.getString("payload"), HeartbeatMsg.class);
+                instanceRegistry.update(apiKeyMd5, hb);
+                log.debug("[Station] 心跳: {}, state={}", apiKeyMd5, hb.getState());
+
+            } else if ("CMD_ACK".equals(type)) {
+                CmdAckMsg ack = JSON.parseObject(json.getString("payload"), CmdAckMsg.class);
+                instanceRegistry.updateState(apiKeyMd5, ack.getNewState());
+                log.info("[Station] 指令确认: cmdId={}, success={}, newState={}",
+                        ack.getCommandId(), ack.isSuccess(), ack.getNewState());
+            }
+
+        } catch (Exception e) {
+            log.error("[Station] 心跳消费异常", e);
+        }
+    }
+
+    /**
+     * 消费策略事件(stats 队列,落库)
+     */
+    @RabbitListener(queues = RabbitMqConfig.QUEUE_GATE_STATS)
+    public void onStats(String msg) {
+        try {
+            GateStatsEvent event = JSON.parseObject(msg, GateStatsEvent.class);
+            stationService.saveEvent(event);
+        } catch (Exception e) {
+            log.error("[Station] 策略事件消费异常", e);
+        }
+    }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java b/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java
new file mode 100644
index 0000000..b6c2a9b
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java
@@ -0,0 +1,72 @@
+package com.xcong.excoin.modules.station.controller;
+
+import com.xcong.excoin.common.response.Result;
+import com.xcong.excoin.modules.station.registry.InstanceRegistry;
+import com.xcong.excoin.modules.station.service.StationService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+/**
+ * Station 管理接口 — 实例列表 / 启停 / 状态 / 事件查询
+ */
+@Slf4j
+@Api(value = "Gate 策略管理", tags = "Gate 策略管理")
+@RestController
+@RequestMapping("/api/gate/station")
+public class StationController {
+
+    @Resource
+    private InstanceRegistry instanceRegistry;
+
+    @Resource
+    private StationService stationService;
+
+    @ApiOperation("获取所有在线 JAR 实例")
+    @GetMapping("/list")
+    public Result list() {
+        return Result.ok(instanceRegistry.list());
+    }
+
+    @ApiOperation("获取单个实例状态")
+    @GetMapping("/status")
+    public Result status(@RequestParam String apiKeyMd5) {
+        return Result.ok(instanceRegistry.get(apiKeyMd5));
+    }
+
+    @ApiOperation("启动指定 apiKeyMd5 的策略")
+    @PostMapping("/start")
+    public Result start(@RequestParam String apiKeyMd5) {
+        stationService.startByMd5(apiKeyMd5);
+        return Result.ok("指令已发送");
+    }
+
+    @ApiOperation("停止指定 apiKeyMd5 的策略")
+    @PostMapping("/stop")
+    public Result stop(@RequestParam String apiKeyMd5) {
+        stationService.stopByMd5(apiKeyMd5);
+        return Result.ok("指令已发送");
+    }
+
+    @ApiOperation("查询策略事件日志")
+    @GetMapping("/events")
+    public Result events(@RequestParam String apiKeyMd5) {
+        return Result.ok(stationService.getEvents(apiKeyMd5));
+    }
+
+    @ApiOperation("查询策略实时状态")
+    @GetMapping("/strategy-status")
+    public Result strategyStatus(@RequestParam String apiKeyMd5) {
+        return Result.ok(stationService.getStrategyStatus(apiKeyMd5));
+    }
+
+    @ApiOperation("更新策略参数(仅持久化,需手动启动生效)")
+    @PostMapping("/config")
+    public Result updateConfig(@RequestParam String apiKeyMd5, @RequestBody java.util.Map<String, Object> params) {
+        stationService.updateConfig(apiKeyMd5, params);
+        return Result.ok("参数已保存,请手动点击「启动」使新配置生效");
+    }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java b/src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java
new file mode 100644
index 0000000..4e37e98
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java
@@ -0,0 +1,23 @@
+package com.xcong.excoin.modules.station.dao;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xcong.excoin.modules.station.entity.StrategyEventLog;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 策略事件日志 DAO
+ */
+public interface StrategyEventLogDao extends BaseMapper<StrategyEventLog> {
+
+    /**
+     * 按 apiKeyMd5 分页查询事件(最近 200 条)
+     */
+    List<StrategyEventLog> selectByApiKeyMd5(@Param("apiKeyMd5") String apiKeyMd5);
+
+    /**
+     * eventId 是否存在(幂等去重)
+     */
+    int countByEventId(@Param("eventId") String eventId);
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java b/src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java
new file mode 100644
index 0000000..a8c8746
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java
@@ -0,0 +1,16 @@
+package com.xcong.excoin.modules.station.dao;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xcong.excoin.modules.station.entity.StrategyStatus;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 策略状态 DAO
+ */
+public interface StrategyStatusDao extends BaseMapper<StrategyStatus> {
+
+    /**
+     * 按 apiKeyMd5 查询唯一状态记录
+     */
+    StrategyStatus selectByApiKeyMd5(@Param("apiKeyMd5") String apiKeyMd5);
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java
new file mode 100644
index 0000000..43eda23
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java
@@ -0,0 +1,33 @@
+package com.xcong.excoin.modules.station.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.xcong.excoin.common.system.base.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 策略事件日志表(只追加,eventId 唯一)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("strategy_event_log")
+public class StrategyEventLog extends BaseEntity {
+
+    /** 事件唯一 ID(幂等去重) */
+    private String eventId;
+
+    /** 事件类型:HEARTBEAT / STRATEGY_START / STOP_LOSS_TRIGGERED ... */
+    private String eventType;
+
+    /** apiKey 的 MD5 */
+    private String apiKeyMd5;
+
+    /** 合约名称 */
+    private String contract;
+
+    /** 事件时间戳 */
+    private Long eventTime;
+
+    /** JSON payload */
+    private String payloadJson;
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java
new file mode 100644
index 0000000..1410787
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java
@@ -0,0 +1,93 @@
+package com.xcong.excoin.modules.station.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.xcong.excoin.common.system.base.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 策略实时状态表(apiKeyMd5 维度,UPSERT)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("strategy_status")
+public class StrategyStatus extends BaseEntity {
+
+    /** apiKey 的 MD5 */
+    private String apiKeyMd5;
+
+    /** 合约名称 */
+    private String contract;
+
+    /** 策略状态:ACTIVE / STOPPED / WAITING_KLINE */
+    private String state;
+
+    /** 杠杆倍数 */
+    private String leverage;
+
+    /** 网格间距比例 */
+    private String gridRate;
+
+    /** 预期收益(USDT) */
+    private String expectedProfit;
+
+    /** 最大亏损(USDT) */
+    private String maxLoss;
+
+    /** 基底开仓张数 */
+    private String baseQuantity;
+
+    /** 每次下单张数 */
+    private String quantity;
+
+    /** 最大持仓张数(单方向),0=不限制 */
+    private Integer maxPositionSize;
+
+    /** 止损阶梯次数,0=禁用 */
+    private Integer stopLossCount;
+
+    /** 止盈网格跨度 */
+    private Integer takeProfitGridSpan;
+
+    /** 止损统计方式 */
+    private String stopLossCountMode;
+
+    /** 加仓间隔 */
+    private Integer addPositionInterval;
+
+    /** 加仓数量 */
+    private Integer addPositionQuantity;
+
+    /** 单边最大仓位 */
+    private Integer maxPositionPerSide;
+
+    /** 加仓启动阈值 */
+    private Integer addPositionStartThreshold;
+
+    /** 超额止盈开关 */
+    private Boolean placeExcessTakeProfit;
+
+    /** 价格驱动开关 */
+    private Boolean priceDriveEnabled;
+
+    /** 当前轮次 */
+    private Integer currentRound;
+
+    /** 总轮数上限 */
+    private Integer totalRounds;
+
+    /** 多头累计止损次数 */
+    private Integer accumulatedLongLoss;
+
+    /** 空头累计止损次数 */
+    private Integer accumulatedShortLoss;
+
+    /** 累计已实现盈亏 */
+    private String cumulativePnl;
+
+    /** 未实现盈亏 */
+    private String unrealizedPnl;
+
+    /** 初始本金 */
+    private String principal;
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java b/src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java
new file mode 100644
index 0000000..8f4323a
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java
@@ -0,0 +1,61 @@
+package com.xcong.excoin.modules.station.producer;
+
+import com.alibaba.fastjson.JSON;
+import com.xcong.excoin.configurations.RabbitMqConfig;
+import com.xcong.excoin.modules.station.model.GateCommand;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.rabbit.connection.CorrelationData;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+/**
+ * Station → JAR 指令发送器(精准路由到 QUEUE_GATE_CMD_{apiKeyMd5})
+ */
+@Slf4j
+@Component
+public class CmdProducer {
+
+    private final RabbitTemplate rabbitTemplate;
+
+    /** RabbitTemplate 是 prototype,必须用构造器注入 */
+    @Autowired
+    public CmdProducer(RabbitTemplate rabbitTemplate) {
+        this.rabbitTemplate = rabbitTemplate;
+    }
+
+    /**
+     * 发送指令到目标 JAR
+     *
+     * @param apiKeyMd5 目标 apiKey MD5
+     * @param type      START / STOP
+     */
+    public void sendCommand(String apiKeyMd5, String type) {
+        sendCommand(apiKeyMd5, type, null);
+    }
+
+    /**
+     * 发送带 payload 的指令到目标 JAR
+     *
+     * @param apiKeyMd5 目标 apiKey MD5
+     * @param type      START / STOP / UPDATE_CONFIG
+     * @param payload   指令附加数据(JSON),可为 null
+     */
+    public void sendCommand(String apiKeyMd5, String type, String payload) {
+        GateCommand cmd = GateCommand.builder()
+                .commandId(UUID.randomUUID().toString())
+                .commandType(type)
+                .apiKeyMd5(apiKeyMd5)
+                .timestamp(System.currentTimeMillis())
+                .payload(payload)
+                .build();
+
+        String routingKey = "cmd." + apiKeyMd5;
+        CorrelationData cd = new CorrelationData(cmd.getCommandId());
+        rabbitTemplate.convertAndSend(RabbitMqConfig.EXCHANGE_GATE, routingKey, JSON.toJSONString(cmd), cd);
+        log.info("[Station] 发送指令: type={}, apiKeyMd5={}, cmdId={}, routingKey={}",
+                type, apiKeyMd5, cmd.getCommandId(), routingKey);
+    }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java b/src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java
new file mode 100644
index 0000000..3e42edc
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java
@@ -0,0 +1,85 @@
+package com.xcong.excoin.modules.station.registry;
+
+import com.xcong.excoin.modules.station.model.HeartbeatMsg;
+import com.xcong.excoin.modules.station.model.InstanceInfo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * JAR 实例注册表 — 心跳更新,超时自动清理。
+ */
+@Slf4j
+@Component
+public class InstanceRegistry {
+
+    /** 30s 心跳 × 2 = 60s 超时下线 */
+    private static final long TIMEOUT_MS = 60_000;
+
+    private final ConcurrentHashMap<String, InstanceInfo> registry = new ConcurrentHashMap<>();
+
+    /**
+     * 心跳更新(不存在则新增)
+     */
+    public void update(String apiKeyMd5, HeartbeatMsg msg) {
+        InstanceInfo info = InstanceInfo.builder()
+                .apiKeyMd5(apiKeyMd5)
+                .contract(msg.getContract())
+                .state(msg.getState())
+                .leverage(msg.getLeverage())
+                .currentRound(msg.getCurrentRound())
+                .cumulativePnl(msg.getCumulativePnl())
+                .principal(msg.getPrincipal())
+                .hostPort(msg.getHostPort())
+                .lastSeen(System.currentTimeMillis())
+                .build();
+        InstanceInfo old = registry.put(apiKeyMd5, info);
+        if (old == null) {
+            log.info("[Station] 实例上线, apiKeyMd5={}, contract={}", apiKeyMd5, msg.getContract());
+        }
+    }
+
+    /**
+     * 更新状态(用于 ACK)
+     */
+    public void updateState(String apiKeyMd5, String newState) {
+        InstanceInfo info = registry.get(apiKeyMd5);
+        if (info != null) {
+            info.setState(newState);
+            info.setLastSeen(System.currentTimeMillis());
+        }
+    }
+
+    /**
+     * 获取单个实例
+     */
+    public InstanceInfo get(String apiKeyMd5) {
+        return registry.get(apiKeyMd5);
+    }
+
+    /**
+     * 所有在线实例
+     */
+    public Collection<InstanceInfo> list() {
+        return registry.values();
+    }
+
+    /**
+     * 每 30s 清理超时实例
+     */
+    @Scheduled(fixedRate = 30_000)
+    public void cleanDead() {
+        long now = System.currentTimeMillis();
+        registry.entrySet().removeIf(entry -> {
+            boolean dead = now - entry.getValue().getLastSeen() > TIMEOUT_MS;
+            if (dead) {
+                log.info("[Station] 实例离线, apiKeyMd5={}, 最后心跳:{}ms前",
+                        entry.getKey(), now - entry.getValue().getLastSeen());
+            }
+            return dead;
+        });
+    }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/service/StationService.java b/src/main/java/com/xcong/excoin/modules/station/service/StationService.java
new file mode 100644
index 0000000..6915344
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/service/StationService.java
@@ -0,0 +1,36 @@
+package com.xcong.excoin.modules.station.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.xcong.excoin.modules.station.entity.StrategyEventLog;
+import com.xcong.excoin.modules.station.entity.StrategyStatus;
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+
+import java.util.List;
+import java.util.Map;
+
+public interface StationService extends IService<StrategyEventLog> {
+
+    /** 启动策略(通过 MQ 发送 START 到目标 JAR) */
+    void startStrategy(String apiKey);
+
+    /** 停止策略(通过 MQ 发送 STOP 到目标 JAR) */
+    void stopStrategy(String apiKey);
+
+    /** 启动策略(直接使用 apiKeyMd5,跳过内部 md5 计算) */
+    void startByMd5(String apiKeyMd5);
+
+    /** 停止策略(直接使用 apiKeyMd5,跳过内部 md5 计算) */
+    void stopByMd5(String apiKeyMd5);
+
+    /** 更新策略参数(通过 MQ 发送 UPDATE_CONFIG 到目标 JAR,仅持久化,需手动启动生效) */
+    void updateConfig(String apiKeyMd5, Map<String, Object> params);
+
+    /** 保存策略事件(幂等) */
+    void saveEvent(GateStatsEvent event);
+
+    /** 查询事件日志 */
+    List<StrategyEventLog> getEvents(String apiKeyMd5);
+
+    /** 查询策略实时状态 */
+    StrategyStatus getStrategyStatus(String apiKeyMd5);
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java b/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java
new file mode 100644
index 0000000..86a8b68
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java
@@ -0,0 +1,228 @@
+package com.xcong.excoin.modules.station.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.xcong.excoin.modules.station.dao.StrategyEventLogDao;
+import com.xcong.excoin.modules.station.dao.StrategyStatusDao;
+import com.xcong.excoin.modules.station.entity.StrategyEventLog;
+import com.xcong.excoin.modules.station.entity.StrategyStatus;
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+import com.xcong.excoin.modules.station.producer.CmdProducer;
+import com.xcong.excoin.modules.station.service.StationService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class StationServiceImpl extends ServiceImpl<StrategyEventLogDao, StrategyEventLog> implements StationService {
+
+    @Resource
+    private CmdProducer cmdProducer;
+
+    @Resource
+    private StrategyStatusDao strategyStatusDao;
+
+    @Resource
+    private StrategyEventLogDao strategyEventLogDao;
+
+    // ==================== 启停 ====================
+
+    @Override
+    public void startStrategy(String apiKey) {
+        cmdProducer.sendCommand(md5(apiKey), "START");
+    }
+
+    @Override
+    public void stopStrategy(String apiKey) {
+        cmdProducer.sendCommand(md5(apiKey), "STOP");
+    }
+
+    @Override
+    public void startByMd5(String apiKeyMd5) {
+        cmdProducer.sendCommand(apiKeyMd5, "START");
+    }
+
+    @Override
+    public void stopByMd5(String apiKeyMd5) {
+        cmdProducer.sendCommand(apiKeyMd5, "STOP");
+    }
+
+    @Override
+    public void updateConfig(String apiKeyMd5, Map<String, Object> params) {
+        // 过滤空字符串,避免 Fastjson 反序列化到 BigDecimal/int 时抛异常
+        params.entrySet().removeIf(e -> e.getValue() == null || "".equals(e.getValue()));
+        // 1. 持久化配置到 strategy_status,保证保存后刷新页面可回显
+        persistConfig(apiKeyMd5, params);
+        // 2. 发送 MQ 命令,通知 JAR(需手动点击「启动」才生效)
+        cmdProducer.sendCommand(apiKeyMd5, "UPDATE_CONFIG", JSON.toJSONString(params));
+    }
+
+    // ==================== 事件落库 ====================
+
+    @Override
+    public void saveEvent(GateStatsEvent event) {
+        // 幂等去重
+        if (strategyEventLogDao.countByEventId(event.getEventId()) > 0) {
+            return;
+        }
+
+        StrategyEventLog log = new StrategyEventLog();
+        log.setEventId(event.getEventId());
+        log.setEventType(event.getType());
+        log.setApiKeyMd5(event.getApiKeyMd5());
+        log.setEventTime(event.getTimestamp());
+        log.setPayloadJson(event.getPayload());
+        log.setCreateTime(new Date());
+
+        // 从 payload 提取 contract(仅 STRATEGY_START 时有)
+        String contract = extractContract(event);
+        log.setContract(contract);
+
+        strategyEventLogDao.insert(log);
+
+        // STRATEGY_START / STRATEGY_STOP 事件同步更新实时状态表
+        if ("STRATEGY_START".equals(event.getType())) {
+            upsertStrategyStatus(event, contract);
+        } else if ("STRATEGY_STOP".equals(event.getType())) {
+            updateStrategyStopped(event);
+        }
+    }
+
+    @Override
+    public List<StrategyEventLog> getEvents(String apiKeyMd5) {
+        return strategyEventLogDao.selectByApiKeyMd5(apiKeyMd5);
+    }
+
+    @Override
+    public StrategyStatus getStrategyStatus(String apiKeyMd5) {
+        return strategyStatusDao.selectByApiKeyMd5(apiKeyMd5);
+    }
+
+    // ==================== 辅助方法 ====================
+
+    private void upsertStrategyStatus(GateStatsEvent event, String contract) {
+        JSONObject payload = JSON.parseObject(event.getPayload());
+        StrategyStatus existing = strategyStatusDao.selectByApiKeyMd5(event.getApiKeyMd5());
+
+        StrategyStatus status = (existing != null) ? existing : new StrategyStatus();
+        status.setApiKeyMd5(event.getApiKeyMd5());
+        status.setContract(contract);
+        status.setState("ACTIVE");
+        // 复用统一的配置字段映射,保证 START 事件落库字段与 updateConfig 一致
+        applyConfigFields(status, payload);
+
+        if (existing != null) {
+            strategyStatusDao.updateById(status);
+        } else {
+            status.setCreateTime(new Date());
+            strategyStatusDao.insert(status);
+        }
+    }
+
+    private void updateStrategyStopped(GateStatsEvent event) {
+        JSONObject payload = JSON.parseObject(event.getPayload());
+        StrategyStatus status = strategyStatusDao.selectByApiKeyMd5(event.getApiKeyMd5());
+        if (status != null) {
+            status.setState("STOPPED");
+            status.setCumulativePnl(payload.getString("pnl"));
+            strategyStatusDao.updateById(status);
+        }
+    }
+
+    /**
+     * 持久化配置到 strategy_status(UPSERT)。
+     * 保存配置但策略未启动时,DB 里只有配置字段,state/contract 保持原值或为空,
+     * 前端刷新即可回显已保存参数。
+     */
+    private void persistConfig(String apiKeyMd5, Map<String, Object> params) {
+        StrategyStatus existing = strategyStatusDao.selectByApiKeyMd5(apiKeyMd5);
+        StrategyStatus status = (existing != null) ? existing : new StrategyStatus();
+        status.setApiKeyMd5(apiKeyMd5);
+        applyConfigFields(status, params);
+
+        if (existing != null) {
+            strategyStatusDao.updateById(status);
+        } else {
+            status.setCreateTime(new Date());
+            strategyStatusDao.insert(status);
+        }
+    }
+
+    /**
+     * 将配置 Map 的字段映射到 StrategyStatus 实体。
+     * 前端提交的 key(gridRate/quantity/expectedProfit/rounds/priceDriveEnabled...)
+     * 与 STRATEGY_START payload 的 key 一致,rounds 映射到实体字段 totalRounds。
+     */
+    private void applyConfigFields(StrategyStatus status, Map<String, Object> params) {
+        if (params.containsKey("leverage")) status.setLeverage(str(params.get("leverage")));
+        if (params.containsKey("contract")) status.setContract(str(params.get("contract")));
+        if (params.containsKey("gridRate")) status.setGridRate(str(params.get("gridRate")));
+        if (params.containsKey("expectedProfit")) status.setExpectedProfit(str(params.get("expectedProfit")));
+        if (params.containsKey("maxLoss")) status.setMaxLoss(str(params.get("maxLoss")));
+        if (params.containsKey("baseQuantity")) status.setBaseQuantity(str(params.get("baseQuantity")));
+        if (params.containsKey("quantity")) status.setQuantity(str(params.get("quantity")));
+        if (params.containsKey("maxPositionSize")) status.setMaxPositionSize(intVal(params.get("maxPositionSize")));
+        if (params.containsKey("stopLossCount")) status.setStopLossCount(intVal(params.get("stopLossCount")));
+        if (params.containsKey("takeProfitGridSpan")) status.setTakeProfitGridSpan(intVal(params.get("takeProfitGridSpan")));
+        if (params.containsKey("stopLossCountMode")) status.setStopLossCountMode(str(params.get("stopLossCountMode")));
+        if (params.containsKey("addPositionInterval")) status.setAddPositionInterval(intVal(params.get("addPositionInterval")));
+        if (params.containsKey("addPositionQuantity")) status.setAddPositionQuantity(intVal(params.get("addPositionQuantity")));
+        if (params.containsKey("maxPositionPerSide")) status.setMaxPositionPerSide(intVal(params.get("maxPositionPerSide")));
+        if (params.containsKey("addPositionStartThreshold")) status.setAddPositionStartThreshold(intVal(params.get("addPositionStartThreshold")));
+        if (params.containsKey("placeExcessTakeProfit")) status.setPlaceExcessTakeProfit(boolVal(params.get("placeExcessTakeProfit")));
+        if (params.containsKey("priceDriveEnabled")) status.setPriceDriveEnabled(boolVal(params.get("priceDriveEnabled")));
+        if (params.containsKey("rounds")) status.setTotalRounds(intVal(params.get("rounds")));
+        if (params.containsKey("principal")) status.setPrincipal(str(params.get("principal")));
+    }
+
+    private static String str(Object o) {
+        return o == null ? null : String.valueOf(o);
+    }
+
+    private static Integer intVal(Object o) {
+        if (o == null) return null;
+        if (o instanceof Number) return ((Number) o).intValue();
+        try {
+            return Integer.parseInt(String.valueOf(o).trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static Boolean boolVal(Object o) {
+        if (o == null) return null;
+        if (o instanceof Boolean) return (Boolean) o;
+        String s = String.valueOf(o).trim();
+        return "true".equalsIgnoreCase(s) || "1".equals(s);
+    }
+
+    private String extractContract(GateStatsEvent event) {
+        try {
+            JSONObject payload = JSON.parseObject(event.getPayload());
+            return payload.getString("contract");
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String md5(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) sb.append(String.format("%02x", b));
+            return sb.toString();
+        } catch (NoSuchAlgorithmException e) {
+            return Integer.toHexString(input.hashCode());
+        }
+    }
+}
diff --git a/src/main/resources/db/station-schema.sql b/src/main/resources/db/station-schema.sql
new file mode 100644
index 0000000..f6ae3fa
--- /dev/null
+++ b/src/main/resources/db/station-schema.sql
@@ -0,0 +1,71 @@
+-- Gate 策略管理 — Station 数据库表
+
+-- 策略实时状态(apiKeyMd5 唯一,UPSERT)
+CREATE TABLE IF NOT EXISTS `strategy_status` (
+    `id`             BIGINT(20)   NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `api_key_md5`    VARCHAR(64)  NOT NULL COMMENT 'apiKey MD5',
+    `contract`       VARCHAR(32)  DEFAULT NULL COMMENT '合约名称',
+    `state`          VARCHAR(32)  DEFAULT NULL COMMENT '策略状态:ACTIVE / STOPPED / WAITING_KLINE',
+    `leverage`       VARCHAR(16)  DEFAULT NULL COMMENT '杠杆倍数',
+    `grid_rate`      VARCHAR(16)  DEFAULT NULL COMMENT '网格间距比例',
+    `expected_profit`            VARCHAR(32)  DEFAULT NULL COMMENT '预期收益(USDT)',
+    `max_loss`                   VARCHAR(32)  DEFAULT NULL COMMENT '最大亏损(USDT)',
+    `base_quantity`  VARCHAR(16)  DEFAULT NULL COMMENT '基底开仓张数',
+    `quantity`       VARCHAR(16)  DEFAULT NULL COMMENT '每次下单张数',
+    `max_position_size`          INT(11)      DEFAULT 0  COMMENT '最大持仓张数(单方向)',
+    `stop_loss_count`            INT(11)      DEFAULT 0  COMMENT '止损阶梯次数',
+    `take_profit_grid_span`      INT(11)      DEFAULT 2  COMMENT '止盈网格跨度',
+    `stop_loss_count_mode`        VARCHAR(16)  DEFAULT NULL COMMENT '止损统计方式:single / dual',
+    `add_position_interval`       INT(11)      DEFAULT 0  COMMENT '加仓间隔',
+    `add_position_quantity`       INT(11)      DEFAULT 0  COMMENT '加仓数量',
+    `max_position_per_side`       INT(11)      DEFAULT 0  COMMENT '单边最大仓位',
+    `add_position_start_threshold` INT(11)     DEFAULT 0  COMMENT '加仓启动阈值',
+    `place_excess_take_profit`    TINYINT(1)   DEFAULT 0  COMMENT '超额止盈开关',
+    `price_drive_enabled`        TINYINT(1)   DEFAULT 1  COMMENT '价格驱动开关',
+    `current_round`  INT(11)      DEFAULT 0  COMMENT '当前轮次',
+    `total_rounds`   INT(11)      DEFAULT 0  COMMENT '总轮数上限',
+    `accumulated_long_loss`       INT(11)      DEFAULT 0  COMMENT '多头累计止损次数',
+    `accumulated_short_loss`      INT(11)      DEFAULT 0  COMMENT '空头累计止损次数',
+    `cumulative_pnl` VARCHAR(32)  DEFAULT '0' COMMENT '累计已实现盈亏',
+    `unrealized_pnl` VARCHAR(32)  DEFAULT '0' COMMENT '未实现盈亏',
+    `principal`      VARCHAR(32)  DEFAULT NULL COMMENT '初始本金',
+    `create_by`      VARCHAR(64)  DEFAULT NULL,
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    `update_by`      VARCHAR(64)  DEFAULT NULL,
+    `update_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    `version`        INT(11)      DEFAULT 0,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_api_key_md5` (`api_key_md5`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='策略实时状态';
+
+-- 策略事件日志(只追加,eventId 唯一去重)
+CREATE TABLE IF NOT EXISTS `strategy_event_log` (
+    `id`             BIGINT(20)   NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `event_id`       VARCHAR(64)  NOT NULL COMMENT '事件唯一 ID(幂等去重)',
+    `event_type`     VARCHAR(64)  NOT NULL COMMENT '事件类型:HEARTBEAT / STRATEGY_START / STRATEGY_STOP / ROUND_COMPLETE / STOP_LOSS_TRIGGERED / ENTRY_FILLED / PNL_SNAPSHOT',
+    `api_key_md5`    VARCHAR(64)  NOT NULL COMMENT 'apiKey MD5',
+    `contract`       VARCHAR(32)  DEFAULT NULL COMMENT '合约名称',
+    `event_time`     BIGINT(20)   DEFAULT NULL COMMENT '事件时间戳(ms)',
+    `payload_json`   TEXT         DEFAULT NULL COMMENT 'JSON payload',
+    `create_by`      VARCHAR(64)  DEFAULT NULL,
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    `update_by`      VARCHAR(64)  DEFAULT NULL,
+    `update_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    `version`        INT(11)      DEFAULT 0,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_event_id` (`event_id`),
+    KEY `idx_api_key_md5_time` (`api_key_md5`, `event_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='策略事件日志';
+
+-- ============================================================
+-- 升级脚本:为已存在的 strategy_status 表补齐配置回显字段
+-- (新增字段与上方建表语句保持一致;若已执行过可忽略 Duplicate column 报错)
+-- ============================================================
+ALTER TABLE `strategy_status`
+    ADD COLUMN `expected_profit`       VARCHAR(32) DEFAULT NULL COMMENT '预期收益(USDT)'        AFTER `grid_rate`,
+    ADD COLUMN `max_loss`              VARCHAR(32) DEFAULT NULL COMMENT '最大亏损(USDT)'        AFTER `grid_rate`,
+    ADD COLUMN `quantity`              VARCHAR(16) DEFAULT NULL COMMENT '每次下单张数'          AFTER `base_quantity`,
+    ADD COLUMN `max_position_size`     INT(11)     DEFAULT 0    COMMENT '最大持仓张数(单方向)'  AFTER `base_quantity`,
+    ADD COLUMN `stop_loss_count`       INT(11)     DEFAULT 0    COMMENT '止损阶梯次数'          AFTER `base_quantity`,
+    ADD COLUMN `take_profit_grid_span` INT(11)     DEFAULT 2    COMMENT '止盈网格跨度'          AFTER `base_quantity`,
+    ADD COLUMN `price_drive_enabled`   TINYINT(1)  DEFAULT 1    COMMENT '价格驱动开关'          AFTER `place_excess_take_profit`;
diff --git a/src/main/resources/mapper/station/StrategyEventLogDao.xml b/src/main/resources/mapper/station/StrategyEventLogDao.xml
new file mode 100644
index 0000000..2713a99
--- /dev/null
+++ b/src/main/resources/mapper/station/StrategyEventLogDao.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.xcong.excoin.modules.station.dao.StrategyEventLogDao">
+
+    <select id="selectByApiKeyMd5" resultType="com.xcong.excoin.modules.station.entity.StrategyEventLog">
+        SELECT * FROM strategy_event_log
+        WHERE api_key_md5 = #{apiKeyMd5}
+        ORDER BY event_time DESC
+        LIMIT 200
+    </select>
+
+    <select id="countByEventId" resultType="int">
+        SELECT COUNT(1) FROM strategy_event_log WHERE event_id = #{eventId}
+    </select>
+
+</mapper>
diff --git a/src/main/resources/mapper/station/StrategyStatusDao.xml b/src/main/resources/mapper/station/StrategyStatusDao.xml
new file mode 100644
index 0000000..77d21a3
--- /dev/null
+++ b/src/main/resources/mapper/station/StrategyStatusDao.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.xcong.excoin.modules.station.dao.StrategyStatusDao">
+
+    <select id="selectByApiKeyMd5" resultType="com.xcong.excoin.modules.station.entity.StrategyStatus">
+        SELECT * FROM strategy_status WHERE api_key_md5 = #{apiKeyMd5} LIMIT 1
+    </select>
+
+</mapper>
diff --git a/src/main/resources/static/station-dashboard.html b/src/main/resources/static/station-dashboard.html
new file mode 100644
index 0000000..31e91ff
--- /dev/null
+++ b/src/main/resources/static/station-dashboard.html
@@ -0,0 +1,1146 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Station Dashboard — 策略监控中心</title>
+    <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
+    <style>
+        :root {
+            --bg: #eaf2ff; --card: #ffffff; --border: #e3e8f0;
+            --text: #1f2937; --dim: #6b7280; --accent: #3b82f6;
+            --danger: #dc2626; --green: #16a34a; --warn: #d97706;
+            --input-bg: #f9fafb; --sidebar-w: 280px;
+            --purple: #7c3aed; --teal: #0d9488;
+        }
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+            background: var(--bg); color: var(--text); min-height: 100vh;
+            display: flex; overflow: hidden;
+        }
+
+        /* ===== 侧边栏 ===== */
+        .sidebar {
+            width: var(--sidebar-w); min-width: var(--sidebar-w);
+            background: var(--card); border-right: 1px solid var(--border);
+            display: flex; flex-direction: column; height: 100vh;
+        }
+        .sidebar-header {
+            padding: 16px 16px 12px; border-bottom: 1px solid var(--border);
+            display: flex; justify-content: space-between; align-items: center;
+        }
+        .sidebar-header h1 {
+            font-size: 16px; display: flex; align-items: center; gap: 8px;
+        }
+        .refresh-badge {
+            font-size: 10px; color: var(--dim); border: 1px solid var(--border);
+            border-radius: 10px; padding: 2px 8px;
+        }
+        .instance-list {
+            flex: 1; overflow-y: auto; padding: 8px;
+        }
+        .instance-card {
+            background: var(--bg); border: 1px solid var(--border);
+            border-radius: 6px; padding: 12px; margin-bottom: 6px;
+            cursor: pointer; transition: all .15s;
+        }
+        .instance-card:hover { border-color: var(--accent); }
+        .instance-card.selected { border-color: var(--accent); background: #eef4ff; }
+        .instance-card .top-row {
+            display: flex; justify-content: space-between; align-items: center;
+        }
+        .instance-card .key {
+            font-size: 11px; color: var(--dim); font-family: "SF Mono", "Consolas", monospace;
+        }
+        .instance-card .contract {
+            font-size: 14px; font-weight: 600; margin-top: 2px;
+        }
+        .instance-card .meta {
+            display: flex; gap: 10px; margin-top: 6px; font-size: 11px; color: var(--dim);
+        }
+        .instance-card .pnl { font-weight: 600; }
+        .instance-card .pnl.positive { color: var(--green); }
+        .instance-card .pnl.negative { color: var(--danger); }
+        .status-dot {
+            width: 8px; height: 8px; border-radius: 50%; display: inline-block;
+            flex-shrink: 0;
+        }
+        .status-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
+        .status-dot.stopped { background: var(--danger); }
+        .status-dot.offline { background: var(--dim); }
+
+        .sidebar-footer {
+            padding: 10px 16px; border-top: 1px solid var(--border);
+            font-size: 11px; color: var(--dim); display: flex; justify-content: space-between;
+        }
+
+        /* ===== 主区域 ===== */
+        .main {
+            flex: 1; display: flex; flex-direction: column; height: 100vh;
+            overflow: hidden;
+        }
+        .main-header {
+            padding: 14px 24px; border-bottom: 1px solid var(--border);
+            background: var(--card);
+            display: flex; justify-content: space-between; align-items: center;
+        }
+        .main-header .instance-title {
+            display: flex; align-items: center; gap: 10px;
+        }
+        .main-header h2 { font-size: 17px; }
+        .main-header .state-badge {
+            font-size: 11px; padding: 2px 10px; border-radius: 10px;
+            font-weight: 600;
+        }
+        .state-badge.ACTIVE { background: #dcfce7; color: #15803d; }
+        .state-badge.STOPPED { background: #fee2e2; color: #b91c1c; }
+        .state-badge.WAITING_KLINE { background: #fef3c7; color: #b45309; }
+        .state-badge.OPENING { background: #dbeafe; color: #1d4ed8; }
+
+        .btn-group { display: flex; gap: 6px; }
+        .btn {
+            padding: 6px 14px; border-radius: 5px; border: none;
+            font-size: 12px; cursor: pointer; font-weight: 500;
+            transition: opacity .15s;
+        }
+        .btn:hover { opacity: .85; }
+        .btn-success { background: var(--green); color: #fff; }
+        .btn-danger { background: var(--danger); color: #fff; }
+        .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
+        .btn:disabled { opacity: .4; cursor: not-allowed; }
+
+        /* ===== 内容区(Tabs) ===== */
+        .content { flex: 1; overflow-y: auto; padding: 16px 24px; }
+        .tabs {
+            display: flex; gap: 0; margin-bottom: 16px;
+            border-bottom: 1px solid var(--border);
+        }
+        .tab {
+            padding: 8px 20px; font-size: 13px; cursor: pointer;
+            border-bottom: 2px solid transparent; color: var(--dim);
+            transition: all .15s;
+        }
+        .tab:hover { color: var(--text); }
+        .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
+
+        .tab-content { display: none; }
+        .tab-content.active { display: block; }
+
+        /* ===== 统计卡片 ===== */
+        .stats-grid {
+            display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+            gap: 12px; margin-bottom: 20px;
+        }
+        .stat-card {
+            background: var(--card); border: 1px solid var(--border);
+            border-radius: 8px; padding: 14px 16px;
+        }
+        .stat-card .label { font-size: 11px; color: var(--dim); margin-bottom: 4px; }
+        .stat-card .value { font-size: 22px; font-weight: 600; }
+        .stat-card .sub { font-size: 11px; color: var(--dim); margin-top: 2px; }
+
+        /* ===== 图表容器 ===== */
+        .chart-container {
+            background: var(--card); border: 1px solid var(--border);
+            border-radius: 8px; padding: 16px; margin-bottom: 20px;
+        }
+        .chart-container h3 {
+            font-size: 13px; color: var(--dim); margin-bottom: 12px;
+        }
+        .pnl-summary {
+            display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+            gap: 10px; margin-bottom: 16px;
+        }
+        .pnl-summary-item {
+            background: var(--bg); border: 1px solid var(--border);
+            border-radius: 8px; padding: 10px 14px;
+        }
+        .pnl-summary-item .s-label { font-size: 11px; color: var(--dim); margin-bottom: 4px; }
+        .pnl-summary-item .s-value { font-size: 17px; font-weight: 600; color: var(--text); }
+        .pnl-summary-item .s-delta { font-size: 11px; margin-top: 3px; font-weight: 500; color: var(--dim); }
+        .pnl-summary-item .s-value.positive { color: var(--green); }
+        .pnl-summary-item .s-value.negative { color: var(--danger); }
+        .pnl-summary-item .s-delta.positive { color: var(--green); }
+        .pnl-summary-item .s-delta.negative { color: var(--danger); }
+        .chart-wrap { position: relative; height: 300px; }
+        .chart-wrap canvas { width: 100% !important; height: 100% !important; }
+
+        /* ===== 事件日志 ===== */
+        .events-panel {
+            background: var(--card); border: 1px solid var(--border);
+            border-radius: 8px; overflow: hidden;
+        }
+        .events-header {
+            padding: 12px 16px; border-bottom: 1px solid var(--border);
+            display: flex; justify-content: space-between; align-items: center;
+        }
+        .events-header h3 { font-size: 13px; color: var(--dim); }
+        .events-table {
+            width: 100%; font-size: 12px; border-collapse: collapse;
+        }
+        .events-table th {
+            text-align: left; padding: 8px 16px; font-weight: 500;
+            color: var(--dim); border-bottom: 1px solid var(--border);
+            background: var(--bg);
+        }
+        .events-table td {
+            padding: 7px 16px; border-bottom: 1px solid rgba(0,0,0,0.06);
+            vertical-align: middle;
+        }
+        .events-table tr:hover { background: rgba(59,130,246,0.05); }
+        .event-tag {
+            font-size: 10px; padding: 1px 8px; border-radius: 8px;
+            font-weight: 600; white-space: nowrap;
+        }
+        .event-tag.STRATEGY_START { background: #dcfce7; color: #15803d; }
+        .event-tag.STRATEGY_STOP { background: #fee2e2; color: #b91c1c; }
+        .event-tag.ROUND_COMPLETE { background: #dbeafe; color: #1d4ed8; }
+        .event-tag.STOP_LOSS_TRIGGERED { background: #fef3c7; color: #b45309; }
+        .event-tag.ENTRY_FILLED { background: #ede9fe; color: #6d28d9; }
+        .event-tag.PNL_SNAPSHOT { background: #ccfbf1; color: #0f766e; }
+        .event-tag.CMD_ACK { background: #e5e7eb; color: #4b5563; }
+        .payload-preview {
+            max-width: 260px; overflow: hidden; text-overflow: ellipsis;
+            white-space: nowrap; color: var(--dim); font-family: "SF Mono", "Consolas", monospace;
+        }
+        .relative-time { color: var(--dim); white-space: nowrap; font-size: 11px; }
+
+        /* ===== 配置面板 ===== */
+        .config-status-bar {
+            display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px;
+        }
+        .config-status-item {
+            flex: 1; min-width: 120px;
+            background: var(--card); border: 1px solid var(--border);
+            border-radius: 8px; padding: 10px 14px;
+        }
+        .config-status-item .label { font-size: 11px; color: var(--dim); margin-bottom: 3px; }
+        .config-status-item .value { font-size: 16px; font-weight: 600; }
+        .config-status-item .value.positive { color: var(--green); }
+        .config-status-item .value.negative { color: var(--danger); }
+        .config-status-item .value.dim { color: var(--dim); }
+
+        .config-section { margin-bottom: 18px; }
+        .config-section-title {
+            font-size: 13px; font-weight: 600; color: var(--text);
+            margin-bottom: 10px; padding-bottom: 7px;
+            border-bottom: 1px solid var(--border);
+            display: flex; align-items: center; gap: 7px;
+        }
+        .config-section-title .count {
+            font-size: 10px; font-weight: 400; color: var(--dim);
+            background: var(--bg); border: 1px solid var(--border);
+            border-radius: 9px; padding: 0 7px; line-height: 16px;
+        }
+        .form-grid {
+            display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px;
+        }
+        .form-group { display: flex; flex-direction: column; gap: 5px; }
+        .form-group label { font-size: 11px; color: var(--dim); font-weight: 500; }
+        .form-group input, .form-group select {
+            background: var(--input-bg); border: 1px solid var(--border);
+            border-radius: 6px; color: var(--text); padding: 8px 10px;
+            font-size: 13px; outline: none; width: 100%;
+            transition: border-color .15s;
+        }
+        .form-group input:focus, .form-group select:focus { border-color: var(--accent); }
+        .form-group input::placeholder { color: #9ca3af; }
+        .form-group .field-tip { font-size: 10px; color: #9ca3af; line-height: 1.3; }
+        .form-group.modified label::after {
+            content: ' • 已修改'; color: var(--warn); font-weight: 600;
+        }
+        .form-group.modified input, .form-group.modified select { border-color: var(--warn); }
+        .config-actions {
+            display: flex; justify-content: flex-end; gap: 8px;
+            margin-bottom: 18px; padding-bottom: 14px;
+            border-bottom: 1px solid var(--border);
+        }
+
+        /* ===== 空状态 ===== */
+        .empty-state {
+            text-align: center; padding: 60px 20px; color: var(--dim);
+        }
+        .empty-state .icon { font-size: 48px; margin-bottom: 12px; }
+        .empty-state p { font-size: 14px; }
+
+        /* ===== 确认弹窗 ===== */
+        .modal-overlay {
+            position: fixed; inset: 0; background: rgba(15,23,42,0.4);
+            display: flex; align-items: center; justify-content: center; z-index: 1000;
+            display: none;
+        }
+        .modal-overlay.show { display: flex; }
+        .modal {
+            background: var(--card); border: 1px solid var(--border);
+            border-radius: 10px; padding: 24px; width: 380px;
+        }
+        .modal h3 { font-size: 16px; margin-bottom: 12px; }
+        .modal p { font-size: 13px; color: var(--dim); margin-bottom: 20px; }
+        .modal .modal-btns { display: flex; justify-content: flex-end; gap: 8px; }
+
+        /* Toast — 页面正中间,3s 后彻底隐藏 */
+        .toast {
+            position: fixed; top: 50%; left: 50%;
+            transform: translate(-50%, -50%);
+            padding: 12px 22px; border-radius: 8px; font-size: 14px;
+            z-index: 2000; display: none;
+            max-width: 80vw; text-align: center;
+        }
+        .toast.show { display: block; animation: toastIn .25s ease; }
+        .toast.success { background: #dcfce7; border:1px solid #86efac; color:#15803d; }
+        .toast.error { background: #fee2e2; border:1px solid #fca5a5; color:#b91c1c; }
+        @keyframes toastIn {
+            from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
+            to   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+        }
+
+        /* Scrollbar */
+        ::-webkit-scrollbar { width: 6px; }
+        ::-webkit-scrollbar-track { background: transparent; }
+        ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+        @media (max-width: 900px) {
+            body { flex-direction: column; }
+            .sidebar { width: 100%; min-width: 100%; height: auto; max-height: 40vh; }
+            .main { height: auto; }
+            .stats-grid { grid-template-columns: 1fr 1fr; }
+            .form-grid { grid-template-columns: 1fr; }
+        }
+    </style>
+</head>
+<body>
+
+<!-- ===== 侧边栏:实例列表 ===== -->
+<div class="sidebar">
+    <div class="sidebar-header">
+        <h1>📡 Station</h1>
+        <span class="refresh-badge" id="refreshBadge">⏳ 5s</span>
+    </div>
+    <div class="instance-list" id="instanceList">
+        <div class="empty-state">
+            <div class="icon">📭</div>
+            <p>暂无在线实例</p>
+            <p style="font-size:11px;margin-top:4px">等待 JAR 心跳注册...</p>
+        </div>
+    </div>
+    <div class="sidebar-footer">
+        <span id="connStatus">🟢 已连接</span>
+        <span id="instanceCount">0 实例</span>
+    </div>
+</div>
+
+<!-- ===== 主区域 ===== -->
+<div class="main">
+    <!-- 顶栏 -->
+    <div class="main-header" id="mainHeader">
+        <div class="instance-title">
+            <h2 style="color:var(--dim)">← 选择一个实例</h2>
+        </div>
+        <div class="btn-group">
+            <button class="btn btn-success" id="btnStart" disabled onclick="showConfirm('start')">▶ 启动</button>
+            <button class="btn btn-danger" id="btnStop" disabled onclick="showConfirm('stop')">⏹ 停止</button>
+        </div>
+    </div>
+
+    <!-- 内容区 -->
+    <div class="content" id="mainContent">
+        <div class="empty-state">
+            <div class="icon">👈</div>
+            <p>从左侧列表选择一个实例查看详情</p>
+        </div>
+    </div>
+</div>
+
+<!-- 详情模板(初始隐藏) -->
+<div id="detailTemplate" style="display:none">
+    <!-- Tabs -->
+    <div class="tabs">
+        <div class="tab active" data-tab="overview">概览</div>
+        <div class="tab" data-tab="events">事件日志</div>
+        <div class="tab" data-tab="config">策略参数</div>
+    </div>
+
+    <!-- 概览 Tab -->
+    <div class="tab-content active" data-tab="overview">
+        <div class="stats-grid" id="statsGrid"></div>
+        <div class="chart-container">
+            <h3>📈 盈亏趋势(最近 PNL_SNAPSHOT 事件)</h3>
+            <div class="pnl-summary" id="pnlSummary"></div>
+            <div class="chart-wrap"><canvas id="pnlChart"></canvas></div>
+        </div>
+    </div>
+
+    <!-- 事件日志 Tab -->
+    <div class="tab-content" data-tab="events">
+        <div class="events-panel">
+            <div class="events-header">
+                <h3>📋 最近 100 条事件</h3>
+                <span style="font-size:11px;color:var(--dim)" id="eventCount">—</span>
+            </div>
+            <div style="max-height:500px;overflow-y:auto">
+                <table class="events-table">
+                    <thead>
+                        <tr>
+                            <th style="width:120px">时间</th>
+                            <th style="width:140px">类型</th>
+                            <th>Payload</th>
+                        </tr>
+                    </thead>
+                    <tbody id="eventsBody"></tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+
+    <!-- 策略参数 Tab(可编辑) -->
+    <div class="tab-content" data-tab="config">
+        <!-- 只读状态条 -->
+        <div class="config-status-bar" id="configStatusBar"></div>
+
+        <div class="chart-container">
+            <div class="config-actions">
+                <span style="flex:1;font-size:12px;color:var(--dim);align-self:center">💡 修改后需手动点击顶部「▶ 启动」使新配置生效</span>
+                <button class="btn btn-outline" id="btnResetConfig" onclick="resetConfig()">↺ 重置</button>
+                <button class="btn btn-success" id="btnSaveConfig" onclick="saveConfig()">💾 保存</button>
+            </div>
+            <div id="configForm"></div>
+        </div>
+    </div>
+</div>
+
+<!-- 确认弹窗 -->
+<div class="modal-overlay" id="modalOverlay">
+    <div class="modal">
+        <h3 id="modalTitle">确认操作</h3>
+        <p id="modalMsg"></p>
+        <div class="modal-btns">
+            <button class="btn btn-outline" onclick="closeModal()">取消</button>
+            <button class="btn" id="modalConfirmBtn" onclick="confirmAction()">确认</button>
+        </div>
+    </div>
+</div>
+
+<div id="toast"></div>
+
+<script>
+const API = '/api/gate/station';
+let selectedMd5 = null;
+let instances = {};
+let eventsCache = {};
+let pnlChart = null;
+let pollingTimer = null;
+let pendingAction = null;
+let pnlData = [];
+
+// ==================== 工具 ====================
+function $(id) { return document.getElementById(id); }
+function toast(msg, type) {
+    const t = $('toast');
+    t.textContent = msg;
+    t.className = 'toast ' + type + ' show';
+    // 清除上一次未消失的定时器,避免重复点击时闪烁
+    clearTimeout(t._toastTimer);
+    t._toastTimer = setTimeout(() => {
+        t.className = 'toast'; // 移除 show → display:none,彻底隐藏不留空白框
+    }, 3000);
+}
+function fmtNum(v) {
+    if (v == null || v === '') return '—';
+    const n = parseFloat(v);
+    if (isNaN(n)) return v;
+    return n.toFixed(n < 1 ? 6 : 2);
+}
+function fmtPnl(v) {
+    if (v == null || v === '') return { text: '—', cls: '' };
+    const n = parseFloat(v);
+    if (isNaN(n)) return { text: v, cls: '' };
+    return {
+        text: (n >= 0 ? '+' : '') + n.toFixed(4),
+        cls: n > 0 ? 'positive' : n < 0 ? 'negative' : ''
+    };
+}
+function relativeTime(ts) {
+    if (!ts) return '—';
+    const diff = Date.now() - ts;
+    if (diff < 5000) return '刚刚';
+    if (diff < 60000) return Math.floor(diff / 1000) + 's 前';
+    if (diff < 3600000) return Math.floor(diff / 60000) + 'm 前';
+    if (diff < 86400000) return Math.floor(diff / 3600000) + 'h 前';
+    return new Date(ts).toLocaleDateString();
+}
+function formatTime(ts) {
+    if (!ts) return '—';
+    const d = new Date(ts);
+    return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0');
+}
+
+// ==================== API 请求 ====================
+async function api(url, opts) {
+    try {
+        const res = await fetch(url, opts);
+        const data = await res.json();
+        if (data.code !== 0 && data.code !== 200) throw new Error(data.msg || '请求失败');
+        return data.data !== undefined ? data.data : data.msg;
+    } catch (e) {
+        if (e.message.includes('Failed to fetch') || e.message.includes('NetworkError')) {
+            $('connStatus').innerHTML = '🔴 离线';
+        }
+        throw e;
+    }
+}
+
+// ==================== 实例列表 ====================
+async function refreshInstanceList() {
+    try {
+        const list = await api(API + '/list');
+        $('connStatus').innerHTML = '🟢 已连接';
+        const newInstances = {};
+        for (const inst of (list || [])) {
+            newInstances[inst.apiKeyMd5] = inst;
+        }
+        instances = newInstances;
+        renderInstanceList();
+    } catch (e) {
+        // 静默失败,保留旧数据
+    }
+}
+
+function renderInstanceList() {
+    const container = $('instanceList');
+    const keys = Object.keys(instances);
+
+    if (keys.length === 0) {
+        container.innerHTML = `
+            <div class="empty-state">
+                <div class="icon">📭</div>
+                <p>暂无在线实例</p>
+                <p style="font-size:11px;margin-top:4px">等待 JAR 心跳注册...</p>
+            </div>`;
+    } else {
+        container.innerHTML = keys.map(k => {
+            const i = instances[k];
+            const isActive = i.state === 'ACTIVE' || i.state === 'OPENING';
+            const dotClass = i.state === 'STOPPED' ? 'stopped' : isActive ? 'active' : 'offline';
+            const pnl = fmtPnl(i.cumulativePnl);
+            const selected = k === selectedMd5 ? ' selected' : '';
+            return `
+            <div class="instance-card${selected}" onclick="selectInstance('${k}')" data-md5="${k}">
+                <div class="top-row">
+                    <span class="key">${i.contract || '—'}</span>
+                    <span class="status-dot ${dotClass}" title="${i.state || 'UNKNOWN'}"></span>
+                </div>
+                <div class="contract">${k.substring(0, 16)}...</div>
+                <div class="meta">
+                    <span>轮次 ${i.currentRound ?? 0}</span>
+                    <span class="pnl ${pnl.cls}">${pnl.text}</span>
+                    <span>${relativeTime(i.lastSeen)}</span>
+                </div>
+            </div>`;
+        }).join('');
+    }
+    $('instanceCount').textContent = keys.length + ' 实例';
+}
+
+function selectInstance(md5) {
+    selectedMd5 = md5;
+    eventsCache[md5] = null; // 强制刷新
+    pnlData = [];
+    renderInstanceList();
+    loadInstanceDetail(md5, true);
+}
+
+async function loadInstanceDetail(md5, full) {
+    const inst = instances[md5];
+    if (!inst) return;
+
+    // 显示详情模板,隐藏空状态
+    const content = $('mainContent');
+    const tmpl = $('detailTemplate');
+    if (!content.querySelector('.tabs')) {
+        content.innerHTML = tmpl.innerHTML;
+        // 绑定 tab 切换
+        content.querySelectorAll('.tab').forEach(t => {
+            t.addEventListener('click', () => switchTab(t.dataset.tab));
+        });
+    }
+
+    // 更新 header
+    const header = $('mainHeader');
+    header.querySelector('.instance-title').innerHTML = `
+        <h2>${inst.contract || '—'} <span style="font-size:12px;color:var(--dim);font-weight:400">${md5.substring(0,12)}...</span></h2>
+        <span class="state-badge ${sanitizeState(inst.state)}">${inst.state || 'UNKNOWN'}</span>
+    `;
+    $('btnStart').disabled = false;
+    $('btnStop').disabled = false;
+
+    // 更新概览统计
+    renderStats(inst);
+
+    // 加载事件(内部会渲染事件表格 + PNL 图表)
+    await loadEvents(md5);
+
+    // 策略参数表单:仅全量加载(切换实例)时渲染,避免轮询清空用户正在编辑的内容
+    if (full) {
+        loadConfig(md5);
+    }
+}
+
+function sanitizeState(s) {
+    if (!s) return '';
+    const valid = ['ACTIVE', 'STOPPED', 'WAITING_KLINE', 'OPENING'];
+    return valid.includes(s) ? s : '';
+}
+
+function switchTab(name) {
+    document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+    document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+    document.querySelector(`.tab[data-tab="${name}"]`).classList.add('active');
+    document.querySelector(`.tab-content[data-tab="${name}"]`).classList.add('active');
+}
+
+// ==================== 统计卡片 ====================
+function renderStats(inst) {
+    const pnl = fmtPnl(inst.cumulativePnl);
+    const principal = inst.principal ? parseFloat(inst.principal) : 0;
+    const cumpnl = inst.cumulativePnl ? parseFloat(inst.cumulativePnl) : 0;
+    const totalEquity = principal + cumpnl;
+    const roi = principal > 0 ? ((cumpnl / principal) * 100) : 0;
+
+    $('statsGrid').innerHTML = `
+        <div class="stat-card">
+            <div class="label">累计已实现盈亏</div>
+            <div class="value ${pnl.cls}">${pnl.text}</div>
+            <div class="sub">USDT</div>
+        </div>
+        <div class="stat-card">
+            <div class="label">初始本金</div>
+            <div class="value">${fmtNum(inst.principal)}</div>
+            <div class="sub">USDT</div>
+        </div>
+        <div class="stat-card">
+            <div class="label">估算总权益</div>
+            <div class="value">${fmtNum(totalEquity)}</div>
+            <div class="sub">本金 + 已实现盈亏</div>
+        </div>
+        <div class="stat-card">
+            <div class="label">收益率 (ROI)</div>
+            <div class="value ${roi >= 0 ? 'positive' : 'negative'}" style="color:${roi >= 0 ? 'var(--green)' : 'var(--danger)'}">${roi >= 0 ? '+' : ''}${roi.toFixed(4)}%</div>
+            <div class="sub">累计</div>
+        </div>
+        <div class="stat-card">
+            <div class="label">当前轮次</div>
+            <div class="value">${inst.currentRound ?? 0}</div>
+            <div class="sub">杠杆 ${inst.leverage || '—'}x</div>
+        </div>
+        <div class="stat-card">
+            <div class="label">最后心跳</div>
+            <div class="value" style="font-size:16px">${relativeTime(inst.lastSeen)}</div>
+            <div class="sub">${inst.hostPort || '—'}</div>
+        </div>
+    `;
+}
+
+// ==================== 事件日志 ====================
+async function loadEvents(md5) {
+    try {
+        const events = await api(API + '/events?apiKeyMd5=' + encodeURIComponent(md5));
+        eventsCache[md5] = events || [];
+        renderEvents(events || []);
+    } catch (e) {
+        eventsCache[md5] = [];
+        renderEvents([]);
+    }
+}
+
+function renderEvents(events) {
+    const tbody = $('eventsBody');
+    $('eventCount').textContent = events.length + ' 条';
+
+    if (events.length === 0) {
+        tbody.innerHTML = `<tr><td colspan="3" style="text-align:center;padding:30px;color:var(--dim)">暂无事件记录</td></tr>`;
+        pnlData = [];
+        renderPnlChart();
+        return;
+    }
+
+    // 收集 PNL_SNAPSHOT 数据用于图表
+    pnlData = events
+        .filter(e => e.eventType === 'PNL_SNAPSHOT')
+        .map(e => {
+            let payload = {};
+            try { payload = JSON.parse(e.payloadJson || '{}'); } catch (_) {}
+            return {
+                time: e.eventTime || e.createTime,
+                cumulativePnl: parseFloat(payload.cumulativePnl || 0),
+                unrealizedPnl: parseFloat(payload.unrealizedPnl || 0),
+                totalEquity: parseFloat(payload.totalEquity || 0),
+                markPrice: parseFloat(payload.markPrice || 0)
+            };
+        })
+        .slice(-50);
+
+    tbody.innerHTML = events.slice(0, 100).map(e => {
+        let payloadStr = e.payloadJson || '';
+        // 截断显示
+        let shortPayload = payloadStr;
+        try {
+            const obj = JSON.parse(payloadStr);
+            const keys = Object.keys(obj);
+            shortPayload = keys.map(k => `${k}: ${obj[k]}`).join(', ');
+        } catch (_) {}
+        if (shortPayload.length > 80) shortPayload = shortPayload.substring(0, 80) + '...';
+
+        const ts = e.eventTime || (e.createTime ? new Date(e.createTime).getTime() : null);
+        return `
+        <tr>
+            <td class="relative-time" title="${new Date(ts).toLocaleString()}">${formatTime(ts)}</td>
+            <td><span class="event-tag ${e.eventType || ''}">${e.eventType || '—'}</span></td>
+            <td class="payload-preview" title="${payloadStr}">${shortPayload || '—'}</td>
+        </tr>`;
+    }).join('');
+
+    renderPnlChart();
+}
+
+// ==================== PNL 图表 ====================
+function renderPnlChart() {
+    const canvas = document.getElementById('pnlChart');
+    if (!canvas) return;
+
+    // 顶部摘要指标(当前值 + 较上个快照涨跌)
+    renderPnlSummary();
+
+    if (pnlChart) pnlChart.destroy();
+
+    if (pnlData.length === 0) {
+        const ctx = canvas.getContext('2d');
+        ctx.clearRect(0, 0, canvas.width, canvas.height);
+        ctx.fillStyle = '#6b7280';
+        ctx.font = '14px sans-serif';
+        ctx.textAlign = 'center';
+        ctx.fillText('暂无 PNL 数据(等待 PNL_SNAPSHOT 事件上报)', canvas.width / 2, canvas.height / 2);
+        return;
+    }
+
+    const labels = pnlData.map(d => formatTime(d.time));
+    const cumulative = pnlData.map(d => d.cumulativePnl);
+    const unrealized = pnlData.map(d => d.unrealizedPnl);
+    const total = pnlData.map(d => d.totalEquity);
+
+    // 面积渐变填充:顶部半透明 → 底部透明
+    function gradient(rgb, topAlpha) {
+        return (context) => {
+            const { ctx, chartArea } = context.chart;
+            if (!chartArea) return `rgba(${rgb},${topAlpha})`;
+            const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
+            g.addColorStop(0, `rgba(${rgb},${topAlpha})`);
+            g.addColorStop(1, `rgba(${rgb},0)`);
+            return g;
+        };
+    }
+
+    pnlChart = new Chart(canvas, {
+        type: 'line',
+        data: {
+            labels,
+            datasets: [
+                {
+                    label: '已实现盈亏',
+                    data: cumulative,
+                    borderColor: '#16a34a',
+                    backgroundColor: gradient('22,163,74', 0.22),
+                    fill: true,
+                    tension: 0.3,
+                    pointRadius: 2,
+                    pointBackgroundColor: '#16a34a',
+                    pointBorderColor: '#ffffff',
+                    pointBorderWidth: 1,
+                    borderWidth: 1.8
+                },
+                {
+                    label: '未实现盈亏',
+                    data: unrealized,
+                    borderColor: '#d97706',
+                    backgroundColor: gradient('217,119,6', 0.16),
+                    fill: true,
+                    tension: 0.3,
+                    pointRadius: 0,
+                    borderWidth: 1.5,
+                    borderDash: [4, 3]
+                },
+                {
+                    label: '总权益',
+                    data: total,
+                    borderColor: '#3b82f6',
+                    backgroundColor: gradient('59,130,246', 0.10),
+                    fill: true,
+                    tension: 0.3,
+                    pointRadius: 0,
+                    borderWidth: 2,
+                    yAxisID: 'y1'
+                }
+            ]
+        },
+        options: {
+            responsive: true,
+            maintainAspectRatio: false,
+            interaction: { intersect: false, mode: 'index' },
+            plugins: {
+                legend: {
+                    labels: { color: '#6b7280', font: { size: 11 }, usePointStyle: true, padding: 15, boxWidth: 8 }
+                },
+                tooltip: {
+                    backgroundColor: '#ffffff',
+                    borderColor: '#e3e8f0',
+                    borderWidth: 1,
+                    titleColor: '#1f2937',
+                    bodyColor: '#1f2937',
+                    titleFont: { size: 12 },
+                    bodyFont: { size: 12 },
+                    padding: 10,
+                    callbacks: {
+                        label: (ctx) => {
+                            const isEquity = ctx.dataset.label === '总权益';
+                            const v = ctx.parsed.y;
+                            const txt = isEquity
+                                ? v.toFixed(2)
+                                : (v >= 0 ? '+' : '') + v.toFixed(4);
+                            return ctx.dataset.label + ': ' + txt;
+                        }
+                    }
+                }
+            },
+            scales: {
+                x: {
+                    ticks: { color: '#6b7280', font: { size: 9 }, maxTicksLimit: 10, maxRotation: 0 },
+                    grid: { color: 'rgba(0,0,0,0.06)' }
+                },
+                y: {
+                    type: 'linear',
+                    display: true,
+                    position: 'left',
+                    ticks: { color: '#6b7280', font: { size: 10 }, callback: v => v.toFixed(4) },
+                    grid: { color: 'rgba(0,0,0,0.06)' }
+                },
+                y1: {
+                    type: 'linear',
+                    display: true,
+                    position: 'right',
+                    ticks: { color: '#3b82f6', font: { size: 10 }, callback: v => v.toFixed(2) },
+                    grid: { drawOnChartArea: false }
+                }
+            }
+        }
+    });
+}
+
+// 盈亏趋势顶部摘要:当前值 + 较上个快照涨跌
+function renderPnlSummary() {
+    const box = document.getElementById('pnlSummary');
+    if (!box) return;
+    if (pnlData.length === 0) {
+        box.innerHTML = '';
+        return;
+    }
+    const cur = pnlData[pnlData.length - 1];
+    const prev = pnlData.length > 1 ? pnlData[pnlData.length - 2] : null;
+
+    const items = [
+        { label: '已实现盈亏', key: 'cumulativePnl', signed: true, digits: 4, fmt: v => (v >= 0 ? '+' : '') + v.toFixed(4) },
+        { label: '未实现盈亏', key: 'unrealizedPnl', signed: true, digits: 4, fmt: v => (v >= 0 ? '+' : '') + v.toFixed(4) },
+        { label: '总权益', key: 'totalEquity', signed: false, digits: 2, fmt: v => v.toFixed(2) },
+        { label: '标记价', key: 'markPrice', signed: false, digits: 4, fmt: v => v.toFixed(4) }
+    ];
+
+    box.innerHTML = items.map(it => {
+        const v = cur[it.key];
+        const cls = (it.signed && v !== 0) ? (v > 0 ? 'positive' : 'negative') : '';
+        let deltaHtml = '<div class="s-delta">—</div>';
+        if (prev != null) {
+            const d = v - prev[it.key];
+            if (d !== 0) {
+                const dCls = d > 0 ? 'positive' : 'negative';
+                deltaHtml = `<div class="s-delta ${dCls}">${d > 0 ? '▲' : '▼'} ${Math.abs(d).toFixed(it.digits)}</div>`;
+            } else {
+                deltaHtml = '<div class="s-delta">— 持平</div>';
+            }
+        }
+        return `<div class="pnl-summary-item">
+            <div class="s-label">${it.label}</div>
+            <div class="s-value ${cls}">${it.fmt(v)}</div>
+            ${deltaHtml}
+        </div>`;
+    }).join('');
+}
+
+// ==================== 策略参数 ====================
+// 从事件流提取最近一次 STRATEGY_START 的完整参数快照(toParamsMap 含全部 18 个字段)
+function extractConfigFromEvents(events) {
+    for (let i = events.length - 1; i >= 0; i--) {
+        if (events[i].eventType === 'STRATEGY_START') {
+            try {
+                return JSON.parse(events[i].payloadJson || '{}');
+            } catch (_) {
+                return null;
+            }
+        }
+    }
+    return null;
+}
+
+// 默认参数(与后端 GateConfigDTO.defaultsFor() 保持一致)
+const DEFAULT_CONFIG = {
+    gridRate: 0.005,
+    expectedProfit: 0.15,
+    maxLoss: 1.5,
+    baseQuantity: '2',
+    quantity: '2',
+    maxPositionSize: 4,
+    stopLossCount: 0,
+    takeProfitGridSpan: 2,
+    rounds: 0,
+    stopLossCountMode: 'dual',
+    addPositionInterval: 3,
+    addPositionQuantity: 1,
+    maxPositionPerSide: 0,
+    addPositionStartThreshold: 1,
+    placeExcessTakeProfit: false,
+    priceDriveEnabled: true,
+};
+
+async function loadConfig(md5) {
+    // 优先:DB strategy_status(保存的配置已持久化,STRATEGY_START 也会写入这里,始终是最新值)
+    try {
+        const status = await api(API + '/strategy-status?apiKeyMd5=' + encodeURIComponent(md5));
+        if (status) {
+            // DB 字段 totalRounds 映射为前端 key rounds
+            if (status.totalRounds != null && status.rounds == null) status.rounds = status.totalRounds;
+            renderConfig(status);
+            return;
+        }
+    } catch (e) {
+        // 请求失败 → 回退事件流 / 默认值
+    }
+    // 回退:最近 STRATEGY_START 事件 payload(字段最全,含 rounds/expectedProfit/maxLoss 等)
+    const config = extractConfigFromEvents(eventsCache[md5] || []);
+    if (config) {
+        renderConfig(config);
+        return;
+    }
+    // 兜底:默认值填充,方便用户预填后启动
+    renderConfig(DEFAULT_CONFIG);
+}
+
+// 可编辑的策略参数字段(jtype 标记 Java 类型用于正确序列化,group 用于分组渲染)
+const EDITABLE_FIELDS = [
+    { key: 'gridRate', label: '网格间距比例', type: 'number', step: '0.0001', placeholder: '0.005', jtype: 'decimal', group: 'grid', tip: '短基价 × 该比例 = 绝对步长' },
+    { key: 'baseQuantity', label: '基底开仓张数', type: 'number', placeholder: '2', jtype: 'string', group: 'grid' },
+    { key: 'quantity', label: '每次下单张数', type: 'number', placeholder: '2', jtype: 'string', group: 'grid' },
+    { key: 'maxPositionSize', label: '最大持仓张数', type: 'number', placeholder: '4', jtype: 'int', group: 'grid' },
+    { key: 'takeProfitGridSpan', label: '止盈网格跨度', type: 'number', placeholder: '2', jtype: 'int', group: 'grid' },
+
+    { key: 'expectedProfit', label: '预期收益 (USDT)', type: 'number', step: '0.01', placeholder: '0.15', jtype: 'decimal', group: 'risk' },
+    { key: 'maxLoss', label: '最大亏损 (USDT)', type: 'number', step: '0.01', placeholder: '1.5', jtype: 'decimal', group: 'risk' },
+    { key: 'stopLossCount', label: '止损阶梯次数', type: 'number', placeholder: '0', jtype: 'int', group: 'risk', tip: '0 = 禁用阶梯止损' },
+
+    { key: 'stopLossCountMode', label: '止损统计方式', type: 'select', options: [{ v: 'dual', l: '双向统一统计' }, { v: 'single', l: '单向分别统计' }], group: 'addon' },
+    { key: 'addPositionInterval', label: '加仓间隔 (次)', type: 'number', placeholder: '3', jtype: 'int', group: 'addon', tip: '每隔 N 次止损触发一次加仓' },
+    { key: 'addPositionQuantity', label: '加仓数量 (张)', type: 'number', placeholder: '1', jtype: 'int', group: 'addon' },
+    { key: 'maxPositionPerSide', label: '单边最大仓位 (0=不限)', type: 'number', placeholder: '0', jtype: 'int', group: 'addon' },
+    { key: 'addPositionStartThreshold', label: '加仓启动阈值 (次)', type: 'number', placeholder: '1', jtype: 'int', group: 'addon', tip: '前 N 次止损不触发加仓' },
+
+    { key: 'rounds', label: '运行轮数 (0=不限)', type: 'number', placeholder: '0', jtype: 'int', group: 'run', tip: '盈利重启达此轮数后停止' },
+    { key: 'placeExcessTakeProfit', label: '超额止盈', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'run' },
+    { key: 'priceDriveEnabled', label: '价格驱动', type: 'select', options: [{ v: 'true', l: '开启' }, { v: 'false', l: '关闭' }], group: 'run' },
+];
+
+// 分组定义(渲染顺序)
+const CONFIG_GROUPS = [
+    { id: 'grid', name: '📐 网格参数' },
+    { id: 'risk', name: '🛡️ 风控参数' },
+    { id: 'addon', name: '📈 加仓 & 止损' },
+    { id: 'run', name: '⚡ 运行控制' },
+];
+
+// 字段是否处于"已修改"状态(用于高亮)
+const modifiedFields = new Set();
+
+function fieldHtml(f) {
+    const inputId = 'cfg_' + f.key;
+    let inner;
+    if (f.type === 'select') {
+        inner = `<select id="${inputId}" onchange="markModified('${f.key}')">${f.options.map(o => `<option value="${o.v}">${o.l}</option>`).join('')}</select>`;
+    } else {
+        inner = `<input id="${inputId}" type="${f.type}" placeholder="${f.placeholder || ''}" ${f.step ? 'step="' + f.step + '"' : ''} oninput="markModified('${f.key}')">`;
+    }
+    const tip = f.tip ? `<div class="field-tip">${f.tip}</div>` : '';
+    return `<div class="form-group" id="group_${f.key}"><label>${f.label}</label>${inner}${tip}</div>`;
+}
+
+function renderConfig(status) {
+    const form = $('configForm');
+    if (!status) {
+        form.innerHTML = `<div style="padding:20px;color:var(--dim);text-align:center">暂无策略参数</div>`;
+        return;
+    }
+    // 1. 只读状态条
+    renderConfigStatusBar();
+
+    // 2. 分组渲染表单
+    let html = '';
+    for (const g of CONFIG_GROUPS) {
+        const fields = EDITABLE_FIELDS.filter(f => f.group === g.id);
+        if (fields.length === 0) continue;
+        html += `<div class="config-section">
+            <div class="config-section-title">${g.name}<span class="count">${fields.length} 项</span></div>
+            <div class="form-grid">${fields.map(fieldHtml).join('')}</div>
+        </div>`;
+    }
+    form.innerHTML = html;
+
+    // 3. 填充当前值
+    for (const f of EDITABLE_FIELDS) {
+        const el = document.getElementById('cfg_' + f.key);
+        if (!el) continue;
+        let val = status[f.key];
+        if (val === true) val = 'true';
+        else if (val === false) val = 'false';
+        else if (val == null || val === '') val = '';
+        else val = String(val);
+        el.value = val;
+    }
+    modifiedFields.clear();
+}
+
+function renderConfigStatusBar() {
+    const bar = $('configStatusBar');
+    const inst = instances[selectedMd5];
+    if (!inst) {
+        bar.innerHTML = '';
+        return;
+    }
+    const pnl = fmtPnl(inst.cumulativePnl);
+    const items = [
+        { label: '合约', value: inst.contract || '—', cls: '' },
+        { label: '状态', value: inst.state || '—', cls: 'dim' },
+        { label: '杠杆', value: (inst.leverage || '—') + 'x', cls: '' },
+        { label: '当前轮次', value: inst.currentRound ?? 0, cls: '' },
+        { label: '累计盈亏', value: pnl.text, cls: pnl.cls },
+        { label: '初始本金', value: fmtNum(inst.principal), cls: '' },
+    ];
+    bar.innerHTML = items.map(i =>
+        `<div class="config-status-item"><div class="label">${i.label}</div><div class="value ${i.cls}">${i.value}</div></div>`
+    ).join('');
+}
+
+function markModified(key) {
+    modifiedFields.add(key);
+    const g = document.getElementById('group_' + key);
+    if (g) g.classList.add('modified');
+}
+
+function resetConfig() {
+    if (!selectedMd5) return;
+    // 重新从当前数据源加载,覆盖用户未保存的编辑
+    loadConfig(selectedMd5);
+    toast('已重置为当前生效参数', 'success');
+}
+
+async function saveConfig() {
+    if (!selectedMd5) return;
+    const params = {};
+    for (const f of EDITABLE_FIELDS) {
+        const el = document.getElementById('cfg_' + f.key);
+        if (!el) continue;
+        const raw = el.value;
+        // 跳过空值:缺失字段在 DTO 中为 null,buildFromDTO 会用 nvl() 填充默认值
+        if (raw === '' || raw == null) continue;
+
+        if (f.jtype === 'string') {
+            params[f.key] = raw;
+        } else if (f.jtype === 'int') {
+            params[f.key] = parseInt(raw, 10);
+        } else if (f.jtype === 'decimal') {
+            params[f.key] = parseFloat(raw);
+        } else {
+            // select 等保持不变
+            params[f.key] = raw;
+        }
+    }
+    try {
+        const msg = await api(API + '/config?apiKeyMd5=' + encodeURIComponent(selectedMd5), {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(params)
+        });
+        toast(msg || '参数已保存,点击「启动」使新配置生效', 'success');
+        // 保存成功后清除修改高亮
+        modifiedFields.clear();
+        document.querySelectorAll('.form-group.modified').forEach(g => g.classList.remove('modified'));
+    } catch (e) {
+        toast(e.message, 'error');
+    }
+}
+
+// ==================== 启停操作 ====================
+function showConfirm(action) {
+    if (!selectedMd5) return;
+    pendingAction = action;
+    const verb = action === 'start' ? '启动' : '停止';
+    $('modalTitle').textContent = `确认${verb}`;
+    $('modalMsg').textContent = `确定要${verb}实例 ${selectedMd5.substring(0, 12)}... 的策略吗?`;
+    $('modalConfirmBtn').textContent = verb;
+    $('modalConfirmBtn').className = 'btn ' + (action === 'start' ? 'btn-success' : 'btn-danger');
+    $('modalOverlay').classList.add('show');
+}
+
+function closeModal() {
+    $('modalOverlay').classList.remove('show');
+    pendingAction = null;
+}
+
+async function confirmAction() {
+    if (!pendingAction || !selectedMd5) return;
+    const action = pendingAction;
+    closeModal();
+
+    try {
+        const msg = await api(API + '/' + action + '?apiKeyMd5=' + encodeURIComponent(selectedMd5), { method: 'POST' });
+        toast(msg || '指令已发送', 'success');
+        // 延迟刷新等待 ACK
+        setTimeout(refreshInstanceList, 2000);
+    } catch (e) {
+        toast(e.message, 'error');
+    }
+}
+
+// ==================== 轮询 ====================
+async function poll() {
+    await refreshInstanceList();
+    // 如果有选中实例,刷新详情(full=false,不重渲染参数表单,避免清空用户编辑)
+    if (selectedMd5 && instances[selectedMd5]) {
+        await loadInstanceDetail(selectedMd5, false);
+    }
+}
+
+// ==================== 初始化 ====================
+function startPolling() {
+    poll(); // 立即执行一次
+    pollingTimer = setInterval(poll, 5000);
+}
+
+// 倒计时显示
+setInterval(() => {
+    const badge = $('refreshBadge');
+    if (badge) badge.textContent = '⏳ 5s';
+    setTimeout(() => { if (badge) badge.textContent = '⏳ 4s'; }, 1000);
+    setTimeout(() => { if (badge) badge.textContent = '⏳ 3s'; }, 2000);
+    setTimeout(() => { if (badge) badge.textContent = '⏳ 2s'; }, 3000);
+    setTimeout(() => { if (badge) badge.textContent = '⏳ 1s'; }, 4000);
+}, 5000);
+
+startPolling();
+</script>
+</body>
+</html>

--
Gitblit v1.9.1