| New file |
| | |
| | | <!DOCTYPE html> |
| | | <html lang="zh"> |
| | | <head> |
| | | <meta charset="UTF-8"> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | | <title>Station Dashboard — 策略监控中心</title> |
| | | <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> |
| | | <style> |
| | | :root { |
| | | --bg: #eaf2ff; --card: #ffffff; --border: #e3e8f0; |
| | | --text: #1f2937; --dim: #6b7280; --accent: #3b82f6; |
| | | --danger: #dc2626; --green: #16a34a; --warn: #d97706; |
| | | --input-bg: #f9fafb; --sidebar-w: 280px; |
| | | --purple: #7c3aed; --teal: #0d9488; |
| | | } |
| | | * { margin: 0; padding: 0; box-sizing: border-box; } |
| | | body { |
| | | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| | | background: var(--bg); color: var(--text); min-height: 100vh; |
| | | display: flex; overflow: hidden; |
| | | } |
| | | |
| | | /* ===== 侧边栏 ===== */ |
| | | .sidebar { |
| | | width: var(--sidebar-w); min-width: var(--sidebar-w); |
| | | background: var(--card); border-right: 1px solid var(--border); |
| | | display: flex; flex-direction: column; height: 100vh; |
| | | } |
| | | .sidebar-header { |
| | | padding: 16px 16px 12px; border-bottom: 1px solid var(--border); |
| | | display: flex; justify-content: space-between; align-items: center; |
| | | } |
| | | .sidebar-header h1 { |
| | | font-size: 16px; display: flex; align-items: center; gap: 8px; |
| | | } |
| | | .refresh-badge { |
| | | font-size: 10px; color: var(--dim); border: 1px solid var(--border); |
| | | border-radius: 10px; padding: 2px 8px; |
| | | } |
| | | .instance-list { |
| | | flex: 1; overflow-y: auto; padding: 8px; |
| | | } |
| | | .instance-card { |
| | | background: var(--bg); border: 1px solid var(--border); |
| | | border-radius: 6px; padding: 12px; margin-bottom: 6px; |
| | | cursor: pointer; transition: all .15s; |
| | | } |
| | | .instance-card:hover { border-color: var(--accent); } |
| | | .instance-card.selected { border-color: var(--accent); background: #eef4ff; } |
| | | .instance-card .top-row { |
| | | display: flex; justify-content: space-between; align-items: center; |
| | | } |
| | | .instance-card .key { |
| | | font-size: 11px; color: var(--dim); font-family: "SF Mono", "Consolas", monospace; |
| | | } |
| | | .instance-card .contract { |
| | | font-size: 14px; font-weight: 600; margin-top: 2px; |
| | | } |
| | | .instance-card .meta { |
| | | display: flex; gap: 10px; margin-top: 6px; font-size: 11px; color: var(--dim); |
| | | } |
| | | .instance-card .pnl { font-weight: 600; } |
| | | .instance-card .pnl.positive { color: var(--green); } |
| | | .instance-card .pnl.negative { color: var(--danger); } |
| | | .status-dot { |
| | | width: 8px; height: 8px; border-radius: 50%; display: inline-block; |
| | | flex-shrink: 0; |
| | | } |
| | | .status-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); } |
| | | .status-dot.stopped { background: var(--danger); } |
| | | .status-dot.offline { background: var(--dim); } |
| | | |
| | | .sidebar-footer { |
| | | padding: 10px 16px; border-top: 1px solid var(--border); |
| | | font-size: 11px; color: var(--dim); display: flex; justify-content: space-between; |
| | | } |
| | | |
| | | /* ===== 主区域 ===== */ |
| | | .main { |
| | | flex: 1; display: flex; flex-direction: column; height: 100vh; |
| | | overflow: hidden; |
| | | } |
| | | .main-header { |
| | | padding: 14px 24px; border-bottom: 1px solid var(--border); |
| | | background: var(--card); |
| | | display: flex; justify-content: space-between; align-items: center; |
| | | } |
| | | .main-header .instance-title { |
| | | display: flex; align-items: center; gap: 10px; |
| | | } |
| | | .main-header h2 { font-size: 17px; } |
| | | .main-header .state-badge { |
| | | font-size: 11px; padding: 2px 10px; border-radius: 10px; |
| | | font-weight: 600; |
| | | } |
| | | .state-badge.ACTIVE { background: #dcfce7; color: #15803d; } |
| | | .state-badge.STOPPED { background: #fee2e2; color: #b91c1c; } |
| | | .state-badge.WAITING_KLINE { background: #fef3c7; color: #b45309; } |
| | | .state-badge.OPENING { background: #dbeafe; color: #1d4ed8; } |
| | | |
| | | .btn-group { display: flex; gap: 6px; } |
| | | .btn { |
| | | padding: 6px 14px; border-radius: 5px; border: none; |
| | | font-size: 12px; cursor: pointer; font-weight: 500; |
| | | transition: opacity .15s; |
| | | } |
| | | .btn:hover { opacity: .85; } |
| | | .btn-success { background: var(--green); color: #fff; } |
| | | .btn-danger { background: var(--danger); color: #fff; } |
| | | .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); } |
| | | .btn:disabled { opacity: .4; cursor: not-allowed; } |
| | | |
| | | /* ===== 内容区(Tabs) ===== */ |
| | | .content { flex: 1; overflow-y: auto; padding: 16px 24px; } |
| | | .tabs { |
| | | display: flex; gap: 0; margin-bottom: 16px; |
| | | border-bottom: 1px solid var(--border); |
| | | } |
| | | .tab { |
| | | padding: 8px 20px; font-size: 13px; cursor: pointer; |
| | | border-bottom: 2px solid transparent; color: var(--dim); |
| | | transition: all .15s; |
| | | } |
| | | .tab:hover { color: var(--text); } |
| | | .tab.active { color: var(--accent); border-bottom-color: var(--accent); } |
| | | |
| | | .tab-content { display: none; } |
| | | .tab-content.active { display: block; } |
| | | |
| | | /* ===== 统计卡片 ===== */ |
| | | .stats-grid { |
| | | display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); |
| | | gap: 12px; margin-bottom: 20px; |
| | | } |
| | | .stat-card { |
| | | background: var(--card); border: 1px solid var(--border); |
| | | border-radius: 8px; padding: 14px 16px; |
| | | } |
| | | .stat-card .label { font-size: 11px; color: var(--dim); margin-bottom: 4px; } |
| | | .stat-card .value { font-size: 22px; font-weight: 600; } |
| | | .stat-card .sub { font-size: 11px; color: var(--dim); margin-top: 2px; } |
| | | |
| | | /* ===== 图表容器 ===== */ |
| | | .chart-container { |
| | | background: var(--card); border: 1px solid var(--border); |
| | | border-radius: 8px; padding: 16px; margin-bottom: 20px; |
| | | } |
| | | .chart-container h3 { |
| | | font-size: 13px; color: var(--dim); margin-bottom: 12px; |
| | | } |
| | | .pnl-summary { |
| | | display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); |
| | | gap: 10px; margin-bottom: 16px; |
| | | } |
| | | .pnl-summary-item { |
| | | background: var(--bg); border: 1px solid var(--border); |
| | | border-radius: 8px; padding: 10px 14px; |
| | | } |
| | | .pnl-summary-item .s-label { font-size: 11px; color: var(--dim); margin-bottom: 4px; } |
| | | .pnl-summary-item .s-value { font-size: 17px; font-weight: 600; color: var(--text); } |
| | | .pnl-summary-item .s-delta { font-size: 11px; margin-top: 3px; font-weight: 500; color: var(--dim); } |
| | | .pnl-summary-item .s-value.positive { color: var(--green); } |
| | | .pnl-summary-item .s-value.negative { color: var(--danger); } |
| | | .pnl-summary-item .s-delta.positive { color: var(--green); } |
| | | .pnl-summary-item .s-delta.negative { color: var(--danger); } |
| | | .chart-wrap { position: relative; height: 300px; } |
| | | .chart-wrap canvas { width: 100% !important; height: 100% !important; } |
| | | |
| | | /* ===== 事件日志 ===== */ |
| | | .events-panel { |
| | | background: var(--card); border: 1px solid var(--border); |
| | | border-radius: 8px; overflow: hidden; |
| | | } |
| | | .events-header { |
| | | padding: 12px 16px; border-bottom: 1px solid var(--border); |
| | | display: flex; justify-content: space-between; align-items: center; |
| | | } |
| | | .events-header h3 { font-size: 13px; color: var(--dim); } |
| | | .events-table { |
| | | width: 100%; font-size: 12px; border-collapse: collapse; |
| | | } |
| | | .events-table th { |
| | | text-align: left; padding: 8px 16px; font-weight: 500; |
| | | color: var(--dim); border-bottom: 1px solid var(--border); |
| | | background: var(--bg); |
| | | } |
| | | .events-table td { |
| | | padding: 7px 16px; border-bottom: 1px solid rgba(0,0,0,0.06); |
| | | vertical-align: middle; |
| | | } |
| | | .events-table tr:hover { background: rgba(59,130,246,0.05); } |
| | | .event-tag { |
| | | font-size: 10px; padding: 1px 8px; border-radius: 8px; |
| | | font-weight: 600; white-space: nowrap; |
| | | } |
| | | .event-tag.STRATEGY_START { background: #dcfce7; color: #15803d; } |
| | | .event-tag.STRATEGY_STOP { background: #fee2e2; color: #b91c1c; } |
| | | .event-tag.ROUND_COMPLETE { background: #dbeafe; color: #1d4ed8; } |
| | | .event-tag.STOP_LOSS_TRIGGERED { background: #fef3c7; color: #b45309; } |
| | | .event-tag.ENTRY_FILLED { background: #ede9fe; color: #6d28d9; } |
| | | .event-tag.PNL_SNAPSHOT { background: #ccfbf1; color: #0f766e; } |
| | | .event-tag.CMD_ACK { background: #e5e7eb; color: #4b5563; } |
| | | .payload-preview { |
| | | max-width: 260px; overflow: hidden; text-overflow: ellipsis; |
| | | white-space: nowrap; color: var(--dim); font-family: "SF Mono", "Consolas", monospace; |
| | | } |
| | | .relative-time { color: var(--dim); white-space: nowrap; font-size: 11px; } |
| | | |
| | | /* ===== 配置面板 ===== */ |
| | | .config-status-bar { |
| | | display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; |
| | | } |
| | | .config-status-item { |
| | | flex: 1; min-width: 120px; |
| | | background: var(--card); border: 1px solid var(--border); |
| | | border-radius: 8px; padding: 10px 14px; |
| | | } |
| | | .config-status-item .label { font-size: 11px; color: var(--dim); margin-bottom: 3px; } |
| | | .config-status-item .value { font-size: 16px; font-weight: 600; } |
| | | .config-status-item .value.positive { color: var(--green); } |
| | | .config-status-item .value.negative { color: var(--danger); } |
| | | .config-status-item .value.dim { color: var(--dim); } |
| | | |
| | | .config-section { margin-bottom: 18px; } |
| | | .config-section-title { |
| | | font-size: 13px; font-weight: 600; color: var(--text); |
| | | margin-bottom: 10px; padding-bottom: 7px; |
| | | border-bottom: 1px solid var(--border); |
| | | display: flex; align-items: center; gap: 7px; |
| | | } |
| | | .config-section-title .count { |
| | | font-size: 10px; font-weight: 400; color: var(--dim); |
| | | background: var(--bg); border: 1px solid var(--border); |
| | | border-radius: 9px; padding: 0 7px; line-height: 16px; |
| | | } |
| | | .form-grid { |
| | | display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; |
| | | } |
| | | .form-group { display: flex; flex-direction: column; gap: 5px; } |
| | | .form-group label { font-size: 11px; color: var(--dim); font-weight: 500; } |
| | | .form-group input, .form-group select { |
| | | background: var(--input-bg); border: 1px solid var(--border); |
| | | border-radius: 6px; color: var(--text); padding: 8px 10px; |
| | | font-size: 13px; outline: none; width: 100%; |
| | | transition: border-color .15s; |
| | | } |
| | | .form-group input:focus, .form-group select:focus { border-color: var(--accent); } |
| | | .form-group input::placeholder { color: #9ca3af; } |
| | | .form-group .field-tip { font-size: 10px; color: #9ca3af; line-height: 1.3; } |
| | | .form-group.modified label::after { |
| | | content: ' • 已修改'; color: var(--warn); font-weight: 600; |
| | | } |
| | | .form-group.modified input, .form-group.modified select { border-color: var(--warn); } |
| | | .config-actions { |
| | | display: flex; justify-content: flex-end; gap: 8px; |
| | | margin-bottom: 18px; padding-bottom: 14px; |
| | | border-bottom: 1px solid var(--border); |
| | | } |
| | | |
| | | /* ===== 空状态 ===== */ |
| | | .empty-state { |
| | | text-align: center; padding: 60px 20px; color: var(--dim); |
| | | } |
| | | .empty-state .icon { font-size: 48px; margin-bottom: 12px; } |
| | | .empty-state p { font-size: 14px; } |
| | | |
| | | /* ===== 确认弹窗 ===== */ |
| | | .modal-overlay { |
| | | position: fixed; inset: 0; background: rgba(15,23,42,0.4); |
| | | display: flex; align-items: center; justify-content: center; z-index: 1000; |
| | | display: none; |
| | | } |
| | | .modal-overlay.show { display: flex; } |
| | | .modal { |
| | | background: var(--card); border: 1px solid var(--border); |
| | | border-radius: 10px; padding: 24px; width: 380px; |
| | | } |
| | | .modal h3 { font-size: 16px; margin-bottom: 12px; } |
| | | .modal p { font-size: 13px; color: var(--dim); margin-bottom: 20px; } |
| | | .modal .modal-btns { display: flex; justify-content: flex-end; gap: 8px; } |
| | | |
| | | /* Toast — 页面正中间,3s 后彻底隐藏 */ |
| | | .toast { |
| | | position: fixed; top: 50%; left: 50%; |
| | | transform: translate(-50%, -50%); |
| | | padding: 12px 22px; border-radius: 8px; font-size: 14px; |
| | | z-index: 2000; display: none; |
| | | max-width: 80vw; text-align: center; |
| | | } |
| | | .toast.show { display: block; animation: toastIn .25s ease; } |
| | | .toast.success { background: #dcfce7; border:1px solid #86efac; color:#15803d; } |
| | | .toast.error { background: #fee2e2; border:1px solid #fca5a5; color:#b91c1c; } |
| | | @keyframes toastIn { |
| | | from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } |
| | | to { opacity: 1; transform: translate(-50%, -50%) scale(1); } |
| | | } |
| | | |
| | | /* Scrollbar */ |
| | | ::-webkit-scrollbar { width: 6px; } |
| | | ::-webkit-scrollbar-track { background: transparent; } |
| | | ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } |
| | | |
| | | @media (max-width: 900px) { |
| | | body { flex-direction: column; } |
| | | .sidebar { width: 100%; min-width: 100%; height: auto; max-height: 40vh; } |
| | | .main { height: auto; } |
| | | .stats-grid { grid-template-columns: 1fr 1fr; } |
| | | .form-grid { grid-template-columns: 1fr; } |
| | | } |
| | | </style> |
| | | </head> |
| | | <body> |
| | | |
| | | <!-- ===== 侧边栏:实例列表 ===== --> |
| | | <div class="sidebar"> |
| | | <div class="sidebar-header"> |
| | | <h1>📡 Station</h1> |
| | | <span class="refresh-badge" id="refreshBadge">⏳ 5s</span> |
| | | </div> |
| | | <div class="instance-list" id="instanceList"> |
| | | <div class="empty-state"> |
| | | <div class="icon">📭</div> |
| | | <p>暂无在线实例</p> |
| | | <p style="font-size:11px;margin-top:4px">等待 JAR 心跳注册...</p> |
| | | </div> |
| | | </div> |
| | | <div class="sidebar-footer"> |
| | | <span id="connStatus">🟢 已连接</span> |
| | | <span id="instanceCount">0 实例</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ===== 主区域 ===== --> |
| | | <div class="main"> |
| | | <!-- 顶栏 --> |
| | | <div class="main-header" id="mainHeader"> |
| | | <div class="instance-title"> |
| | | <h2 style="color:var(--dim)">← 选择一个实例</h2> |
| | | </div> |
| | | <div class="btn-group"> |
| | | <button class="btn btn-success" id="btnStart" disabled onclick="showConfirm('start')">▶ 启动</button> |
| | | <button class="btn btn-danger" id="btnStop" disabled onclick="showConfirm('stop')">⏹ 停止</button> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 内容区 --> |
| | | <div class="content" id="mainContent"> |
| | | <div class="empty-state"> |
| | | <div class="icon">👈</div> |
| | | <p>从左侧列表选择一个实例查看详情</p> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 详情模板(初始隐藏) --> |
| | | <div id="detailTemplate" style="display:none"> |
| | | <!-- Tabs --> |
| | | <div class="tabs"> |
| | | <div class="tab active" data-tab="overview">概览</div> |
| | | <div class="tab" data-tab="events">事件日志</div> |
| | | <div class="tab" data-tab="config">策略参数</div> |
| | | </div> |
| | | |
| | | <!-- 概览 Tab --> |
| | | <div class="tab-content active" data-tab="overview"> |
| | | <div class="stats-grid" id="statsGrid"></div> |
| | | <div class="chart-container"> |
| | | <h3>📈 盈亏趋势(最近 PNL_SNAPSHOT 事件)</h3> |
| | | <div class="pnl-summary" id="pnlSummary"></div> |
| | | <div class="chart-wrap"><canvas id="pnlChart"></canvas></div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 事件日志 Tab --> |
| | | <div class="tab-content" data-tab="events"> |
| | | <div class="events-panel"> |
| | | <div class="events-header"> |
| | | <h3>📋 最近 100 条事件</h3> |
| | | <span style="font-size:11px;color:var(--dim)" id="eventCount">—</span> |
| | | </div> |
| | | <div style="max-height:500px;overflow-y:auto"> |
| | | <table class="events-table"> |
| | | <thead> |
| | | <tr> |
| | | <th style="width:120px">时间</th> |
| | | <th style="width:140px">类型</th> |
| | | <th>Payload</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody id="eventsBody"></tbody> |
| | | </table> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 策略参数 Tab(可编辑) --> |
| | | <div class="tab-content" data-tab="config"> |
| | | <!-- 只读状态条 --> |
| | | <div class="config-status-bar" id="configStatusBar"></div> |
| | | |
| | | <div class="chart-container"> |
| | | <div class="config-actions"> |
| | | <span style="flex:1;font-size:12px;color:var(--dim);align-self:center">💡 修改后需手动点击顶部「▶ 启动」使新配置生效</span> |
| | | <button class="btn btn-outline" id="btnResetConfig" onclick="resetConfig()">↺ 重置</button> |
| | | <button class="btn btn-success" id="btnSaveConfig" onclick="saveConfig()">💾 保存</button> |
| | | </div> |
| | | <div id="configForm"></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 确认弹窗 --> |
| | | <div class="modal-overlay" id="modalOverlay"> |
| | | <div class="modal"> |
| | | <h3 id="modalTitle">确认操作</h3> |
| | | <p id="modalMsg"></p> |
| | | <div class="modal-btns"> |
| | | <button class="btn btn-outline" onclick="closeModal()">取消</button> |
| | | <button class="btn" id="modalConfirmBtn" onclick="confirmAction()">确认</button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div id="toast"></div> |
| | | |
| | | <script> |
| | | const API = '/api/gate/station'; |
| | | let selectedMd5 = null; |
| | | let instances = {}; |
| | | let eventsCache = {}; |
| | | let pnlChart = null; |
| | | let pollingTimer = null; |
| | | let pendingAction = null; |
| | | let pnlData = []; |
| | | |
| | | // ==================== 工具 ==================== |
| | | function $(id) { return document.getElementById(id); } |
| | | function toast(msg, type) { |
| | | const t = $('toast'); |
| | | t.textContent = msg; |
| | | t.className = 'toast ' + type + ' show'; |
| | | // 清除上一次未消失的定时器,避免重复点击时闪烁 |
| | | clearTimeout(t._toastTimer); |
| | | t._toastTimer = setTimeout(() => { |
| | | t.className = 'toast'; // 移除 show → display:none,彻底隐藏不留空白框 |
| | | }, 3000); |
| | | } |
| | | function fmtNum(v) { |
| | | if (v == null || v === '') return '—'; |
| | | const n = parseFloat(v); |
| | | if (isNaN(n)) return v; |
| | | return n.toFixed(n < 1 ? 6 : 2); |
| | | } |
| | | function fmtPnl(v) { |
| | | if (v == null || v === '') return { text: '—', cls: '' }; |
| | | const n = parseFloat(v); |
| | | if (isNaN(n)) return { text: v, cls: '' }; |
| | | return { |
| | | text: (n >= 0 ? '+' : '') + n.toFixed(4), |
| | | cls: n > 0 ? 'positive' : n < 0 ? 'negative' : '' |
| | | }; |
| | | } |
| | | function relativeTime(ts) { |
| | | if (!ts) return '—'; |
| | | const diff = Date.now() - ts; |
| | | if (diff < 5000) return '刚刚'; |
| | | if (diff < 60000) return Math.floor(diff / 1000) + 's 前'; |
| | | if (diff < 3600000) return Math.floor(diff / 60000) + 'm 前'; |
| | | if (diff < 86400000) return Math.floor(diff / 3600000) + 'h 前'; |
| | | return new Date(ts).toLocaleDateString(); |
| | | } |
| | | function formatTime(ts) { |
| | | if (!ts) return '—'; |
| | | const d = new Date(ts); |
| | | return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0'); |
| | | } |
| | | |
| | | // ==================== API 请求 ==================== |
| | | async function api(url, opts) { |
| | | try { |
| | | const res = await fetch(url, opts); |
| | | const data = await res.json(); |
| | | if (data.code !== 0 && data.code !== 200) throw new Error(data.msg || '请求失败'); |
| | | return data.data !== undefined ? data.data : data.msg; |
| | | } catch (e) { |
| | | if (e.message.includes('Failed to fetch') || e.message.includes('NetworkError')) { |
| | | $('connStatus').innerHTML = '🔴 离线'; |
| | | } |
| | | throw e; |
| | | } |
| | | } |
| | | |
| | | // ==================== 实例列表 ==================== |
| | | async function refreshInstanceList() { |
| | | try { |
| | | const list = await api(API + '/list'); |
| | | $('connStatus').innerHTML = '🟢 已连接'; |
| | | const newInstances = {}; |
| | | for (const inst of (list || [])) { |
| | | newInstances[inst.apiKeyMd5] = inst; |
| | | } |
| | | instances = newInstances; |
| | | renderInstanceList(); |
| | | } catch (e) { |
| | | // 静默失败,保留旧数据 |
| | | } |
| | | } |
| | | |
| | | function renderInstanceList() { |
| | | const container = $('instanceList'); |
| | | const keys = Object.keys(instances); |
| | | |
| | | if (keys.length === 0) { |
| | | container.innerHTML = ` |
| | | <div class="empty-state"> |
| | | <div class="icon">📭</div> |
| | | <p>暂无在线实例</p> |
| | | <p style="font-size:11px;margin-top:4px">等待 JAR 心跳注册...</p> |
| | | </div>`; |
| | | } else { |
| | | container.innerHTML = keys.map(k => { |
| | | const i = instances[k]; |
| | | const isActive = i.state === 'ACTIVE' || i.state === 'OPENING'; |
| | | const dotClass = i.state === 'STOPPED' ? 'stopped' : isActive ? 'active' : 'offline'; |
| | | const pnl = fmtPnl(i.cumulativePnl); |
| | | const selected = k === selectedMd5 ? ' selected' : ''; |
| | | return ` |
| | | <div class="instance-card${selected}" onclick="selectInstance('${k}')" data-md5="${k}"> |
| | | <div class="top-row"> |
| | | <span class="key">${i.contract || '—'}</span> |
| | | <span class="status-dot ${dotClass}" title="${i.state || 'UNKNOWN'}"></span> |
| | | </div> |
| | | <div class="contract">${k.substring(0, 16)}...</div> |
| | | <div class="meta"> |
| | | <span>轮次 ${i.currentRound ?? 0}</span> |
| | | <span class="pnl ${pnl.cls}">${pnl.text}</span> |
| | | <span>${relativeTime(i.lastSeen)}</span> |
| | | </div> |
| | | </div>`; |
| | | }).join(''); |
| | | } |
| | | $('instanceCount').textContent = keys.length + ' 实例'; |
| | | } |
| | | |
| | | function selectInstance(md5) { |
| | | selectedMd5 = md5; |
| | | eventsCache[md5] = null; // 强制刷新 |
| | | pnlData = []; |
| | | renderInstanceList(); |
| | | loadInstanceDetail(md5, true); |
| | | } |
| | | |
| | | async function loadInstanceDetail(md5, full) { |
| | | const inst = instances[md5]; |
| | | if (!inst) return; |
| | | |
| | | // 显示详情模板,隐藏空状态 |
| | | const content = $('mainContent'); |
| | | const tmpl = $('detailTemplate'); |
| | | if (!content.querySelector('.tabs')) { |
| | | content.innerHTML = tmpl.innerHTML; |
| | | // 绑定 tab 切换 |
| | | content.querySelectorAll('.tab').forEach(t => { |
| | | t.addEventListener('click', () => switchTab(t.dataset.tab)); |
| | | }); |
| | | } |
| | | |
| | | // 更新 header |
| | | const header = $('mainHeader'); |
| | | header.querySelector('.instance-title').innerHTML = ` |
| | | <h2>${inst.contract || '—'} <span style="font-size:12px;color:var(--dim);font-weight:400">${md5.substring(0,12)}...</span></h2> |
| | | <span class="state-badge ${sanitizeState(inst.state)}">${inst.state || 'UNKNOWN'}</span> |
| | | `; |
| | | $('btnStart').disabled = false; |
| | | $('btnStop').disabled = false; |
| | | |
| | | // 更新概览统计 |
| | | renderStats(inst); |
| | | |
| | | // 加载事件(内部会渲染事件表格 + PNL 图表) |
| | | await loadEvents(md5); |
| | | |
| | | // 策略参数表单:仅全量加载(切换实例)时渲染,避免轮询清空用户正在编辑的内容 |
| | | if (full) { |
| | | loadConfig(md5); |
| | | } |
| | | } |
| | | |
| | | function sanitizeState(s) { |
| | | if (!s) return ''; |
| | | const valid = ['ACTIVE', 'STOPPED', 'WAITING_KLINE', 'OPENING']; |
| | | return valid.includes(s) ? s : ''; |
| | | } |
| | | |
| | | function switchTab(name) { |
| | | document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); |
| | | document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); |
| | | document.querySelector(`.tab[data-tab="${name}"]`).classList.add('active'); |
| | | document.querySelector(`.tab-content[data-tab="${name}"]`).classList.add('active'); |
| | | } |
| | | |
| | | // ==================== 统计卡片 ==================== |
| | | function renderStats(inst) { |
| | | const pnl = fmtPnl(inst.cumulativePnl); |
| | | const principal = inst.principal ? parseFloat(inst.principal) : 0; |
| | | const cumpnl = inst.cumulativePnl ? parseFloat(inst.cumulativePnl) : 0; |
| | | const totalEquity = principal + cumpnl; |
| | | const roi = principal > 0 ? ((cumpnl / principal) * 100) : 0; |
| | | |
| | | $('statsGrid').innerHTML = ` |
| | | <div class="stat-card"> |
| | | <div class="label">累计已实现盈亏</div> |
| | | <div class="value ${pnl.cls}">${pnl.text}</div> |
| | | <div class="sub">USDT</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">初始本金</div> |
| | | <div class="value">${fmtNum(inst.principal)}</div> |
| | | <div class="sub">USDT</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">估算总权益</div> |
| | | <div class="value">${fmtNum(totalEquity)}</div> |
| | | <div class="sub">本金 + 已实现盈亏</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">收益率 (ROI)</div> |
| | | <div class="value ${roi >= 0 ? 'positive' : 'negative'}" style="color:${roi >= 0 ? 'var(--green)' : 'var(--danger)'}">${roi >= 0 ? '+' : ''}${roi.toFixed(4)}%</div> |
| | | <div class="sub">累计</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">当前轮次</div> |
| | | <div class="value">${inst.currentRound ?? 0}</div> |
| | | <div class="sub">杠杆 ${inst.leverage || '—'}x</div> |
| | | </div> |
| | | <div class="stat-card"> |
| | | <div class="label">最后心跳</div> |
| | | <div class="value" style="font-size:16px">${relativeTime(inst.lastSeen)}</div> |
| | | <div class="sub">${inst.hostPort || '—'}</div> |
| | | </div> |
| | | `; |
| | | } |
| | | |
| | | // ==================== 事件日志 ==================== |
| | | async function loadEvents(md5) { |
| | | try { |
| | | const events = await api(API + '/events?apiKeyMd5=' + encodeURIComponent(md5)); |
| | | eventsCache[md5] = events || []; |
| | | renderEvents(events || []); |
| | | } catch (e) { |
| | | eventsCache[md5] = []; |
| | | renderEvents([]); |
| | | } |
| | | } |
| | | |
| | | function renderEvents(events) { |
| | | const tbody = $('eventsBody'); |
| | | $('eventCount').textContent = events.length + ' 条'; |
| | | |
| | | if (events.length === 0) { |
| | | tbody.innerHTML = `<tr><td colspan="3" style="text-align:center;padding:30px;color:var(--dim)">暂无事件记录</td></tr>`; |
| | | pnlData = []; |
| | | renderPnlChart(); |
| | | return; |
| | | } |
| | | |
| | | // 收集 PNL_SNAPSHOT 数据用于图表 |
| | | pnlData = events |
| | | .filter(e => e.eventType === 'PNL_SNAPSHOT') |
| | | .map(e => { |
| | | let payload = {}; |
| | | try { payload = JSON.parse(e.payloadJson || '{}'); } catch (_) {} |
| | | return { |
| | | time: e.eventTime || e.createTime, |
| | | cumulativePnl: parseFloat(payload.cumulativePnl || 0), |
| | | unrealizedPnl: parseFloat(payload.unrealizedPnl || 0), |
| | | totalEquity: parseFloat(payload.totalEquity || 0), |
| | | markPrice: parseFloat(payload.markPrice || 0) |
| | | }; |
| | | }) |
| | | .slice(-50); |
| | | |
| | | tbody.innerHTML = events.slice(0, 100).map(e => { |
| | | let payloadStr = e.payloadJson || ''; |
| | | // 截断显示 |
| | | let shortPayload = payloadStr; |
| | | try { |
| | | const obj = JSON.parse(payloadStr); |
| | | const keys = Object.keys(obj); |
| | | shortPayload = keys.map(k => `${k}: ${obj[k]}`).join(', '); |
| | | } catch (_) {} |
| | | if (shortPayload.length > 80) shortPayload = shortPayload.substring(0, 80) + '...'; |
| | | |
| | | const ts = e.eventTime || (e.createTime ? new Date(e.createTime).getTime() : null); |
| | | return ` |
| | | <tr> |
| | | <td class="relative-time" title="${new Date(ts).toLocaleString()}">${formatTime(ts)}</td> |
| | | <td><span class="event-tag ${e.eventType || ''}">${e.eventType || '—'}</span></td> |
| | | <td class="payload-preview" title="${payloadStr}">${shortPayload || '—'}</td> |
| | | </tr>`; |
| | | }).join(''); |
| | | |
| | | renderPnlChart(); |
| | | } |
| | | |
| | | // ==================== PNL 图表 ==================== |
| | | function renderPnlChart() { |
| | | const canvas = document.getElementById('pnlChart'); |
| | | if (!canvas) return; |
| | | |
| | | // 顶部摘要指标(当前值 + 较上个快照涨跌) |
| | | renderPnlSummary(); |
| | | |
| | | if (pnlChart) pnlChart.destroy(); |
| | | |
| | | if (pnlData.length === 0) { |
| | | const ctx = canvas.getContext('2d'); |
| | | ctx.clearRect(0, 0, canvas.width, canvas.height); |
| | | ctx.fillStyle = '#6b7280'; |
| | | ctx.font = '14px sans-serif'; |
| | | ctx.textAlign = 'center'; |
| | | ctx.fillText('暂无 PNL 数据(等待 PNL_SNAPSHOT 事件上报)', canvas.width / 2, canvas.height / 2); |
| | | return; |
| | | } |
| | | |
| | | const labels = pnlData.map(d => formatTime(d.time)); |
| | | const cumulative = pnlData.map(d => d.cumulativePnl); |
| | | const unrealized = pnlData.map(d => d.unrealizedPnl); |
| | | const total = pnlData.map(d => d.totalEquity); |
| | | |
| | | // 面积渐变填充:顶部半透明 → 底部透明 |
| | | function gradient(rgb, topAlpha) { |
| | | return (context) => { |
| | | const { ctx, chartArea } = context.chart; |
| | | if (!chartArea) return `rgba(${rgb},${topAlpha})`; |
| | | const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); |
| | | g.addColorStop(0, `rgba(${rgb},${topAlpha})`); |
| | | g.addColorStop(1, `rgba(${rgb},0)`); |
| | | return g; |
| | | }; |
| | | } |
| | | |
| | | pnlChart = new Chart(canvas, { |
| | | type: 'line', |
| | | data: { |
| | | labels, |
| | | datasets: [ |
| | | { |
| | | label: '已实现盈亏', |
| | | data: cumulative, |
| | | borderColor: '#16a34a', |
| | | backgroundColor: gradient('22,163,74', 0.22), |
| | | fill: true, |
| | | tension: 0.3, |
| | | pointRadius: 2, |
| | | pointBackgroundColor: '#16a34a', |
| | | pointBorderColor: '#ffffff', |
| | | pointBorderWidth: 1, |
| | | borderWidth: 1.8 |
| | | }, |
| | | { |
| | | label: '未实现盈亏', |
| | | data: unrealized, |
| | | borderColor: '#d97706', |
| | | backgroundColor: gradient('217,119,6', 0.16), |
| | | fill: true, |
| | | tension: 0.3, |
| | | pointRadius: 0, |
| | | borderWidth: 1.5, |
| | | borderDash: [4, 3] |
| | | }, |
| | | { |
| | | label: '总权益', |
| | | data: total, |
| | | borderColor: '#3b82f6', |
| | | backgroundColor: gradient('59,130,246', 0.10), |
| | | fill: true, |
| | | tension: 0.3, |
| | | pointRadius: 0, |
| | | borderWidth: 2, |
| | | yAxisID: 'y1' |
| | | } |
| | | ] |
| | | }, |
| | | options: { |
| | | responsive: true, |
| | | maintainAspectRatio: false, |
| | | interaction: { intersect: false, mode: 'index' }, |
| | | plugins: { |
| | | legend: { |
| | | labels: { color: '#6b7280', font: { size: 11 }, usePointStyle: true, padding: 15, boxWidth: 8 } |
| | | }, |
| | | tooltip: { |
| | | backgroundColor: '#ffffff', |
| | | borderColor: '#e3e8f0', |
| | | borderWidth: 1, |
| | | titleColor: '#1f2937', |
| | | bodyColor: '#1f2937', |
| | | titleFont: { size: 12 }, |
| | | bodyFont: { size: 12 }, |
| | | padding: 10, |
| | | callbacks: { |
| | | label: (ctx) => { |
| | | const isEquity = ctx.dataset.label === '总权益'; |
| | | const v = ctx.parsed.y; |
| | | const txt = isEquity |
| | | ? v.toFixed(2) |
| | | : (v >= 0 ? '+' : '') + v.toFixed(4); |
| | | return ctx.dataset.label + ': ' + txt; |
| | | } |
| | | } |
| | | } |
| | | }, |
| | | scales: { |
| | | x: { |
| | | ticks: { color: '#6b7280', font: { size: 9 }, maxTicksLimit: 10, maxRotation: 0 }, |
| | | grid: { color: 'rgba(0,0,0,0.06)' } |
| | | }, |
| | | y: { |
| | | type: 'linear', |
| | | display: true, |
| | | position: 'left', |
| | | ticks: { color: '#6b7280', font: { size: 10 }, callback: v => v.toFixed(4) }, |
| | | grid: { color: 'rgba(0,0,0,0.06)' } |
| | | }, |
| | | y1: { |
| | | type: 'linear', |
| | | display: true, |
| | | position: 'right', |
| | | ticks: { color: '#3b82f6', font: { size: 10 }, callback: v => v.toFixed(2) }, |
| | | grid: { drawOnChartArea: false } |
| | | } |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | |
| | | // 盈亏趋势顶部摘要:当前值 + 较上个快照涨跌 |
| | | function renderPnlSummary() { |
| | | const box = document.getElementById('pnlSummary'); |
| | | if (!box) return; |
| | | if (pnlData.length === 0) { |
| | | box.innerHTML = ''; |
| | | return; |
| | | } |
| | | const cur = pnlData[pnlData.length - 1]; |
| | | const prev = pnlData.length > 1 ? pnlData[pnlData.length - 2] : null; |
| | | |
| | | const items = [ |
| | | { label: '已实现盈亏', key: 'cumulativePnl', signed: true, digits: 4, fmt: v => (v >= 0 ? '+' : '') + v.toFixed(4) }, |
| | | { label: '未实现盈亏', key: 'unrealizedPnl', signed: true, digits: 4, fmt: v => (v >= 0 ? '+' : '') + v.toFixed(4) }, |
| | | { label: '总权益', key: 'totalEquity', signed: false, digits: 2, fmt: v => v.toFixed(2) }, |
| | | { label: '标记价', key: 'markPrice', signed: false, digits: 4, fmt: v => v.toFixed(4) } |
| | | ]; |
| | | |
| | | box.innerHTML = items.map(it => { |
| | | const v = cur[it.key]; |
| | | const cls = (it.signed && v !== 0) ? (v > 0 ? 'positive' : 'negative') : ''; |
| | | let deltaHtml = '<div class="s-delta">—</div>'; |
| | | if (prev != null) { |
| | | const d = v - prev[it.key]; |
| | | if (d !== 0) { |
| | | const dCls = d > 0 ? 'positive' : 'negative'; |
| | | deltaHtml = `<div class="s-delta ${dCls}">${d > 0 ? '▲' : '▼'} ${Math.abs(d).toFixed(it.digits)}</div>`; |
| | | } else { |
| | | deltaHtml = '<div class="s-delta">— 持平</div>'; |
| | | } |
| | | } |
| | | return `<div class="pnl-summary-item"> |
| | | <div class="s-label">${it.label}</div> |
| | | <div class="s-value ${cls}">${it.fmt(v)}</div> |
| | | ${deltaHtml} |
| | | </div>`; |
| | | }).join(''); |
| | | } |
| | | |
| | | // ==================== 策略参数 ==================== |
| | | // 从事件流提取最近一次 STRATEGY_START 的完整参数快照(toParamsMap 含全部 18 个字段) |
| | | function extractConfigFromEvents(events) { |
| | | for (let i = events.length - 1; i >= 0; i--) { |
| | | if (events[i].eventType === 'STRATEGY_START') { |
| | | try { |
| | | return JSON.parse(events[i].payloadJson || '{}'); |
| | | } catch (_) { |
| | | return null; |
| | | } |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | // 默认参数(与后端 GateConfigDTO.defaultsFor() 保持一致) |
| | | const DEFAULT_CONFIG = { |
| | | gridRate: 0.005, |
| | | expectedProfit: 0.15, |
| | | maxLoss: 1.5, |
| | | baseQuantity: '2', |
| | | quantity: '2', |
| | | maxPositionSize: 4, |
| | | stopLossCount: 0, |
| | | takeProfitGridSpan: 2, |
| | | rounds: 0, |
| | | stopLossCountMode: 'dual', |
| | | addPositionInterval: 3, |
| | | addPositionQuantity: 1, |
| | | maxPositionPerSide: 0, |
| | | addPositionStartThreshold: 1, |
| | | placeExcessTakeProfit: false, |
| | | priceDriveEnabled: true, |
| | | }; |
| | | |
| | | async function loadConfig(md5) { |
| | | // 优先:DB strategy_status(保存的配置已持久化,STRATEGY_START 也会写入这里,始终是最新值) |
| | | try { |
| | | const status = await api(API + '/strategy-status?apiKeyMd5=' + encodeURIComponent(md5)); |
| | | if (status) { |
| | | // DB 字段 totalRounds 映射为前端 key rounds |
| | | if (status.totalRounds != null && status.rounds == null) status.rounds = status.totalRounds; |
| | | renderConfig(status); |
| | | return; |
| | | } |
| | | } catch (e) { |
| | | // 请求失败 → 回退事件流 / 默认值 |
| | | } |
| | | // 回退:最近 STRATEGY_START 事件 payload(字段最全,含 rounds/expectedProfit/maxLoss 等) |
| | | const config = extractConfigFromEvents(eventsCache[md5] || []); |
| | | if (config) { |
| | | renderConfig(config); |
| | | return; |
| | | } |
| | | // 兜底:默认值填充,方便用户预填后启动 |
| | | renderConfig(DEFAULT_CONFIG); |
| | | } |
| | | |
| | | // 可编辑的策略参数字段(jtype 标记 Java 类型用于正确序列化,group 用于分组渲染) |
| | | const EDITABLE_FIELDS = [ |
| | | { key: 'gridRate', label: '网格间距比例', type: 'number', step: '0.0001', placeholder: '0.005', jtype: 'decimal', group: 'grid', tip: '短基价 × 该比例 = 绝对步长' }, |
| | | { key: 'baseQuantity', label: '基底开仓张数', type: 'number', placeholder: '2', jtype: 'string', group: 'grid' }, |
| | | { key: 'quantity', label: '每次下单张数', type: 'number', placeholder: '2', jtype: 'string', group: 'grid' }, |
| | | { key: 'maxPositionSize', label: '最大持仓张数', type: 'number', placeholder: '4', jtype: 'int', group: 'grid' }, |
| | | { key: 'takeProfitGridSpan', label: '止盈网格跨度', type: 'number', placeholder: '2', jtype: 'int', group: 'grid' }, |
| | | |
| | | { key: 'expectedProfit', label: '预期收益 (USDT)', type: 'number', step: '0.01', placeholder: '0.15', jtype: 'decimal', group: 'risk' }, |
| | | { key: 'maxLoss', label: '最大亏损 (USDT)', type: 'number', step: '0.01', placeholder: '1.5', jtype: 'decimal', group: 'risk' }, |
| | | { key: 'stopLossCount', label: '止损阶梯次数', type: 'number', placeholder: '0', jtype: 'int', group: 'risk', tip: '0 = 禁用阶梯止损' }, |
| | | |
| | | { key: 'stopLossCountMode', label: '止损统计方式', type: 'select', options: [{ v: 'dual', l: '双向统一统计' }, { v: 'single', l: '单向分别统计' }], group: 'addon' }, |
| | | { key: 'addPositionInterval', label: '加仓间隔 (次)', type: 'number', placeholder: '3', jtype: 'int', group: 'addon', tip: '每隔 N 次止损触发一次加仓' }, |
| | | { key: 'addPositionQuantity', label: '加仓数量 (张)', type: 'number', placeholder: '1', jtype: 'int', group: 'addon' }, |
| | | { key: 'maxPositionPerSide', label: '单边最大仓位 (0=不限)', type: 'number', placeholder: '0', jtype: 'int', group: 'addon' }, |
| | | { key: 'addPositionStartThreshold', label: '加仓启动阈值 (次)', type: 'number', placeholder: '1', jtype: 'int', group: 'addon', tip: '前 N 次止损不触发加仓' }, |
| | | |
| | | { key: 'rounds', label: '运行轮数 (0=不限)', type: 'number', placeholder: '0', jtype: 'int', group: 'run', tip: '盈利重启达此轮数后停止' }, |
| | | { key: 'placeExcessTakeProfit', label: '超额止盈', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'run' }, |
| | | { key: 'priceDriveEnabled', label: '价格驱动', type: 'select', options: [{ v: 'true', l: '开启' }, { v: 'false', l: '关闭' }], group: 'run' }, |
| | | ]; |
| | | |
| | | // 分组定义(渲染顺序) |
| | | const CONFIG_GROUPS = [ |
| | | { id: 'grid', name: '📐 网格参数' }, |
| | | { id: 'risk', name: '🛡️ 风控参数' }, |
| | | { id: 'addon', name: '📈 加仓 & 止损' }, |
| | | { id: 'run', name: '⚡ 运行控制' }, |
| | | ]; |
| | | |
| | | // 字段是否处于"已修改"状态(用于高亮) |
| | | const modifiedFields = new Set(); |
| | | |
| | | function fieldHtml(f) { |
| | | const inputId = 'cfg_' + f.key; |
| | | let inner; |
| | | if (f.type === 'select') { |
| | | inner = `<select id="${inputId}" onchange="markModified('${f.key}')">${f.options.map(o => `<option value="${o.v}">${o.l}</option>`).join('')}</select>`; |
| | | } else { |
| | | inner = `<input id="${inputId}" type="${f.type}" placeholder="${f.placeholder || ''}" ${f.step ? 'step="' + f.step + '"' : ''} oninput="markModified('${f.key}')">`; |
| | | } |
| | | const tip = f.tip ? `<div class="field-tip">${f.tip}</div>` : ''; |
| | | return `<div class="form-group" id="group_${f.key}"><label>${f.label}</label>${inner}${tip}</div>`; |
| | | } |
| | | |
| | | function renderConfig(status) { |
| | | const form = $('configForm'); |
| | | if (!status) { |
| | | form.innerHTML = `<div style="padding:20px;color:var(--dim);text-align:center">暂无策略参数</div>`; |
| | | return; |
| | | } |
| | | // 1. 只读状态条 |
| | | renderConfigStatusBar(); |
| | | |
| | | // 2. 分组渲染表单 |
| | | let html = ''; |
| | | for (const g of CONFIG_GROUPS) { |
| | | const fields = EDITABLE_FIELDS.filter(f => f.group === g.id); |
| | | if (fields.length === 0) continue; |
| | | html += `<div class="config-section"> |
| | | <div class="config-section-title">${g.name}<span class="count">${fields.length} 项</span></div> |
| | | <div class="form-grid">${fields.map(fieldHtml).join('')}</div> |
| | | </div>`; |
| | | } |
| | | form.innerHTML = html; |
| | | |
| | | // 3. 填充当前值 |
| | | for (const f of EDITABLE_FIELDS) { |
| | | const el = document.getElementById('cfg_' + f.key); |
| | | if (!el) continue; |
| | | let val = status[f.key]; |
| | | if (val === true) val = 'true'; |
| | | else if (val === false) val = 'false'; |
| | | else if (val == null || val === '') val = ''; |
| | | else val = String(val); |
| | | el.value = val; |
| | | } |
| | | modifiedFields.clear(); |
| | | } |
| | | |
| | | function renderConfigStatusBar() { |
| | | const bar = $('configStatusBar'); |
| | | const inst = instances[selectedMd5]; |
| | | if (!inst) { |
| | | bar.innerHTML = ''; |
| | | return; |
| | | } |
| | | const pnl = fmtPnl(inst.cumulativePnl); |
| | | const items = [ |
| | | { label: '合约', value: inst.contract || '—', cls: '' }, |
| | | { label: '状态', value: inst.state || '—', cls: 'dim' }, |
| | | { label: '杠杆', value: (inst.leverage || '—') + 'x', cls: '' }, |
| | | { label: '当前轮次', value: inst.currentRound ?? 0, cls: '' }, |
| | | { label: '累计盈亏', value: pnl.text, cls: pnl.cls }, |
| | | { label: '初始本金', value: fmtNum(inst.principal), cls: '' }, |
| | | ]; |
| | | bar.innerHTML = items.map(i => |
| | | `<div class="config-status-item"><div class="label">${i.label}</div><div class="value ${i.cls}">${i.value}</div></div>` |
| | | ).join(''); |
| | | } |
| | | |
| | | function markModified(key) { |
| | | modifiedFields.add(key); |
| | | const g = document.getElementById('group_' + key); |
| | | if (g) g.classList.add('modified'); |
| | | } |
| | | |
| | | function resetConfig() { |
| | | if (!selectedMd5) return; |
| | | // 重新从当前数据源加载,覆盖用户未保存的编辑 |
| | | loadConfig(selectedMd5); |
| | | toast('已重置为当前生效参数', 'success'); |
| | | } |
| | | |
| | | async function saveConfig() { |
| | | if (!selectedMd5) return; |
| | | const params = {}; |
| | | for (const f of EDITABLE_FIELDS) { |
| | | const el = document.getElementById('cfg_' + f.key); |
| | | if (!el) continue; |
| | | const raw = el.value; |
| | | // 跳过空值:缺失字段在 DTO 中为 null,buildFromDTO 会用 nvl() 填充默认值 |
| | | if (raw === '' || raw == null) continue; |
| | | |
| | | if (f.jtype === 'string') { |
| | | params[f.key] = raw; |
| | | } else if (f.jtype === 'int') { |
| | | params[f.key] = parseInt(raw, 10); |
| | | } else if (f.jtype === 'decimal') { |
| | | params[f.key] = parseFloat(raw); |
| | | } else { |
| | | // select 等保持不变 |
| | | params[f.key] = raw; |
| | | } |
| | | } |
| | | try { |
| | | const msg = await api(API + '/config?apiKeyMd5=' + encodeURIComponent(selectedMd5), { |
| | | method: 'POST', |
| | | headers: { 'Content-Type': 'application/json' }, |
| | | body: JSON.stringify(params) |
| | | }); |
| | | toast(msg || '参数已保存,点击「启动」使新配置生效', 'success'); |
| | | // 保存成功后清除修改高亮 |
| | | modifiedFields.clear(); |
| | | document.querySelectorAll('.form-group.modified').forEach(g => g.classList.remove('modified')); |
| | | } catch (e) { |
| | | toast(e.message, 'error'); |
| | | } |
| | | } |
| | | |
| | | // ==================== 启停操作 ==================== |
| | | function showConfirm(action) { |
| | | if (!selectedMd5) return; |
| | | pendingAction = action; |
| | | const verb = action === 'start' ? '启动' : '停止'; |
| | | $('modalTitle').textContent = `确认${verb}`; |
| | | $('modalMsg').textContent = `确定要${verb}实例 ${selectedMd5.substring(0, 12)}... 的策略吗?`; |
| | | $('modalConfirmBtn').textContent = verb; |
| | | $('modalConfirmBtn').className = 'btn ' + (action === 'start' ? 'btn-success' : 'btn-danger'); |
| | | $('modalOverlay').classList.add('show'); |
| | | } |
| | | |
| | | function closeModal() { |
| | | $('modalOverlay').classList.remove('show'); |
| | | pendingAction = null; |
| | | } |
| | | |
| | | async function confirmAction() { |
| | | if (!pendingAction || !selectedMd5) return; |
| | | const action = pendingAction; |
| | | closeModal(); |
| | | |
| | | try { |
| | | const msg = await api(API + '/' + action + '?apiKeyMd5=' + encodeURIComponent(selectedMd5), { method: 'POST' }); |
| | | toast(msg || '指令已发送', 'success'); |
| | | // 延迟刷新等待 ACK |
| | | setTimeout(refreshInstanceList, 2000); |
| | | } catch (e) { |
| | | toast(e.message, 'error'); |
| | | } |
| | | } |
| | | |
| | | // ==================== 轮询 ==================== |
| | | async function poll() { |
| | | await refreshInstanceList(); |
| | | // 如果有选中实例,刷新详情(full=false,不重渲染参数表单,避免清空用户编辑) |
| | | if (selectedMd5 && instances[selectedMd5]) { |
| | | await loadInstanceDetail(selectedMd5, false); |
| | | } |
| | | } |
| | | |
| | | // ==================== 初始化 ==================== |
| | | function startPolling() { |
| | | poll(); // 立即执行一次 |
| | | pollingTimer = setInterval(poll, 5000); |
| | | } |
| | | |
| | | // 倒计时显示 |
| | | setInterval(() => { |
| | | const badge = $('refreshBadge'); |
| | | if (badge) badge.textContent = '⏳ 5s'; |
| | | setTimeout(() => { if (badge) badge.textContent = '⏳ 4s'; }, 1000); |
| | | setTimeout(() => { if (badge) badge.textContent = '⏳ 3s'; }, 2000); |
| | | setTimeout(() => { if (badge) badge.textContent = '⏳ 2s'; }, 3000); |
| | | setTimeout(() => { if (badge) badge.textContent = '⏳ 1s'; }, 4000); |
| | | }, 5000); |
| | | |
| | | startPolling(); |
| | | </script> |
| | | </body> |
| | | </html> |