From 57e5a8385de2d5ff2e43c72535b472e42d65ba91 Mon Sep 17 00:00:00 2001
From: Administrator <15274802129@163.com>
Date: Fri, 14 Aug 2026 11:13:44 +0800
Subject: [PATCH] feat(stations): 添加实例别名功能支持
---
src/main/resources/static/station-dashboard.html | 255 ++++++++++++++++++++++++++++++++++--------
src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java | 3
src/main/java/com/xcong/excoin/modules/station/controller/StationController.java | 23 +++
src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java | 31 +++++
src/main/java/com/xcong/excoin/modules/station/service/StationService.java | 6 +
src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java | 2
src/main/resources/db/station-schema.sql | 7 +
7 files changed, 275 insertions(+), 52 deletions(-)
diff --git a/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java b/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java
index b6c2a9b..3902ce7 100644
--- a/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java
+++ b/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java
@@ -1,6 +1,7 @@
package com.xcong.excoin.modules.station.controller;
import com.xcong.excoin.common.response.Result;
+import com.xcong.excoin.modules.station.model.InstanceInfo;
import com.xcong.excoin.modules.station.registry.InstanceRegistry;
import com.xcong.excoin.modules.station.service.StationService;
import io.swagger.annotations.Api;
@@ -9,6 +10,7 @@
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
+import java.util.Collection;
/**
* Station 管理接口 — 实例列表 / 启停 / 状态 / 事件查询
@@ -28,13 +30,30 @@
@ApiOperation("获取所有在线 JAR 实例")
@GetMapping("/list")
public Result list() {
- return Result.ok(instanceRegistry.list());
+ // 内存注册表只维护心跳信息,别名从 DB 合并(心跳重建实例对象时不会丢失)
+ Collection<InstanceInfo> instances = instanceRegistry.list();
+ for (InstanceInfo info : instances) {
+ info.setAliasName(stationService.getAlias(info.getApiKeyMd5()));
+ }
+ return Result.ok(instances);
}
@ApiOperation("获取单个实例状态")
@GetMapping("/status")
public Result status(@RequestParam String apiKeyMd5) {
- return Result.ok(instanceRegistry.get(apiKeyMd5));
+ InstanceInfo info = instanceRegistry.get(apiKeyMd5);
+ if (info != null) {
+ info.setAliasName(stationService.getAlias(apiKeyMd5));
+ }
+ return Result.ok(info);
+ }
+
+ @ApiOperation("编辑实例别名(空白 = 清除别名)")
+ @PostMapping("/alias")
+ public Result saveAlias(@RequestParam String apiKeyMd5,
+ @RequestParam(required = false) String alias) {
+ stationService.saveAlias(apiKeyMd5, alias);
+ return Result.ok("别名已保存");
}
@ApiOperation("启动指定 apiKeyMd5 的策略")
diff --git a/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java
index 1410787..db1c277 100644
--- a/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java
+++ b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java
@@ -16,6 +16,9 @@
/** apiKey 的 MD5 */
private String apiKeyMd5;
+ /** 实例别名(Station 端自定义显示名) */
+ private String aliasName;
+
/** 合约名称 */
private String contract;
diff --git a/src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java b/src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java
index 371faac..3a9434e 100644
--- a/src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java
+++ b/src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java
@@ -15,6 +15,8 @@
public class InstanceInfo {
/** apiKey 的 MD5 */
private String apiKeyMd5;
+ /** 实例别名(Station 端自定义显示名,来自 strategy_status) */
+ private String aliasName;
/** 合约名称 */
private String contract;
/** 策略状态 */
diff --git a/src/main/java/com/xcong/excoin/modules/station/service/StationService.java b/src/main/java/com/xcong/excoin/modules/station/service/StationService.java
index 6915344..49ec74f 100644
--- a/src/main/java/com/xcong/excoin/modules/station/service/StationService.java
+++ b/src/main/java/com/xcong/excoin/modules/station/service/StationService.java
@@ -25,6 +25,12 @@
/** 更新策略参数(通过 MQ 发送 UPDATE_CONFIG 到目标 JAR,仅持久化,需手动启动生效) */
void updateConfig(String apiKeyMd5, Map<String, Object> params);
+ /** 保存实例别名(空白/空串 = 清除别名),持久化到 strategy_status */
+ void saveAlias(String apiKeyMd5, String alias);
+
+ /** 查询实例别名,不存在返回 null */
+ String getAlias(String apiKeyMd5);
+
/** 保存策略事件(幂等) */
void saveEvent(GateStatsEvent event);
diff --git a/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java b/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java
index 86a8b68..8bc76bd 100644
--- a/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java
+++ b/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java
@@ -2,6 +2,7 @@
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xcong.excoin.modules.station.dao.StrategyEventLogDao;
import com.xcong.excoin.modules.station.dao.StrategyStatusDao;
@@ -66,6 +67,36 @@
cmdProducer.sendCommand(apiKeyMd5, "UPDATE_CONFIG", JSON.toJSONString(params));
}
+ // ==================== 别名 ====================
+
+ @Override
+ public void saveAlias(String apiKeyMd5, String alias) {
+ String cleaned = alias == null ? "" : alias.trim();
+ String value = cleaned.isEmpty() ? null : cleaned;
+
+ StrategyStatus existing = strategyStatusDao.selectByApiKeyMd5(apiKeyMd5);
+ if (existing != null) {
+ // 用 UpdateWrapper 显式 set,支持把别名更新为 null(清除)
+ LambdaUpdateWrapper<StrategyStatus> wrapper = new LambdaUpdateWrapper<>();
+ wrapper.eq(StrategyStatus::getApiKeyMd5, apiKeyMd5)
+ .set(StrategyStatus::getAliasName, value);
+ strategyStatusDao.update(null, wrapper);
+ } else if (value != null) {
+ // 实例尚无状态记录(未启动过),仅创建一条只含别名的记录
+ StrategyStatus status = new StrategyStatus();
+ status.setApiKeyMd5(apiKeyMd5);
+ status.setAliasName(value);
+ status.setCreateTime(new Date());
+ strategyStatusDao.insert(status);
+ }
+ }
+
+ @Override
+ public String getAlias(String apiKeyMd5) {
+ StrategyStatus status = strategyStatusDao.selectByApiKeyMd5(apiKeyMd5);
+ return status == null ? null : status.getAliasName();
+ }
+
// ==================== 事件落库 ====================
@Override
diff --git a/src/main/resources/db/station-schema.sql b/src/main/resources/db/station-schema.sql
index f6ae3fa..9c18808 100644
--- a/src/main/resources/db/station-schema.sql
+++ b/src/main/resources/db/station-schema.sql
@@ -4,6 +4,7 @@
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',
+ `alias_name` VARCHAR(64) DEFAULT NULL COMMENT '实例别名(Station 端自定义显示名)',
`contract` VARCHAR(32) DEFAULT NULL COMMENT '合约名称',
`state` VARCHAR(32) DEFAULT NULL COMMENT '策略状态:ACTIVE / STOPPED / WAITING_KLINE',
`leverage` VARCHAR(16) DEFAULT NULL COMMENT '杠杆倍数',
@@ -69,3 +70,9 @@
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`;
+
+-- ============================================================
+-- 实例别名(Station 端自定义显示名,2026-08 新增)
+-- ============================================================
+ALTER TABLE `strategy_status`
+ ADD COLUMN `alias_name` VARCHAR(64) DEFAULT NULL COMMENT '实例别名(Station 端自定义显示名)' AFTER `api_key_md5`;
diff --git a/src/main/resources/static/station-dashboard.html b/src/main/resources/static/station-dashboard.html
index 07e2c8d..2f2f7d8 100644
--- a/src/main/resources/static/station-dashboard.html
+++ b/src/main/resources/static/station-dashboard.html
@@ -50,11 +50,22 @@
.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 .alias-line {
+ display: flex; align-items: center; gap: 4px; min-width: 0;
}
- .instance-card .contract {
- font-size: 14px; font-weight: 600; margin-top: 2px;
+ .instance-card .alias-text {
+ font-size: 14px; font-weight: 600;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ }
+ .instance-card .alias-text.placeholder { color: var(--dim); font-weight: 400; }
+ .instance-card .alias-edit {
+ background: none; border: none; cursor: pointer; color: var(--dim);
+ font-size: 11px; line-height: 1; padding: 2px 4px; border-radius: 4px; flex-shrink: 0;
+ }
+ .instance-card .alias-edit:hover { color: var(--accent); background: rgba(59,130,246,0.12); }
+ .instance-card .md5 {
+ font-size: 11px; color: var(--dim); font-family: "SF Mono", "Consolas", monospace;
+ margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.instance-card .meta {
display: flex; gap: 10px; margin-top: 6px; font-size: 11px; color: var(--dim);
@@ -68,6 +79,7 @@
}
.status-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.stopped { background: var(--danger); }
+ .status-dot.waiting { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
.status-dot.offline { background: var(--dim); }
.sidebar-footer {
@@ -89,6 +101,14 @@
display: flex; align-items: center; gap: 10px;
}
.main-header h2 { font-size: 17px; }
+ .main-header .header-alias {
+ display: inline-block; max-width: 160px; vertical-align: middle;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ background: #eef4ff; color: var(--accent);
+ border: 1px solid rgba(59,130,246,0.25);
+ font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 10px;
+ margin-right: 6px;
+ }
.main-header .state-badge {
font-size: 11px; padding: 2px 10px; border-radius: 10px;
font-weight: 600;
@@ -278,6 +298,13 @@
.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; }
+ .modal input[type="text"] {
+ width: 100%; padding: 8px 10px; box-sizing: border-box;
+ border: 1px solid var(--border); border-radius: 6px;
+ font-size: 13px; outline: none; color: var(--text); background: var(--input-bg);
+ margin-bottom: 16px;
+ }
+ .modal input[type="text"]:focus { border-color: var(--accent); }
/* Toast — 页面正中间,3s 后彻底隐藏 */
.toast {
@@ -352,8 +379,8 @@
</div>
</div>
-<!-- 详情模板(初始隐藏) -->
-<div id="detailTemplate" style="display:none">
+<!-- 详情模板 — 原生 <template>:内容惰性不参与文档流,克隆后无重复 ID -->
+<template id="detailTemplate">
<!-- Tabs -->
<div class="tabs">
<div class="tab active" data-tab="overview">概览</div>
@@ -407,7 +434,7 @@
<div id="configForm"></div>
</div>
</div>
-</div>
+</template>
<!-- 确认弹窗 -->
<div class="modal-overlay" id="modalOverlay">
@@ -417,6 +444,20 @@
<div class="modal-btns">
<button class="btn btn-outline" onclick="closeModal()">取消</button>
<button class="btn" id="modalConfirmBtn" onclick="confirmAction()">确认</button>
+ </div>
+ </div>
+</div>
+
+<!-- 别名编辑弹窗 -->
+<div class="modal-overlay" id="aliasModalOverlay">
+ <div class="modal">
+ <h3>✎ 编辑别名</h3>
+ <p>为实例设置便于识别的显示名(留空保存可清除别名)</p>
+ <input id="aliasInput" type="text" maxlength="64" placeholder="例如:ETH_USDT"
+ onkeydown="if(event.key==='Enter'){saveAlias();} else if(event.key==='Escape'){closeAliasModal();}">
+ <div class="modal-btns">
+ <button class="btn btn-outline" onclick="closeAliasModal()">取消</button>
+ <button class="btn btn-success" onclick="saveAlias()">保存</button>
</div>
</div>
</div>
@@ -434,6 +475,11 @@
// ==================== 工具 ====================
function $(id) { return document.getElementById(id); }
+
+/** HTML 转义 — 所有来自服务端/JAR 的动态字符串插值前必须使用,防止属性逃逸注入 */
+const esc = s => String(s ?? '').replace(/[&<>"']/g, c => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
+));
function toast(msg, type) {
const t = $('toast');
t.textContent = msg;
@@ -475,17 +521,38 @@
}
// ==================== API 请求 ====================
+const API_TIMEOUT_MS = 10000;
+
async function api(url, opts) {
+ // 超时保护:10s 未响应则中断请求,避免页面无限等待
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS);
try {
- const res = await fetch(url, opts);
+ const res = await fetch(url, Object.assign({}, opts, { signal: ctrl.signal }));
+ // 未登录/无权限:引导回登录页
+ if (res.status === 401 || res.status === 403) {
+ window.location.href = '/login';
+ throw new Error('未登录或会话已过期');
+ }
+ // 非 2xx 或非 JSON 响应(如安全框架拦截返回的 HTML):给出明确错误
+ const ct = res.headers.get('content-type') || '';
+ if (!res.ok || ct.indexOf('application/json') === -1) {
+ throw new Error('服务器异常 (' + res.status + ')');
+ }
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.name === 'AbortError') {
+ $('connStatus').innerHTML = '🔴 离线';
+ throw new Error('请求超时,请检查网络');
+ }
if (e.message.includes('Failed to fetch') || e.message.includes('NetworkError')) {
$('connStatus').innerHTML = '🔴 离线';
}
throw e;
+ } finally {
+ clearTimeout(timer);
}
}
@@ -500,10 +567,14 @@
}
instances = newInstances;
renderInstanceList();
- // 实例数据已更新,同步刷新选中实例的概览统计与参数页状态条
+ // 实例数据已更新,同步刷新选中实例的概览统计、参数页状态条与操作按钮
if (selectedMd5 && instances[selectedMd5]) {
renderStats(instances[selectedMd5]);
renderConfigStatusBar();
+ updateActionButtons(instances[selectedMd5]);
+ } else if (selectedMd5) {
+ // 选中实例已离线:禁用操作按钮,详情区保留最后快照
+ updateActionButtons(null);
}
} catch (e) {
// 静默失败,保留旧数据
@@ -531,19 +602,27 @@
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 dotClass = i.state === 'STOPPED' ? 'stopped'
+ : isActive ? 'active'
+ : i.state === 'WAITING_KLINE' ? 'waiting' : 'offline';
const pnl = fmtPnl(i.cumulativePnl);
const selected = k === selectedMd5 ? ' selected' : '';
+ // 第一行显示别名;未设置时回退显示合约名(占位样式)
+ const hasAlias = !!i.aliasName;
+ const aliasText = hasAlias ? i.aliasName : (i.contract || '未命名实例');
return `
- <div class="instance-card${selected}" onclick="selectInstance('${k}')" data-md5="${k}">
+ <div class="instance-card${selected}" onclick="selectInstance('${esc(k)}')" data-md5="${esc(k)}">
<div class="top-row">
- <span class="key">${i.contract || '—'}</span>
- <span class="status-dot ${dotClass}" title="${i.state || 'UNKNOWN'}"></span>
+ <span class="alias-line">
+ <span class="alias-text${hasAlias ? '' : ' placeholder'}" title="${esc(aliasText)}">${esc(aliasText)}</span>
+ <button class="alias-edit" title="编辑别名" onclick="event.stopPropagation(); showAliasModal('${esc(k)}')">✎</button>
+ </span>
+ <span class="status-dot ${dotClass}" title="${esc(i.state || 'UNKNOWN')}"></span>
</div>
- <div class="contract">${k.substring(0, 16)}...</div>
+ <div class="md5">${esc(k.substring(0, 16))}...</div>
<div class="meta">
<span>轮次 ${i.currentRound ?? 0}</span>
- <span class="pnl ${pnl.cls}">${pnl.text}</span>
+ <span class="pnl ${pnl.cls}">${esc(pnl.text)}</span>
<span>${relativeTime(i.lastSeen)}</span>
</div>
</div>`;
@@ -568,22 +647,17 @@
const content = $('mainContent');
const tmpl = $('detailTemplate');
if (!content.querySelector('.tabs')) {
- content.innerHTML = tmpl.innerHTML;
+ // 先清空空状态占位,再克隆 <template> 内容(惰性片段,克隆后无重复 ID)
+ content.innerHTML = '';
+ content.appendChild(tmpl.content.cloneNode(true));
// 绑定 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>
- <button class="btn btn-outline" onclick="refreshEvents()" style="font-size:11px;padding:3px 10px">🔄 刷新</button>
- `;
- $('btnStart').disabled = false;
- $('btnStop').disabled = false;
+ // 更新 header(含别名,未设置别名时不显示)
+ renderHeader(inst);
// 更新概览统计
renderStats(inst);
@@ -591,10 +665,30 @@
// 加载事件(内部会渲染事件表格 + PNL 图表)
await loadEvents(md5);
+ // 事件加载完成后重算统计卡:账户总权益依赖最新 PNL_SNAPSHOT
+ if (selectedMd5 === md5 && instances[md5]) {
+ renderStats(instances[md5]);
+ }
+
// 策略参数表单:仅全量加载(切换实例)时渲染,避免轮询清空用户正在编辑的内容
if (full) {
loadConfig(md5);
}
+}
+
+function renderHeader(inst) {
+ const header = $('mainHeader');
+ const md5 = inst.apiKeyMd5;
+ // 别名徽标:仅当设置了别名时显示
+ const aliasHtml = inst.aliasName
+ ? `<span class="header-alias" title="${esc(inst.aliasName)}">${esc(inst.aliasName)}</span>`
+ : '';
+ header.querySelector('.instance-title').innerHTML = `
+ <h2>${aliasHtml}${esc(inst.contract || '—')} <span style="font-size:12px;color:var(--dim);font-weight:400">${esc(md5.substring(0, 12))}...</span></h2>
+ <span class="state-badge ${sanitizeState(inst.state)}">${esc(inst.state || 'UNKNOWN')}</span>
+ <button class="btn btn-outline" onclick="refreshEvents()" style="font-size:11px;padding:3px 10px">🔄 刷新</button>
+ `;
+ updateActionButtons(inst);
}
function sanitizeState(s) {
@@ -604,10 +698,14 @@
}
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');
+ // 查询限定在 mainContent 内,避免误伤模板或其他区域
+ const main = $('mainContent');
+ main.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ main.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+ main.querySelector(`.tab[data-tab="${name}"]`).classList.add('active');
+ main.querySelector(`.tab-content[data-tab="${name}"]`).classList.add('active');
+ // 概览 Tab 变为可见时校正图表尺寸(图表可能在隐藏容器中创建过)
+ if (name === 'overview' && pnlChart) pnlChart.resize();
}
// ==================== 统计卡片 ====================
@@ -615,24 +713,33 @@
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;
+ // 账户总权益:优先用最新 PNL_SNAPSHOT 的真实值(账户余额 + 浮动盈亏),无快照数据时回退估算
+ let totalEquity = principal + cumpnl;
+ let equityFromSnapshot = false;
+ if (pnlData.length > 0) {
+ const latest = pnlData[pnlData.length - 1];
+ if (isFinite(latest.totalEquity) && latest.totalEquity > 0 && isFinite(latest.unrealizedPnl)) {
+ totalEquity = latest.totalEquity + latest.unrealizedPnl;
+ equityFromSnapshot = true;
+ }
+ }
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="value ${pnl.cls}">${esc(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="value">${esc(fmtNum(inst.principal))}</div>
<div class="sub">USDT</div>
</div>
<div class="stat-card">
- <div class="label">估算总权益</div>
+ <div class="label">账户总权益</div>
<div class="value">${fmtNum(totalEquity)}</div>
- <div class="sub">本金 + 已实现盈亏</div>
+ <div class="sub">${equityFromSnapshot ? '含浮动盈亏(最新快照)' : '本金 + 已实现盈亏(暂无快照)'}</div>
</div>
<div class="stat-card">
<div class="label">收益率 (ROI)</div>
@@ -642,12 +749,12 @@
<div class="stat-card">
<div class="label">当前轮次</div>
<div class="value">${inst.currentRound ?? 0}</div>
- <div class="sub">杠杆 ${inst.leverage || '—'}x</div>
+ <div class="sub">杠杆 ${esc(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 class="sub">${esc(inst.hostPort || '—')}</div>
</div>
`;
}
@@ -685,11 +792,10 @@
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)
+ totalEquity: parseFloat(payload.totalEquity || 0)
};
})
- .slice(-50);
+ .slice(0, 50).reverse(); // 后端返回 DESC(最新在前),取最新 50 条并转时间正序
tbody.innerHTML = events.slice(0, 100).map(e => {
let payloadStr = e.payloadJson || '';
@@ -705,9 +811,9 @@
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>
+ <td class="relative-time" title="${esc(new Date(ts).toLocaleString())}">${formatTime(ts)}</td>
+ <td><span class="event-tag ${esc(e.eventType || '')}">${esc(e.eventType || '—')}</span></td>
+ <td class="payload-preview" title="${esc(payloadStr)}">${esc(shortPayload || '—')}</td>
</tr>`;
}).join('');
@@ -860,8 +966,7 @@
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) }
+ { label: '总权益', key: 'totalEquity', signed: false, digits: 2, fmt: v => v.toFixed(2) }
];
box.innerHTML = items.map(it => {
@@ -874,7 +979,7 @@
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>';
+ deltaHtml = '<div class="s-delta">持平</div>';
}
}
return `<div class="pnl-summary-item">
@@ -1033,12 +1138,12 @@
}
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: esc(inst.contract || '—'), cls: '' },
+ { label: '状态', value: esc(inst.state || '—'), cls: 'dim' },
+ { label: '杠杆', value: esc((inst.leverage || '—') + 'x'), cls: '' },
{ label: '当前轮次', value: inst.currentRound ?? 0, cls: '' },
- { label: '累计盈亏', value: pnl.text, cls: pnl.cls },
- { label: '初始本金', value: fmtNum(inst.principal), cls: '' },
+ { label: '累计盈亏', value: esc(pnl.text), cls: pnl.cls },
+ { label: '初始本金', value: esc(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>`
@@ -1095,6 +1200,21 @@
}
// ==================== 启停操作 ====================
+
+// 按实例状态联动启用/禁用 启动/停止 按钮,防止误触(JAR 的 START 会平仓重建策略)
+function updateActionButtons(inst) {
+ const startBtn = $('btnStart');
+ const stopBtn = $('btnStop');
+ if (!inst) {
+ startBtn.disabled = true;
+ stopBtn.disabled = true;
+ return;
+ }
+ const running = inst.state === 'ACTIVE' || inst.state === 'OPENING' || inst.state === 'WAITING_KLINE';
+ startBtn.disabled = running; // 运行中不可重复启动
+ stopBtn.disabled = !running; // 未运行不可停止
+}
+
function showConfirm(action) {
if (!selectedMd5) return;
pendingAction = action;
@@ -1111,6 +1231,41 @@
pendingAction = null;
}
+// ==================== 别名编辑 ====================
+let aliasEditingMd5 = null;
+
+function showAliasModal(md5) {
+ aliasEditingMd5 = md5;
+ const inst = instances[md5];
+ $('aliasInput').value = inst && inst.aliasName ? inst.aliasName : '';
+ $('aliasModalOverlay').classList.add('show');
+ $('aliasInput').focus();
+}
+
+function closeAliasModal() {
+ $('aliasModalOverlay').classList.remove('show');
+ aliasEditingMd5 = null;
+}
+
+async function saveAlias() {
+ if (!aliasEditingMd5) return;
+ const md5 = aliasEditingMd5;
+ const alias = $('aliasInput').value.trim();
+ try {
+ await api(API + '/alias?apiKeyMd5=' + encodeURIComponent(md5)
+ + '&alias=' + encodeURIComponent(alias), { method: 'POST' });
+ toast(alias ? '别名已保存' : '别名已清除', 'success');
+ closeAliasModal();
+ await refreshInstanceList();
+ // 编辑的是当前选中实例时,同步刷新详情头部别名
+ if (selectedMd5 === md5 && instances[md5]) {
+ renderHeader(instances[md5]);
+ }
+ } catch (e) {
+ toast(e.message, 'error');
+ }
+}
+
async function confirmAction() {
if (!pendingAction || !selectedMd5) return;
const action = pendingAction;
--
Gitblit v1.9.1