Administrator
2026-08-13 a23f23570935850192099133509e90c33769d26c
feat(station): 添加策略监控中心功能

- 新增 CmdProducer 类用于发送指令到目标 JAR 实例
- 实现 GateMessageConsumer 消费心跳、ACK 和策略事件消息
- 创建 InstanceRegistry 注册表管理 JAR 实例的上线、下线和状态更新
- 开发 station-dashboard.html 实时监控仪表板界面
- 集成 RabbitMQ 消息队列实现指令精准路由功能
- 实现定时清理超时实例的机制确保注册表准确性
14 files added
1 files modified
1959 ■■■■■ changed files
src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java 1 ●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java 69 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/controller/StationController.java 72 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java 23 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java 16 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java 33 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java 93 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java 61 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java 85 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/service/StationService.java 36 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java 228 ●●●●● patch | view | raw | blame | history
src/main/resources/db/station-schema.sql 71 ●●●●● patch | view | raw | blame | history
src/main/resources/mapper/station/StrategyEventLogDao.xml 16 ●●●●● patch | view | raw | blame | history
src/main/resources/mapper/station/StrategyStatusDao.xml 9 ●●●●● patch | view | raw | blame | history
src/main/resources/static/station-dashboard.html 1146 ●●●●● patch | view | raw | blame | history
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());
    }
src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java
New file
@@ -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);
        }
    }
}
src/main/java/com/xcong/excoin/modules/station/controller/StationController.java
New file
@@ -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("参数已保存,请手动点击「启动」使新配置生效");
    }
}
src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java
New file
@@ -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);
}
src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java
New file
@@ -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);
}
src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java
New file
@@ -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;
}
src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java
New file
@@ -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;
}
src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java
New file
@@ -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);
    }
}
src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java
New file
@@ -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;
        });
    }
}
src/main/java/com/xcong/excoin/modules/station/service/StationService.java
New file
@@ -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);
}
src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java
New file
@@ -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());
        }
    }
}
src/main/resources/db/station-schema.sql
New file
@@ -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`;
src/main/resources/mapper/station/StrategyEventLogDao.xml
New file
@@ -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>
src/main/resources/mapper/station/StrategyStatusDao.xml
New file
@@ -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>
src/main/resources/static/station-dashboard.html
New file
@@ -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>