| src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/com/xcong/excoin/modules/gateApi/GateConfigController.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/com/xcong/excoin/modules/gateApi/GateLogBuffer.java | ●●●●● patch | view | raw | blame | history | |
| src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java | ●●●●● patch | view | raw | blame | history | |
| src/main/resources/static/gate-config.html | ●●●●● patch | view | raw | blame | history |
src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
@@ -53,6 +53,8 @@ .antMatchers("/api/member/getAppVersionInfo").permitAll() .antMatchers("/api/orderCoin/searchSymbolResultList").permitAll() .antMatchers("/api/orderCoin/findCollect").permitAll() .antMatchers("/api/gate/**").permitAll() .antMatchers("/gate-config.html").permitAll() .anyRequest().authenticated() .and().apply(securityConfiguereAdapter()); } src/main/java/com/xcong/excoin/modules/gateApi/GateConfigController.java
New file @@ -0,0 +1,133 @@ package com.xcong.excoin.modules.gateApi; import com.xcong.excoin.common.response.Result; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; /** * Gate 配置控制面板 REST 接口。 */ @Slf4j @RestController @RequestMapping("/api/gate") @Api(tags = "Gate配置管理") public class GateConfigController { @Autowired private GateWebSocketClientManager clientManager; @Autowired private GateConfigPersistenceService persistenceService; @Autowired private GateLogBuffer logBuffer; /** * 根据 apiKey 加载配置,不存在则返回空对象让前端用默认值填充。 */ @PostMapping("/config/load") @ApiOperation("根据 apiKey 加载配置") public Result loadConfig(@RequestParam String apiKey) { if (apiKey == null || apiKey.trim().isEmpty()) { return Result.fail("apiKey 不能为空"); } GateConfigDTO dto = persistenceService.load(apiKey); if (dto == null) { dto = new GateConfigDTO(); } dto.setApiKey(apiKey); return Result.ok(dto); } /** * 获取当前运行中的配置。 */ @GetMapping("/config") @ApiOperation("获取当前运行策略的配置") public Result getConfig() { GateConfig config = clientManager.getConfig(); if (config == null) { return Result.fail("策略未初始化"); } return Result.ok(GateConfigDTO.from(config)); } /** * 保存配置到文件。 */ @PostMapping("/config") @ApiOperation("保存配置") public Result saveConfig(@RequestBody GateConfigDTO dto) { if (dto.getApiKey() == null || dto.getApiKey().trim().isEmpty()) { return Result.fail("apiKey 不能为空"); } persistenceService.save(dto.getApiKey(), dto); return Result.ok("配置已保存,需要重启策略才能生效"); } /** * 重启网格策略:停止 → 用指定 apiKey 的配置重新初始化。 */ @PostMapping("/restart") @ApiOperation("重启策略") public Result restart(@RequestParam String apiKey) { if (apiKey == null || apiKey.trim().isEmpty()) { return Result.fail("apiKey 不能为空"); } try { GateConfigDTO dto = persistenceService.load(apiKey); if (dto == null) { return Result.fail("未找到该 apiKey 的配置文件,请先保存配置"); } GateConfig newConfig = persistenceService.buildFromDTO(dto, apiKey); clientManager.restartWithConfig(newConfig); logBuffer.info("策略已使用 apiKey=" + maskKey(apiKey) + " 的配置重启"); return Result.ok("策略已使用新配置重启"); } catch (Exception e) { log.error("[GateConfig] 重启策略失败", e); return Result.fail("重启失败: " + e.getMessage()); } } /** * 获取策略运行状态。 */ @GetMapping("/status") @ApiOperation("获取策略状态") public Result getStatus() { GateGridTradeService service = clientManager.getGridTradeService(); if (service == null) { return Result.ok("STOPPED"); } return Result.ok(service.isStrategyActive() ? "ACTIVE" : "STOPPED"); } /** * 停止策略。 */ @PostMapping("/stop") @ApiOperation("停止策略") public Result stop() { clientManager.stopStrategy(); return Result.ok("策略已停止"); } /** * SSE 日志流。 */ @GetMapping(value = "/logs", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @ApiOperation("实时日志 SSE 流") public SseEmitter streamLogs() { return logBuffer.subscribe(); } private static String maskKey(String key) { if (key == null || key.length() <= 8) return "***"; return key.substring(0, 4) + "****" + key.substring(key.length() - 4); } } src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java
New file @@ -0,0 +1,80 @@ package com.xcong.excoin.modules.gateApi; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.math.BigDecimal; /** * Gate 策略配置 DTO,用于 Web 控制面板参数传递。 * apiSecret 不暴露给前端。 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class GateConfigDTO { /** API Key */ private String apiKey; /** 合约名称,如 ETH_USDT */ private String contract; /** 杠杆倍数 */ private String leverage; /** 保证金模式:cross / isolated */ private String marginMode; /** 持仓模式:single / dual */ private String positionMode; /** 网格间距比例 */ private BigDecimal gridRate; /** 预期收益(USDT) */ private BigDecimal expectedProfit; /** 最大亏损(USDT) */ private BigDecimal maxLoss; /** 基底开仓张数 */ private String baseQuantity; /** 每次下单张数 */ private String quantity; /** 最大持仓张数(单方向),0=不限制 */ private int maxPositionSize; /** 止损阶梯次数,0=禁用 */ private int stopLossCount; /** 策略重启跨度阈值,0=禁用 */ private int restartGridSpan; /** 价格精度 */ private int priceScale; /** 合约乘数 */ private BigDecimal contractMultiplier; /** 未实现盈亏计价模式:LAST_PRICE / MARK_PRICE */ private String unrealizedPnlPriceMode; /** 是否为生产环境 */ private boolean isProduction; /** * 从 GateConfig 构建 DTO(不暴露 apiSecret)。 */ public static GateConfigDTO from(GateConfig config) { return GateConfigDTO.builder() .apiKey(config.getApiKey()) .contract(config.getContract()) .leverage(config.getLeverage()) .marginMode(config.getMarginMode()) .positionMode(config.getPositionMode()) .gridRate(config.getGridRate()) .expectedProfit(config.getExpectedProfit()) .maxLoss(config.getMaxLoss()) .baseQuantity(config.getBaseQuantity()) .quantity(config.getQuantity()) .maxPositionSize(config.getMaxPositionSize()) .stopLossCount(config.getStopLossCount()) .restartGridSpan(config.getRestartGridSpan()) .priceScale(config.getPriceScale()) .contractMultiplier(config.getContractMultiplier()) .unrealizedPnlPriceMode(config.getUnrealizedPnlPriceMode() != null ? config.getUnrealizedPnlPriceMode().name() : "LAST_PRICE") .isProduction(config.isProduction()) .build(); } } src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java
New file @@ -0,0 +1,135 @@ package com.xcong.excoin.modules.gateApi; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.io.File; import java.io.IOException; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; /** * Gate 配置持久化服务 — 按 apiKey 分文件存储。 * 文件路径: config/gate-config-{md5(apiKey)}.json */ @Slf4j @Service public class GateConfigPersistenceService { private static final String CONFIG_DIR = "config"; private static final String CONFIG_PREFIX = "gate-config-"; private static final String JSON_SUFFIX = ".json"; private final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); @PostConstruct public void ensureConfigDir() { File dir = new File(CONFIG_DIR); if (!dir.exists()) { dir.mkdirs(); } } /** * 确保指定 apiKey 的配置文件存在,不存在则用默认值创建。 */ public GateConfigDTO ensureExists(String apiKey, GateConfigDTO defaults) { GateConfigDTO existing = load(apiKey); if (existing != null) { return existing; } save(apiKey, defaults); log.info("[GateConfig] 为 apiKey={} 创建默认配置文件", maskKey(apiKey)); return defaults; } /** * 保存配置。 */ public void save(String apiKey, GateConfigDTO dto) { try { mapper.writeValue(configFile(apiKey), dto); log.info("[GateConfig] 配置已保存, apiKey={}", maskKey(apiKey)); } catch (IOException e) { log.error("[GateConfig] 保存配置失败, apiKey={}", maskKey(apiKey), e); throw new RuntimeException("保存配置失败", e); } } /** * 加载配置,文件不存在返回 null。 */ public GateConfigDTO load(String apiKey) { File file = configFile(apiKey); if (!file.exists()) { return null; } try { return mapper.readValue(file, GateConfigDTO.class); } catch (IOException e) { log.error("[GateConfig] 读取配置失败, apiKey={}", maskKey(apiKey), e); return null; } } /** * 从 DTO 构建 GateConfig(补齐 apiSecret)。 */ public GateConfig buildFromDTO(GateConfigDTO dto, String apiKey) { return GateConfig.builder() .apiKey(apiKey) .apiSecret(GateWebSocketClientManager.DEFAULT_API_SECRET) // 不可调参数 — 参考 GateWebSocketClientManager 顶部常量 .contract(GateWebSocketClientManager.DEFAULT_CONTRACT) .leverage(GateWebSocketClientManager.DEFAULT_LEVERAGE) .marginMode(GateWebSocketClientManager.DEFAULT_MARGIN_MODE) .positionMode(GateWebSocketClientManager.DEFAULT_POSITION_MODE) .restartGridSpan(GateWebSocketClientManager.DEFAULT_RESTART_GRID_SPAN) .priceScale(GateWebSocketClientManager.DEFAULT_PRICE_SCALE) .contractMultiplier(new BigDecimal(GateWebSocketClientManager.DEFAULT_CONTRACT_MULTIPLIER)) .unrealizedPnlPriceMode(GateConfig.PnLPriceMode.valueOf(GateWebSocketClientManager.DEFAULT_PNL_MODE)) .isProduction(true) .reopenMaxRetries(GateWebSocketClientManager.DEFAULT_REOPEN_MAX_RETRIES) // 可调参数 — 从 DTO 读取 .gridRate(nvl(dto.getGridRate(), new BigDecimal("0.005"))) .expectedProfit(nvl(dto.getExpectedProfit(), new BigDecimal("0.15"))) .maxLoss(nvl(dto.getMaxLoss(), new BigDecimal("1.5"))) .baseQuantity(nvl(dto.getBaseQuantity(), "2")) .quantity(nvl(dto.getQuantity(), "2")) .maxPositionSize(dto.getMaxPositionSize()) .stopLossCount(dto.getStopLossCount()) .build(); } private File configFile(String apiKey) { return new File(CONFIG_DIR, CONFIG_PREFIX + md5(apiKey) + JSON_SUFFIX); } 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()); } } private static String maskKey(String key) { if (key == null || key.length() <= 8) return "***"; return key.substring(0, 4) + "****" + key.substring(key.length() - 4); } private static <T> T nvl(T value, T fallback) { return value != null ? value : fallback; } } src/main/java/com/xcong/excoin/modules/gateApi/GateLogBuffer.java
New file @@ -0,0 +1,86 @@ package com.xcong.excoin.modules.gateApi; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * Gate 策略日志环形缓冲区,支持 SSE 推送到前端。 * 最多保留 500 条日志,新客户端连接时先推送历史再持续推新日志。 */ @Slf4j @Service public class GateLogBuffer { private static final int MAX_ENTRIES = 500; private static final DateTimeFormatter TF = DateTimeFormatter.ofPattern("HH:mm:ss"); private final List<String> entries = Collections.synchronizedList(new ArrayList<>(MAX_ENTRIES)); private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>(); /** * 推送一条日志到所有 SSE 客户端。 */ public void push(String level, String message) { String line = String.format("[%s] %-5s %s", TF.format(LocalTime.now()), level, message); synchronized (entries) { if (entries.size() >= MAX_ENTRIES) { entries.remove(0); } entries.add(line); } // 推送给所有 SSE 客户端 for (SseEmitter emitter : emitters) { try { emitter.send(SseEmitter.event().name("log").data(line)); } catch (IOException e) { emitters.remove(emitter); } } } /** info 级别日志 */ public void info(String message) { push("INFO", message); } /** warn 级别日志 */ public void warn(String message) { push("WARN", message); } /** error 级别日志 */ public void error(String message) { push("ERROR", message); } /** * 创建 SSE 连接,先推送历史再持续推送新日志。 */ public SseEmitter subscribe() { SseEmitter emitter = new SseEmitter(0L); // 无超时 emitters.add(emitter); // 推送历史 List<String> history; synchronized (entries) { history = new ArrayList<>(entries); } for (String line : history) { try { emitter.send(SseEmitter.event().name("log").data(line)); } catch (IOException e) { emitters.remove(emitter); return emitter; } } emitter.onCompletion(() -> emitters.remove(emitter)); emitter.onTimeout(() -> emitters.remove(emitter)); emitter.onError(e -> emitters.remove(emitter)); return emitter; } } src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java
@@ -5,6 +5,7 @@ import com.xcong.excoin.modules.gateApi.wsHandler.handler.PositionClosesChannelHandler; import com.xcong.excoin.modules.gateApi.wsHandler.handler.PositionsChannelHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; @@ -44,6 +45,27 @@ @Component public class GateWebSocketClientManager { // ==================== 修改配置只需改这里 =================== static final String DEFAULT_API_KEY = "08d6108fc29378fe64d6f0e89745b3fb"; static final String DEFAULT_API_SECRET = "4d16e40117477a2344f6fd5872c5b075dc9664a599e65422d3e6791ff64379f1"; // 不可调参数 static final String DEFAULT_CONTRACT = "ETH_USDT"; static final String DEFAULT_LEVERAGE = "100"; static final String DEFAULT_MARGIN_MODE = "CROSS"; static final String DEFAULT_POSITION_MODE = "dual"; static final int DEFAULT_RESTART_GRID_SPAN = 2; static final int DEFAULT_PRICE_SCALE = 2; static final String DEFAULT_CONTRACT_MULTIPLIER = "0.01"; static final String DEFAULT_PNL_MODE = "LAST_PRICE"; static final int DEFAULT_REOPEN_MAX_RETRIES = 3; // ========================================================== @Autowired private GateConfigPersistenceService persistenceService; @Autowired private GateLogBuffer logBuffer; /** WebSocket 连接管理器 */ private GateKlineWebSocketClient wsClient; /** 网格交易策略服务 */ @@ -53,53 +75,35 @@ @PostConstruct public void init() { log.info("[管理器] 开始初始化..."); logBuffer.info("[管理器] 加载配置,策略待手动启动..."); try { //实盘 config = GateConfig.builder() .apiKey("08d6108fc29378fe64d6f0e89745b3fb") .apiSecret("4d16e40117477a2344f6fd5872c5b075dc9664a599e65422d3e6791ff64379f1") .contract("ETH_USDT") .leverage("100") .marginMode("CROSS") .positionMode("dual") .gridRate(new BigDecimal("0.0025")) .expectedProfit(new BigDecimal("2.5")) // 优先读取持久化配置,不存在则用默认值创建文件 GateConfigDTO saved = persistenceService.load(DEFAULT_API_KEY); if (saved != null) { config = persistenceService.buildFromDTO(saved, DEFAULT_API_KEY); logBuffer.info("[管理器] 已加载持久化配置, apiKey=" + mask(DEFAULT_API_KEY)); } else { // 构建默认配置 DTO(只有 7 项可调参数) GateConfigDTO defaults = GateConfigDTO.builder() .apiKey(DEFAULT_API_KEY) .gridRate(new BigDecimal("0.005")) .expectedProfit(new BigDecimal("0.15")) .maxLoss(new BigDecimal("1.5")) .baseQuantity("1") .quantity("1") .restartGridSpan(10) .stopLossCount(2) .maxPositionSize(2) .priceScale(2) .contractMultiplier(new BigDecimal("0.01")) .unrealizedPnlPriceMode(GateConfig.PnLPriceMode.LAST_PRICE) .isProduction(true) .reopenMaxRetries(3) .baseQuantity("2") .quantity("2") .maxPositionSize(4) .stopLossCount(0) .build(); // 确保配置文件存在 persistenceService.ensureExists(DEFAULT_API_KEY, defaults); config = persistenceService.buildFromDTO(defaults, DEFAULT_API_KEY); logBuffer.info("[管理器] 已创建默认配置文件"); } // 1. 初始化交易服务:查用户ID → 切持仓模式 → 清条件单 → 平已有仓位 → 设杠杆 gridTradeService = new GateGridTradeService(config); gridTradeService.init(); // 2. 创建 WS 客户端并注册频道处理器 wsClient = new GateKlineWebSocketClient(config.getWsUrl()); wsClient.addChannelHandler(new CandlestickChannelHandler(config.getContract(), gridTradeService)); wsClient.addChannelHandler(new PositionsChannelHandler( config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService)); wsClient.addChannelHandler(new PositionClosesChannelHandler( config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService)); wsClient.addChannelHandler(new AutoOrdersChannelHandler( config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService)); gridTradeService.setWsClient(wsClient); wsClient.init(); log.info("[管理器] WS已连接, 已注册 4 个频道处理器"); // 3. 激活策略,等待首根 K 线触发基底双开 gridTradeService.startGrid(); logBuffer.info("[管理器] 配置就绪,请通过控制面板点击「重启策略」启动"); } catch (Exception e) { log.error("[管理器] 初始化失败", e); logBuffer.error("[管理器] 配置加载失败: " + e.getMessage()); } } @@ -126,4 +130,60 @@ * @return 网格交易策略服务实例 */ public GateGridTradeService getGridTradeService() { return gridTradeService; } /** * @return 当前生效的配置 */ public GateConfig getConfig() { return config; } /** * 使用新配置重启策略:停止旧策略 → 重建 WS → 重新 init + startGrid。 */ public void restartWithConfig(GateConfig newConfig) { logBuffer.info("[管理器] 开始策略重启..."); // 1. 停止旧策略 + 断开旧 WS if (gridTradeService != null) { gridTradeService.stopGrid(); } if (wsClient != null) { wsClient.destroy(); } // 2. 使用新配置重建 this.config = newConfig; this.gridTradeService = new GateGridTradeService(config); gridTradeService.init(); // 3. 重建 WS 客户端并重新注册 Handler this.wsClient = new GateKlineWebSocketClient(config.getWsUrl()); wsClient.addChannelHandler(new CandlestickChannelHandler(config.getContract(), gridTradeService)); wsClient.addChannelHandler(new PositionsChannelHandler( config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService)); wsClient.addChannelHandler(new PositionClosesChannelHandler( config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService)); wsClient.addChannelHandler(new AutoOrdersChannelHandler( config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService)); gridTradeService.setWsClient(wsClient); wsClient.init(); logBuffer.info("[管理器] WS已重连, Handler 已重新绑定"); // 4. 启动策略 gridTradeService.startGrid(); logBuffer.info("[管理器] 策略已使用新配置重启完成"); } /** * 停止策略:取消所有条件单 → 停止网格 → 断开 WS。 */ public void stopStrategy() { logBuffer.info("[管理器] 停止策略..."); if (gridTradeService != null) { gridTradeService.stopGrid(); } if (wsClient != null) { wsClient.destroy(); } logBuffer.info("[管理器] 策略已停止"); } private static String mask(String key) { if (key == null || key.length() <= 8) return "***"; return key.substring(0, 4) + "****" + key.substring(key.length() - 4); } } src/main/resources/static/gate-config.html
New file @@ -0,0 +1,263 @@ <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Gate 策略控制面板</title> <style> :root { --bg: #0f1117; --card: #1a1d27; --border: #2a2d3a; --text: #e1e4e8; --dim: #8b949e; --accent: #58a6ff; --danger: #f85149; --green: #3fb950; --warn: #d29922; --input-bg: #161b22; } * { 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; padding: 20px; } .container { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; } .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border); } h2 { font-size: 18px; display: flex; align-items: center; gap: 10px; } .status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; } .form-group { display: flex; flex-direction: column; gap: 3px; } .form-group.full { grid-column: span 2; } .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: 5px; color: var(--text); padding: 7px 10px; font-size: 13px; outline: none; } .form-group input:focus, .form-group select:focus { border-color: var(--accent); } .btn { padding: 7px 16px; border-radius: 5px; border: none; font-size: 12px; cursor: pointer; font-weight: 500; } .btn:hover { opacity: .85; } .btn-primary { background: var(--accent); color: #fff; } .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-group { display: flex; gap: 6px; flex-wrap: wrap; } .api-row { display: flex; gap: 10px; align-items: flex-end; } .api-row .form-group { flex: 1; } /* 日志窗口 */ .log-panel { height: 280px; overflow-y: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; font-family: "SF Mono", "Consolas", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; } .log-panel .INFO { color: var(--text); } .log-panel .WARN { color: var(--warn); } .log-panel .ERROR { color: var(--danger); } .log-panel .empty { color: var(--dim); text-align: center; padding-top: 60px; } .toast { position: fixed; top: 20px; right: 20px; padding: 10px 18px; border-radius: 6px; font-size: 13px; z-index: 999; animation: slideIn .3s ease; } .toast.success { background: #1b3d2b; border:1px solid var(--green); color:var(--green); } .toast.error { background: #3d1b1b; border:1px solid var(--danger); color:var(--danger); } @keyframes slideIn { from{transform:translateX(100px);opacity:0} to{transform:translateX(0);opacity:1} } .section-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--dim); } .hidden { display: none !important; } </style> </head> <body> <div class="container"> <!-- Step 1: apiKey 输入 --> <div class="card" id="step1"> <div class="card-header"> <h2>🔑 输入 Gate API Key</h2> </div> <div class="api-row"> <div class="form-group"> <label>API Key</label> <input id="inputApiKey" type="password" placeholder="粘贴你的 Gate API Key"> </div> <button class="btn btn-primary" onclick="loadByApiKey()" style="margin-bottom:1px">加载配置</button> </div> </div> <!-- Step 2: 配置面板(初始隐藏) --> <div class="card hidden" id="step2"> <div class="card-header"> <h2> <span class="status-dot" id="statusDot" style="background:var(--dim)"></span> Gate 策略配置 <span style="font-size:11px;color:var(--dim);font-weight:400" id="currentApiKey"></span> </h2> <div class="btn-group"> <button class="btn btn-outline" onclick="refreshStatus()">刷新状态</button> <button class="btn btn-success" onclick="saveConfig()">保存配置</button> <button class="btn btn-primary" onclick="startStrategy()">启动策略</button> <button class="btn btn-danger" onclick="stopStrategy()">停止策略</button> </div> </div> <form id="configForm" class="form-grid"> <div class="form-group"><label>网格间距 (gridRate)</label><input id="gridRate" placeholder="0.005"></div> <div class="form-group"><label>预期收益 USDT</label><input id="expectedProfit" placeholder="0.15"></div> <div class="form-group"><label>最大亏损 USDT</label><input id="maxLoss" placeholder="1.5"></div> <div class="form-group"><label>基底开仓张数</label><input id="baseQuantity" placeholder="2"></div> <div class="form-group"><label>每次下单张数</label><input id="quantity" placeholder="2"></div> <div class="form-group"><label>最大持仓张数</label><input id="maxPositionSize" placeholder="4"></div> <div class="form-group"><label>止损阶梯次数</label><input id="stopLossCount" placeholder="0"></div> </form> </div> <!-- 日志面板 --> <div class="card"> <div class="card-header"> <h2>📋 实时日志</h2> <button class="btn btn-outline btn-sm" onclick="clearLogs()">清屏</button> </div> <div class="log-panel" id="logPanel"> <div class="empty">等待日志...</div> </div> </div> </div> <div id="toast"></div> <script> const API = '/api/gate'; let currentApiKey = ''; let logLines = []; const logPanel = document.getElementById('logPanel'); function toast(msg, type) { const t = document.getElementById('toast'); t.innerHTML = msg; t.className = 'toast ' + type; setTimeout(() => t.innerHTML = '', 3000); } function maskKey(k) { return k && k.length > 8 ? k.slice(0, 4) + '****' + k.slice(-4) : '***'; } // ========== 日志 ========== function appendLog(line) { logLines.push(line); if (logLines.length > 500) logLines.shift(); if (logPanel.querySelector('.empty')) logPanel.innerHTML = ''; const div = document.createElement('div'); const cls = line.includes('WARN') ? 'WARN' : line.includes('ERROR') ? 'ERROR' : 'INFO'; div.className = cls; div.textContent = line; logPanel.appendChild(div); logPanel.scrollTop = logPanel.scrollHeight; } function clearLogs() { logLines = []; logPanel.innerHTML = '<div class="empty">已清屏</div>'; } // SSE 连接(浏览器自动重连,无需手动 reconnect) (function initSSE() { const es = new EventSource(API + '/logs'); es.addEventListener('log', e => appendLog(e.data)); es.onerror = () => { appendLog('[SYSTEM] SSE 连接断开,浏览器将自动重连...'); }; })(); // ========== API ========== async function fetchApi(url, opts) { 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; } // Step 1: 输入 apiKey 加载配置 async function loadByApiKey() { const key = document.getElementById('inputApiKey').value.trim(); if (!key) return toast('请输入 API Key', 'error'); try { const d = await fetchApi(API + '/config/load?apiKey=' + encodeURIComponent(key)); fillForm(d); toast('配置已加载', 'success'); } catch(e) { // 文件不存在 → 用默认值填充,用户可编辑后保存 fillForm({ gridRate:0.005, expectedProfit:0.15, maxLoss:1.5, baseQuantity:'2', quantity:'2', maxPositionSize:4, stopLossCount:0 }); toast('未找到配置,已加载默认值,编辑后请保存', 'success'); } currentApiKey = key; document.getElementById('currentApiKey').textContent = 'apiKey: ' + maskKey(key); document.getElementById('step2').classList.remove('hidden'); refreshStatus(); } function fillForm(d) { document.getElementById('gridRate').value = d.gridRate || ''; document.getElementById('expectedProfit').value = d.expectedProfit || ''; document.getElementById('maxLoss').value = d.maxLoss || ''; document.getElementById('baseQuantity').value = d.baseQuantity || ''; document.getElementById('quantity').value = d.quantity || ''; document.getElementById('maxPositionSize').value = d.maxPositionSize ?? ''; document.getElementById('stopLossCount').value = d.stopLossCount ?? ''; } function collectForm() { return { apiKey: currentApiKey, gridRate: parseFloat($('#gridRate').value) || 0, expectedProfit: parseFloat($('#expectedProfit').value) || 0, maxLoss: parseFloat($('#maxLoss').value) || 0, baseQuantity: $('#baseQuantity').value, quantity: $('#quantity').value, maxPositionSize: parseInt($('#maxPositionSize').value) || 0, stopLossCount: parseInt($('#stopLossCount').value) || 0 }; } function $(id) { return document.getElementById(id); } async function saveConfig() { if (!currentApiKey) return toast('请先输入 API Key', 'error'); try { const body = collectForm(); const msg = await fetchApi(API + '/config', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }); toast(msg || '保存成功', 'success'); } catch(e) { toast(e.message, 'error'); } } async function startStrategy() { if (!currentApiKey) return toast('请先输入 API Key', 'error'); if (!confirm('确定要启动策略吗?')) return; try { const msg = await fetchApi(API + '/restart?apiKey=' + encodeURIComponent(currentApiKey), { method: 'POST' }); toast(msg, 'success'); refreshStatus(); } catch(e) { toast(e.message, 'error'); } } async function stopStrategy() { if (!confirm('确定要停止策略吗?')) return; try { const msg = await fetchApi(API + '/stop', { method: 'POST' }); toast(msg, 'success'); refreshStatus(); } catch(e) { toast(e.message, 'error'); } } async function refreshStatus() { try { const s = await fetchApi(API + '/status'); const dot = document.getElementById('statusDot'); dot.style.background = s === 'ACTIVE' ? 'var(--green)' : 'var(--danger)'; } catch(e) {} } </script> </body> </html>