Administrator
12 hours ago 6e91f4be557aa70a61d941ff13545be373b78608
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;
@@ -109,6 +129,10 @@
        .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; }
        /* ===== 手动控制面板 ===== */
        .manual-actions { display: flex; flex-wrap: wrap; gap: 8px; }
        .manual-actions .btn { padding: 7px 14px; }
        /* ===== 内容区(Tabs) ===== */
        .content { flex: 1; overflow-y: auto; padding: 16px 24px; }
@@ -278,6 +302,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 {
@@ -315,7 +346,7 @@
<div class="sidebar">
    <div class="sidebar-header">
        <h1>📡 Station</h1>
        <span class="refresh-badge" id="refreshBadge">⏳ 5s</span>
        <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">
@@ -338,8 +369,8 @@
            <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>
            <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>
@@ -352,8 +383,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>
@@ -365,6 +396,17 @@
    <div class="tab-content active" data-tab="overview">
        <div class="stats-grid" id="statsGrid"></div>
        <div class="chart-container">
            <h3>⚙️ 手动控制 <span style="font-size:11px;color:var(--dim);font-weight:400">(实时下发 MQ 指令,策略运行中可用)</span></h3>
            <div class="manual-actions">
                <button class="btn btn-outline" id="btnCancelLongTpSl" disabled onclick="showConfirm('cancel-long-tp-sl','取消多仓止盈止损')">取消多仓止盈止损</button>
                <button class="btn btn-outline" id="btnCancelShortTpSl" disabled onclick="showConfirm('cancel-short-tp-sl','取消空仓止盈止损')">取消空仓止盈止损</button>
                <button class="btn btn-outline" id="btnRebuildLongTpSl" disabled onclick="showConfirm('rebuild-long-tp-sl','生成多仓止盈止损')">生成多仓止盈止损</button>
                <button class="btn btn-outline" id="btnRebuildShortTpSl" disabled onclick="showConfirm('rebuild-short-tp-sl','生成空仓止盈止损')">生成空仓止盈止损</button>
                <button class="btn btn-danger" id="btnCloseLong" disabled onclick="showConfirm('close-long','多单平仓')">多单平仓</button>
                <button class="btn btn-danger" id="btnCloseShort" disabled onclick="showConfirm('close-short','空单平仓')">空单平仓</button>
            </div>
        </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>
@@ -373,6 +415,24 @@
    <!-- 事件日志 Tab -->
    <div class="tab-content" data-tab="events">
        <div class="events-panel" style="margin-bottom:16px">
            <div class="events-header">
                <h3>🎯 每轮止损次数</h3>
                <button class="btn btn-outline" onclick="refreshRoundStopLoss()" style="font-size:11px;padding:3px 10px">🔄 刷新</button>
            </div>
            <div style="max-height:300px;overflow-y:auto">
                <table class="events-table">
                    <thead>
                        <tr>
                            <th style="width:120px">轮次</th>
                            <th style="width:120px">多仓止损</th>
                            <th>空仓止损</th>
                        </tr>
                    </thead>
                    <tbody id="roundStopLossBody"></tbody>
                </table>
            </div>
        </div>
        <div class="events-panel">
            <div class="events-header">
                <h3>📋 最近 100 条事件</h3>
@@ -407,7 +467,7 @@
            <div id="configForm"></div>
        </div>
    </div>
</div>
</template>
<!-- 确认弹窗 -->
<div class="modal-overlay" id="modalOverlay">
@@ -421,6 +481,20 @@
    </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>
@@ -429,12 +503,16 @@
let instances = {};
let eventsCache = {};
let pnlChart = null;
let pollingTimer = null;
let pendingAction = null;
let pnlData = [];
// ==================== 工具 ====================
function $(id) { return document.getElementById(id); }
/** HTML 转义 — 所有来自服务端/JAR 的动态字符串插值前必须使用,防止属性逃逸注入 */
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => (
    { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
function toast(msg, type) {
    const t = $('toast');
    t.textContent = msg;
@@ -476,17 +554,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);
    }
}
@@ -501,9 +600,24 @@
        }
        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() {
@@ -521,19 +635,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>`;
@@ -558,21 +680,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>
    `;
    $('btnStart').disabled = false;
    $('btnStop').disabled = false;
    // 更新 header(含别名,未设置别名时不显示)
    renderHeader(inst);
    // 更新概览统计
    renderStats(inst);
@@ -580,10 +698,33 @@
    // 加载事件(内部会渲染事件表格 + PNL 图表)
    await loadEvents(md5);
    // 加载每轮止损次数
    loadRoundStopLoss(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) {
@@ -593,10 +734,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();
}
// ==================== 统计卡片 ====================
@@ -604,24 +749,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>
@@ -631,12 +785,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>
    `;
}
@@ -674,11 +828,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 || '';
@@ -694,13 +847,45 @@
        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('');
    renderPnlChart();
}
// ==================== 每轮止损次数 ====================
async function loadRoundStopLoss(md5) {
    try {
        const rows = await api(API + '/round-stop-loss?apiKeyMd5=' + encodeURIComponent(md5));
        renderRoundStopLoss(rows || []);
    } catch (e) {
        renderRoundStopLoss([]);
    }
}
function renderRoundStopLoss(rows) {
    const tbody = $('roundStopLossBody');
    if (!tbody) return;
    if (!rows || rows.length === 0) {
        tbody.innerHTML = `<tr><td colspan="3" style="text-align:center;padding:20px;color:var(--dim)">暂无止损记录</td></tr>`;
        return;
    }
    tbody.innerHTML = rows.map(r => {
        const roundLabel = (r.roundNum === null || r.roundNum === undefined) ? '进行中' : ('第 ' + r.roundNum + ' 轮');
        return `
        <tr>
            <td>${esc(roundLabel)}</td>
            <td>${r.longLossCount ?? 0}</td>
            <td>${r.shortLossCount ?? 0}</td>
        </tr>`;
    }).join('');
}
function refreshRoundStopLoss() {
    if (selectedMd5) loadRoundStopLoss(selectedMd5);
}
// ==================== PNL 图表 ====================
@@ -849,8 +1034,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 => {
@@ -863,7 +1047,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">
@@ -905,8 +1089,15 @@
    addPositionQuantity: 1,
    maxPositionPerSide: 0,
    addPositionStartThreshold: 1,
    placeExcessTakeProfit: false,
    placeExcessTakeProfit: 'off',
    priceDriveEnabled: true,
    dualStopLossEnabled: false,
    longStopLossPrice: '',
    shortStopLossPrice: '',
    tpTriggerOppositeAddEnabled: false,
    tpTriggerOppositeAddQuantity: 0,
    baseTakeProfitPerGrid: 0,
    baseTakeProfitGridCount: 0,
};
async function loadConfig(md5) {
@@ -939,6 +1130,8 @@
    { 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: 'baseTakeProfitPerGrid', label: '单格止盈张数', type: 'number', step: 'any', placeholder: '0', jtype: 'decimal', group: 'grid', tip: '基础仓位止盈:每个网格挂的止盈张数(支持小数)' },
    { key: 'baseTakeProfitGridCount', label: '止盈网格数量', type: 'number', placeholder: '0', jtype: 'int', group: 'grid', tip: '基础仓位止盈:挂止盈的网格数量' },
    { 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' },
@@ -949,10 +1142,16 @@
    { 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: 'tpTriggerOppositeAddEnabled', label: '止盈触发对向加仓', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'addon', tip: '止盈单成交后触发对向加仓' },
    { key: 'tpTriggerOppositeAddQuantity', label: '对向加仓挂单数量 (张)', type: 'number', placeholder: '0', jtype: 'int', group: 'addon', tip: '止盈成交后在对向挂出的加仓张数' },
    { 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: 'placeExcessTakeProfit', label: '止盈模式', type: 'select', options: [{ v: 'off', l: '关闭' }, { v: 'concentrated', l: '集中止盈' }, { v: 'dispersed', l: '分散止盈' }], group: 'run', tip: '超出基础仓位的加仓部分如何挂止盈' },
    { key: 'priceDriveEnabled', label: '价格驱动', type: 'select', options: [{ v: 'true', l: '开启' }, { v: 'false', l: '关闭' }], group: 'run' },
    { key: 'dualStopLossEnabled', label: '双开止损触发', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'dual-sl', tip: '开启后按下方多空止损触发价初始化双向止损单,任一触发后重挂另一侧止损' },
    { key: 'longStopLossPrice', label: '多头止损触发价', type: 'number', step: 'any', placeholder: '如 2540', jtype: 'string', group: 'dual-sl', tip: '价格跌至该值时触发多头止损' },
    { key: 'shortStopLossPrice', label: '空头止损触发价', type: 'number', step: 'any', placeholder: '如 2650', jtype: 'string', group: 'dual-sl', tip: '价格涨至该值时触发空头止损' },
];
// 分组定义(渲染顺序)
@@ -960,6 +1159,7 @@
    { id: 'grid', name: '📐 网格参数' },
    { id: 'risk', name: '🛡️ 风控参数' },
    { id: 'addon', name: '📈 加仓 & 止损' },
    { id: 'dual-sl', name: '🎯 双开止损触发' },
    { id: 'run', name: '⚡ 运行控制' },
];
@@ -1022,12 +1222,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>`
@@ -1084,14 +1284,38 @@
}
// ==================== 启停操作 ====================
function showConfirm(action) {
// 按实例状态联动启用/禁用 启动/停止 按钮,防止误触(JAR 的 START 会平仓重建策略)
function updateActionButtons(inst) {
    const startBtn = $('btnStart');
    const stopBtn = $('btnStop');
    // 手动控制按钮(止盈止损/平仓):需策略运行中才有持仓/挂单可操作
    const manualIds = ['btnCancelLongTpSl', 'btnCancelShortTpSl', 'btnRebuildLongTpSl',
        'btnRebuildShortTpSl', 'btnCloseLong', 'btnCloseShort'];
    const setManual = (disabled) => {
        manualIds.forEach(id => { const b = $(id); if (b) b.disabled = disabled; });
    };
    if (!inst) {
        startBtn.disabled = true;
        stopBtn.disabled = true;
        setManual(true);
        return;
    }
    const running = inst.state === 'ACTIVE' || inst.state === 'OPENING' || inst.state === 'WAITING_KLINE';
    startBtn.disabled = running;   // 运行中不可重复启动
    stopBtn.disabled = !running;   // 未运行不可停止
    setManual(!running);           // 仅运行中可下发手动指令
}
function showConfirm(action, label) {
    if (!selectedMd5) return;
    pendingAction = action;
    const verb = action === 'start' ? '启动' : '停止';
    pendingAction = { path: action, label: label || action };
    const verb = pendingAction.label;
    const isDanger = action === 'stop' || action === 'close-long' || action === 'close-short';
    $('modalTitle').textContent = `确认${verb}`;
    $('modalMsg').textContent = `确定要${verb}实例 ${selectedMd5.substring(0, 12)}... 的策略吗?`;
    $('modalMsg').textContent = `确定要对实例 ${selectedMd5.substring(0, 12)}... 执行「${verb}」吗?`;
    $('modalConfirmBtn').textContent = verb;
    $('modalConfirmBtn').className = 'btn ' + (action === 'start' ? 'btn-success' : 'btn-danger');
    $('modalConfirmBtn').className = 'btn ' + (isDanger ? 'btn-danger' : 'btn-success');
    $('modalOverlay').classList.add('show');
}
@@ -1100,13 +1324,48 @@
    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' });
        const msg = await api(API + '/' + action.path + '?apiKeyMd5=' + encodeURIComponent(selectedMd5), { method: 'POST' });
        toast(msg || '指令已发送', 'success');
        // 延迟刷新等待 ACK
        setTimeout(refreshInstanceList, 2000);
@@ -1126,19 +1385,8 @@
// ==================== 初始化 ====================
function startPolling() {
    poll(); // 立即执行一次
    pollingTimer = setInterval(poll, 5000);
    poll(); // 页面加载时立即加载一次实例列表(不再自动轮询,改为手动刷新)
}
// 倒计时显示
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>