<!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 .alias-line {
|
display: flex; align-items: center; gap: 4px; min-width: 0;
|
}
|
.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);
|
}
|
.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.waiting { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
|
.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 .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;
|
}
|
.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; }
|
.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 {
|
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>
|
<button class="btn btn-outline" onclick="refreshInstanceList()" style="font-size:11px;padding:3px 10px">🔄 刷新</button>
|
</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>
|
|
<!-- 详情模板 — 原生 <template>:内容惰性不参与文档流,克隆后无重复 ID -->
|
<template id="detailTemplate">
|
<!-- 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>
|
</template>
|
|
<!-- 确认弹窗 -->
|
<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 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>
|
|
<div id="toast"></div>
|
|
<script>
|
const API = '/api/gate/station';
|
let selectedMd5 = null;
|
let instances = {};
|
let eventsCache = {};
|
let pnlChart = null;
|
let pendingAction = null;
|
let pnlData = [];
|
|
// ==================== 工具 ====================
|
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;
|
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 请求 ====================
|
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, 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);
|
}
|
}
|
|
// ==================== 实例列表 ====================
|
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();
|
// 实例数据已更新,同步刷新选中实例的概览统计、参数页状态条与操作按钮
|
if (selectedMd5 && instances[selectedMd5]) {
|
renderStats(instances[selectedMd5]);
|
renderConfigStatusBar();
|
updateActionButtons(instances[selectedMd5]);
|
} else if (selectedMd5) {
|
// 选中实例已离线:禁用操作按钮,详情区保留最后快照
|
updateActionButtons(null);
|
}
|
} catch (e) {
|
// 静默失败,保留旧数据
|
}
|
}
|
|
// 手动刷新事件日志(对应「事件日志」Tab 的刷新按钮)
|
function refreshEvents() {
|
if (!selectedMd5) return;
|
loadEvents(selectedMd5);
|
}
|
|
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'
|
: 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('${esc(k)}')" data-md5="${esc(k)}">
|
<div class="top-row">
|
<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="md5">${esc(k.substring(0, 16))}...</div>
|
<div class="meta">
|
<span>轮次 ${i.currentRound ?? 0}</span>
|
<span class="pnl ${pnl.cls}">${esc(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')) {
|
// 先清空空状态占位,再克隆 <template> 内容(惰性片段,克隆后无重复 ID)
|
content.innerHTML = '';
|
content.appendChild(tmpl.content.cloneNode(true));
|
// 绑定 tab 切换
|
content.querySelectorAll('.tab').forEach(t => {
|
t.addEventListener('click', () => switchTab(t.dataset.tab));
|
});
|
}
|
|
// 更新 header(含别名,未设置别名时不显示)
|
renderHeader(inst);
|
|
// 更新概览统计
|
renderStats(inst);
|
|
// 加载事件(内部会渲染事件表格 + 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) {
|
if (!s) return '';
|
const valid = ['ACTIVE', 'STOPPED', 'WAITING_KLINE', 'OPENING'];
|
return valid.includes(s) ? s : '';
|
}
|
|
function switchTab(name) {
|
// 查询限定在 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();
|
}
|
|
// ==================== 统计卡片 ====================
|
function renderStats(inst) {
|
const pnl = fmtPnl(inst.cumulativePnl);
|
const principal = inst.principal ? parseFloat(inst.principal) : 0;
|
const cumpnl = inst.cumulativePnl ? parseFloat(inst.cumulativePnl) : 0;
|
// 账户总权益:优先用最新 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}">${esc(pnl.text)}</div>
|
<div class="sub">USDT</div>
|
</div>
|
<div class="stat-card">
|
<div class="label">初始本金</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="value">${fmtNum(totalEquity)}</div>
|
<div class="sub">${equityFromSnapshot ? '含浮动盈亏(最新快照)' : '本金 + 已实现盈亏(暂无快照)'}</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">杠杆 ${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">${esc(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)
|
};
|
})
|
.slice(0, 50).reverse(); // 后端返回 DESC(最新在前),取最新 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="${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('');
|
|
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) }
|
];
|
|
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: 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: 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>`
|
).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');
|
}
|
}
|
|
// ==================== 启停操作 ====================
|
|
// 按实例状态联动启用/禁用 启动/停止 按钮,防止误触(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;
|
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;
|
}
|
|
// ==================== 别名编辑 ====================
|
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;
|
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(); // 页面加载时立即加载一次实例列表(不再自动轮询,改为手动刷新)
|
}
|
|
startPolling();
|
</script>
|
</body>
|
</html>
|