/* Copyright (c) 2025-2026 马建军. All rights reserved. * 详见 LICENSE.zh-CN.txt */ (function () { 'use strict'; var posBody = document.getElementById('dash-positions-body'); var keysBody = document.getElementById('dash-keys-body'); var closesBody = document.getElementById('dash-closes-body'); var equityEl = document.getElementById('dash-equity'); var marginEl = document.getElementById('dash-margin'); var availEl = document.getElementById('dash-available'); var ctpBadge = document.getElementById('dash-ctp-badge'); var modeBadge = document.getElementById('dash-mode-badge'); var updatedEl = document.getElementById('dash-updated'); var riskReasonEl = document.getElementById('dash-risk-reason'); var riskGridEl = document.getElementById('dash-risk-grid'); var riskCardEl = document.getElementById('dash-risk-card'); var riskToggleEl = document.getElementById('dash-risk-toggle'); var riskBodyEl = document.getElementById('dash-risk-body'); var posMobileList = document.getElementById('dash-pos-mobile-list'); var keysMobileList = document.getElementById('dash-keys-mobile-list'); var closesMobileList = document.getElementById('dash-closes-mobile-list'); var detailModal = document.getElementById('dash-detail-modal'); var detailTitleEl = document.getElementById('dash-detail-title'); var detailGridEl = document.getElementById('dash-detail-grid'); var detailCloseBtn = document.getElementById('dash-detail-close'); var pollTimer = null; var pollInFlight = false; var pollFailStreak = 0; var positionSource = null; var posRowCache = {}; var posRenderSig = ''; var posMobileCache = {}; var keysMobileCache = {}; var closesMobileCache = {}; var lastPosRows = []; var lastKeyRows = []; var lastKeyIds = ''; var lastCloseHeadId = null; var lastRiskPayload = null; function isPhoneLayout() { var root = document.documentElement; return root.dataset.layout === 'phone' || root.classList.contains('layout-phone') || root.dataset.mobile === '1'; } function isTabletLayout() { var root = document.documentElement; return root.dataset.layout === 'tablet' || root.classList.contains('layout-tablet'); } function shouldCollapseRiskDefault() { return isPhoneLayout() || isTabletLayout(); } function syncRiskCollapseUi() { if (!riskCardEl || !riskToggleEl) return; var expanded = riskCardEl.classList.contains('is-expanded'); riskToggleEl.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } function initRiskToggle() { if (!riskCardEl || !riskToggleEl) return; if (shouldCollapseRiskDefault()) { riskCardEl.classList.remove('is-expanded'); } else { riskCardEl.classList.add('is-expanded'); } syncRiskCollapseUi(); function toggleRisk() { riskCardEl.classList.toggle('is-expanded'); syncRiskCollapseUi(); } riskToggleEl.addEventListener('click', function (ev) { if (ev.target && ev.target.closest('.dash-risk-doc-link')) return; toggleRisk(); }); riskToggleEl.addEventListener('keydown', function (ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggleRisk(); } }); } function isBreakevenLocked(row) { if (!row) return false; if (row.breakeven_locked) return true; if ((row.trailing_r_locked || 0) >= 1) return true; if (row.stop_loss == null || row.entry_price == null) return false; var entry = Number(row.entry_price); var sl = Number(row.stop_loss); if (isNaN(entry) || isNaN(sl) || entry <= 0) return false; var tick = Number(row.tick_size) || Math.max(Math.abs(entry) * 1e-6, 0.01); var beMult = 2; var dir = (row.direction || 'long').toString().toLowerCase(); var expectedBe = dir === 'short' ? entry - beMult * tick : entry + beMult * tick; var tol = beMult * tick + tick * 0.05; if (Math.abs(sl - expectedBe) <= tol) return true; var buf = tick * Math.max(2, beMult); if (Math.abs(sl - entry) > buf + tick) return false; return dir === 'short' ? sl <= entry + tick * 0.05 : sl >= entry - tick * 0.05; } function breakevenBadgeHtml(row) { return isBreakevenLocked(row) ? ' 已保本' : ''; } function openDetailModal(title, items) { if (!detailModal || !detailGridEl || !detailTitleEl) return; detailTitleEl.textContent = title || '详情'; detailGridEl.innerHTML = (items || []).map(function (it) { var cls = it.wide ? ' item wide' : ' item'; return ( '
' + '' + '
' + (it.html != null ? it.html : escHtml(it.value)) + '
' + '
' ); }).join(''); detailModal.hidden = false; detailModal.classList.add('show'); } function closeDetailModal() { if (!detailModal) return; detailModal.hidden = true; detailModal.classList.remove('show'); } function initDetailModal() { if (detailCloseBtn) detailCloseBtn.addEventListener('click', closeDetailModal); if (detailModal) { detailModal.addEventListener('click', function (ev) { if (ev.target === detailModal) closeDetailModal(); }); } } function fmtNum(v, digits) { if (v === null || v === undefined || v === '') return '—'; var n = Number(v); if (isNaN(n)) return '—'; if (digits != null) return n.toFixed(digits); var s = n.toFixed(2); return s.replace(/\.?0+$/, function (m) { return m === '.00' ? '.00' : ''; }); } function fmtMoney(v) { if (v === null || v === undefined) return '—'; return fmtNum(v, 2) + ' 元'; } function fmtPnl(v) { if (v === null || v === undefined) return '—'; var n = Number(v); if (isNaN(n)) return '—'; return (n >= 0 ? '+' : '') + fmtNum(n, 2) + ' 元'; } function pnlClass(v) { if (v === null || v === undefined) return ''; var n = Number(v); if (isNaN(n) || n === 0) return ''; return n > 0 ? 'pnl-pos' : 'pnl-neg'; } function escHtml(s) { return String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function slText(row) { if (row.trailing_be && row.stop_loss == null) return '移动保本'; if (row.stop_loss != null) return fmtNum(row.stop_loss); return '—'; } function tpText(row) { if (row.trailing_be) return '移动保本'; if (row.take_profit != null) return fmtNum(row.take_profit); return '—'; } function fmtMobileLots(v) { var n = Number(v); if (isNaN(n)) return '—'; if (Math.abs(n - Math.round(n)) < 0.001) return Math.round(n) + '手'; return fmtNum(n) + '手'; } function fmtMobilePrice(v) { if (v == null || v === '') return '—'; var n = Number(v); if (isNaN(n)) return '—'; if (Math.abs(n - Math.round(n)) < 0.001) return String(Math.round(n)); return fmtNum(n); } function fmtMobilePnlNum(v) { if (v == null || v === '') return '—'; var n = Number(v); if (isNaN(n)) return '—'; var abs = Math.abs(n); var s = Math.abs(abs - Math.round(abs)) < 0.01 ? String(Math.round(abs)) : fmtNum(abs); if (n > 0) return s; if (n < 0) return '-' + s; return '0'; } function mobilePnlSpanHtml(v) { return '' + escHtml(fmtMobilePnlNum(v)) + ''; } function mobileSummaryHtml(lots, priceLabel, price, pnl) { return fmtMobileLots(lots) + ' ' + priceLabel + ' ' + fmtMobilePrice(price) + ' 盈亏 ' + mobilePnlSpanHtml(pnl); } function mobileSymbolTitleHtml(row) { var name = row.symbol_name || row.symbol || ''; var code = row.symbol_code || ''; if (!code && row.symbol && String(row.symbol).toLowerCase() !== String(name).toLowerCase()) { code = row.symbol; } var exchange = row.symbol_exchange || ''; var mainBadge = row.symbol_is_main ? ' 主力' : ''; var titleInner = escHtml(name); if (exchange) { titleInner += ' ' + escHtml(exchange) + ''; } titleInner += mainBadge; if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { titleInner += ' ' + escHtml(code) + ''; titleInner += breakevenBadgeHtml(row); titleInner += ' ' + directionBadgeHtml(row); } else if (!name && code) { titleInner = (exchange ? '' + escHtml(exchange) + ' ' : '') + '' + escHtml(code) + ''; titleInner += breakevenBadgeHtml(row); titleInner += ' ' + directionBadgeHtml(row); } else { titleInner += ' ' + directionBadgeHtml(row); titleInner += breakevenBadgeHtml(row); } return titleInner; } function mobileItemFootHtml(summaryHtml) { return ( '
' + '' + summaryHtml + '' + '查看详情 ›' + '
' ); } function symbolCellHtml(row) { var name = row.symbol_name || row.symbol || ''; var code = row.symbol_code || ''; if (!code && row.symbol && String(row.symbol).toLowerCase() !== String(name).toLowerCase()) { code = row.symbol; } var exchange = row.symbol_exchange || ''; var mainBadge = row.symbol_is_main ? ' 主力' : ''; var titleInner = escHtml(name); if (exchange) { titleInner += ' ' + escHtml(exchange) + ''; } titleInner += mainBadge; if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { titleInner += ' ' + escHtml(code) + ''; titleInner += breakevenBadgeHtml(row); } else if (!name && code) { titleInner = (exchange ? '' + escHtml(exchange) + ' ' : '') + '' + escHtml(code) + ''; titleInner += breakevenBadgeHtml(row); } else { titleInner += breakevenBadgeHtml(row); } return ( '
' + '
' + titleInner + '
' + '
' ); } function directionBadgeHtml(row) { var dir = (row.direction || 'long').toString().toLowerCase(); var label = row.direction_label || (dir === 'short' ? '做空' : '做多'); var cls = dir === 'short' ? 'dir dir-short' : 'dir dir-long'; return '' + escHtml(label) + ''; } function riskMarginPctHtml(used, limit) { if (used == null) return escHtml('—'); var usedCls = 'risk-margin-safe'; if (limit != null && limit > 0) { var ratio = Number(used) / Number(limit); if (ratio >= 1) usedCls = 'risk-margin-over'; else if (ratio >= 0.85) usedCls = 'risk-margin-warn'; } var html = '' + escHtml(fmtNum(used) + '%') + ''; if (limit != null) { html += ' / ' + '' + escHtml(fmtNum(limit) + '%') + ''; } return html; } function renderRiskGrid(items) { if (!riskGridEl) return; riskGridEl.innerHTML = items.map(function (it) { var val = it.valueHtml != null ? it.valueHtml : escHtml(it.value); var cls = 'dashboard-risk-value' + (it.valueClass ? ' ' + it.valueClass : ''); return ( '
' + '
' + escHtml(it.label) + '
' + '
' + val + '
' + '
' ); }).join(''); } function fmtHours(h) { if (h == null) return '—'; var n = Number(h); if (isNaN(n)) return '—'; if (Math.abs(n - Math.round(n)) < 0.01) return String(Math.round(n)) + 'h'; return n.toFixed(1) + 'h'; } function fmtRemainSec(sec) { if (sec == null || sec <= 0) return '—'; var s = Math.max(0, Math.floor(sec)); var h = Math.floor(s / 3600); var m = Math.floor((s % 3600) / 60); if (h > 0) return h + 'h ' + m + 'm'; return m + 'm'; } function applyRisk(risk, account) { if (!riskGridEl || !risk) return; if (risk.limits && Object.keys(risk.limits).length) { lastRiskPayload = risk; } else if (lastRiskPayload) { risk = { enabled: lastRiskPayload.enabled, limits: lastRiskPayload.limits, manual_close_count_today: lastRiskPayload.manual_close_count_today, margin_pct_used: lastRiskPayload.margin_pct_used, status: risk.status || lastRiskPayload.status, }; } if (account && account.equity > 0 && account.margin_used != null) { risk.margin_pct_used = Math.round(account.margin_used / account.equity * 10000) / 100; } var lim = risk.limits || {}; var st = risk.status || {}; var enabled = risk.enabled !== false; var active = st.active_count != null ? st.active_count : '—'; var maxPos = lim.max_active_positions != null ? lim.max_active_positions : st.max_active_positions; var manualCnt = risk.manual_close_count_today != null ? risk.manual_close_count_today : 0; var manualLim = lim.manual_close_daily_limit; var dailyOpens = risk.daily_open_count != null ? risk.daily_open_count : (st.daily_open_count != null ? st.daily_open_count : '—'); var dailyPosLim = lim.daily_position_limit != null ? lim.daily_position_limit : st.daily_position_limit; var dailyRiskUsed = risk.daily_risk_used_pct != null ? risk.daily_risk_used_pct : st.daily_risk_used_pct; var dailyRiskLim = lim.daily_trading_risk_pct_limit != null ? lim.daily_trading_risk_pct_limit : st.daily_trading_risk_pct_limit; var dailyRiskText = dailyRiskUsed != null ? fmtNum(dailyRiskUsed) + '%' : '—'; if (dailyRiskLim != null && dailyRiskUsed != null) { dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%'; } else if (dailyRiskLim != null) { dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%'; } var marginPct = risk.margin_pct_used; var maxMarginPct = lim.max_margin_pct; var rollMaxPct = lim.roll_max_margin_pct; var isSingleMode = lim.position_mode === 'single' || (maxPos != null && Number(maxPos) <= 1); var compositeCapLabel = isSingleMode ? '滚仓保证金上限' : '多仓保证金上限'; if (riskReasonEl) { var reason = st.reason || (enabled ? '可新开仓' : '风控已关闭'); riskReasonEl.textContent = (st.status_label ? st.status_label + ' · ' : '') + reason; riskReasonEl.className = 'dashboard-risk-reason'; if (st.can_trade === false) riskReasonEl.classList.add('is-blocked'); else if (st.can_trade) riskReasonEl.classList.add('is-ok'); } var sizingDetail = lim.sizing_label || '—'; if (lim.sizing_mode === 'amount') { sizingDetail += ' · ' + fmtNum(lim.fixed_amount, 0) + ' 元'; } else if (lim.fixed_lots != null) { sizingDetail += ' · ' + lim.fixed_lots + ' 手'; } var items = [ { label: '风控开关', value: enabled ? '开启' : '关闭', valueClass: enabled ? 'risk-switch-on' : 'risk-switch-off', }, { label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') }, { label: '日持仓限制', value: dailyOpens + ' / ' + (dailyPosLim != null ? dailyPosLim : '—') }, { label: '日交易风险', value: dailyRiskText }, { label: '手动平仓(冷静期触发)', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') }, { label: '冷静期(默认)', value: fmtHours(lim.cooling_hours_manual) }, { label: '复盘后冷静', value: fmtHours(lim.cooling_hours_manual_journal) }, { label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) }, { label: '综合保证金占比', valueHtml: riskMarginPctHtml(marginPct, rollMaxPct), }, { label: '单仓保证金上限', value: maxMarginPct != null ? fmtNum(maxMarginPct) + '%' : '—', valueClass: 'risk-cap-single', }, { label: compositeCapLabel, value: rollMaxPct != null ? fmtNum(rollMaxPct) + '%' : '—', valueClass: 'risk-cap-roll', }, { label: '计仓模式', value: sizingDetail }, { label: '交易日切', value: lim.trading_day_reset_hour != null ? lim.trading_day_reset_hour + ':00' : '—' } ]; renderRiskGrid(items); } function updateCtpBadge(st) { if (!ctpBadge || !st) return; var connected = !!st.connected; var connecting = !!st.connecting && !(st.login_cooldown_sec > 0); ctpBadge.className = 'badge ' + (connected ? 'profit' : (connecting ? 'planned' : 'loss')); if (connected) { ctpBadge.textContent = 'CTP 已连接'; } else if (connecting) { ctpBadge.textContent = 'CTP 连接中…'; } else if (st.disabled_hint) { ctpBadge.textContent = 'CTP 自动连接已关闭'; } else if (st.last_error) { ctpBadge.textContent = 'CTP 未连接'; ctpBadge.title = st.last_error; } else { ctpBadge.textContent = 'CTP 未连接'; ctpBadge.title = ''; } } function applyAccount(data) { if (!data) return; var acc = data.account || {}; var st = data.ctp_status || {}; updateCtpBadge(st); if (modeBadge && data.trading_mode_label) { modeBadge.textContent = data.trading_mode_label; modeBadge.className = 'badge dir'; } if (updatedEl && data.updated_at) { updatedEl.textContent = '更新 ' + data.updated_at; } if (equityEl) { equityEl.textContent = fmtMoney(acc.equity != null ? acc.equity : acc.capital_fallback); } if (marginEl) { marginEl.textContent = acc.margin_used != null ? fmtMoney(acc.margin_used) : '—'; } if (availEl) { availEl.textContent = acc.available != null ? fmtMoney(acc.available) : '—'; } } function renderKeys(keys) { if (!keysBody) return; if (!keys || !keys.length) { keysBody.innerHTML = '暂无关键位监控'; return; } keysBody.innerHTML = keys.map(function (k) { return ( '' + '' + symbolCellHtml(k) + '' + '' + escHtml(k.monitor_type || '—') + '' + '' + escHtml(k.bar_period || '—') + '' + '' + fmtNum(k.upper) + '' + '' + fmtNum(k.lower) + '' + '' + (k.price != null ? fmtNum(k.price) : '—') + '' + '' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + '' + '' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—') + '' + '' ); }).join(''); } function patchKeys(keys) { if (!keysBody || !keys) return; keys.forEach(function (k) { var row = keysBody.querySelector('tr[data-key-id="' + k.id + '"]'); if (!row) return; var priceEl = row.querySelector('.dash-k-price'); var upEl = row.querySelector('.dash-k-dist-up'); var downEl = row.querySelector('.dash-k-dist-down'); if (priceEl) priceEl.textContent = k.price != null ? fmtNum(k.price) : '—'; if (upEl) upEl.textContent = k.dist_upper != null ? fmtNum(k.dist_upper) : '—'; if (downEl) downEl.textContent = k.dist_lower != null ? fmtNum(k.dist_lower) : '—'; }); } function renderClosesMobile(closes) { if (!closesMobileList) return; if (!closes || !closes.length) { closesMobileList.innerHTML = '
暂无平仓记录
'; closesMobileCache = {}; return; } closesMobileCache = {}; closesMobileList.innerHTML = closes.map(function (c) { var net = c.pnl_net != null ? c.pnl_net : c.pnl; closesMobileCache[String(c.id)] = c; var summary = mobileSummaryHtml(c.lots, '平仓', c.close_price, net); return ( '' ); }).join(''); } function renderCloses(closes) { if (!closesBody) return; if (!closes || !closes.length) { closesBody.innerHTML = '暂无平仓记录'; if (isPhoneLayout() && closesMobileList) { closesMobileList.innerHTML = '
暂无平仓记录
'; } closesMobileCache = {}; return; } if (isPhoneLayout()) renderClosesMobile(closes); closesBody.innerHTML = closes.map(function (c) { var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl); return ( '' + '' + symbolCellHtml(c) + '' + '' + directionBadgeHtml(c) + '' + '' + fmtNum(c.lots) + '' + '' + fmtNum(c.entry_price) + '' + '' + fmtNum(c.close_price) + '' + '' + fmtPnl(c.pnl) + '' + '' + fmtPnl(c.pnl_net) + '' + '' + escHtml(c.close_time || '—') + '' + '' ); }).join(''); } function applyKeys(keys) { if (!keysBody) return; var ids = (keys || []).map(function (k) { return String(k.id); }).join(','); if (!ids) { keysBody.innerHTML = '暂无关键位监控'; lastKeyIds = ''; lastKeyRows = []; if (keysMobileList) keysMobileList.innerHTML = '
暂无关键位监控
'; return; } if (ids !== lastKeyIds) { lastKeyIds = ids; lastKeyRows = keys || []; renderKeys(keys); if (isPhoneLayout()) renderKeysMobile(lastKeyRows); } else { patchKeys(keys); if (isPhoneLayout()) patchKeysMobile(keys); } } function applyCloses(closes) { if (!closesBody) return; if (!closes || !closes.length) { closesBody.innerHTML = '暂无平仓记录'; lastCloseHeadId = null; if (isPhoneLayout() && closesMobileList) { closesMobileList.innerHTML = '
暂无平仓记录
'; } closesMobileCache = {}; return; } var headId = closes[0].id; if (headId !== lastCloseHeadId) { lastCloseHeadId = headId; renderCloses(closes); } } function positionRows(data) { return (data.rows || []).filter(function (r) { return r.order_state !== 'pending' && (r.lots || 0) > 0; }); } function posDetailItems(r) { return [ { label: '品种', value: (r.symbol_name || r.symbol || '') + (r.symbol_code ? ' ' + r.symbol_code : '') }, { label: '方向', html: directionBadgeHtml(r) }, { label: '手数', value: String(r.lots) }, { label: '均价', value: fmtNum(r.entry_price) }, { label: '现价', value: r.current_price != null ? fmtNum(r.current_price) : '—' }, { label: '浮盈亏', html: '' + fmtPnl(r.float_pnl) + '' }, { label: '保证金', value: r.margin != null ? fmtMoney(r.margin) : '—' }, { label: '止损', value: slText(r) }, { label: '止盈', value: tpText(r) }, ]; } function keyDetailItems(k) { return [ { label: '品种', value: (k.symbol_name || k.symbol || k.symbol_code || '') }, { label: '类型', value: k.monitor_type || '—' }, { label: '周期', value: k.bar_period || '—' }, { label: '上沿', value: fmtNum(k.upper) }, { label: '下沿', value: fmtNum(k.lower) }, { label: '现价', value: k.price != null ? fmtNum(k.price) : '—' }, { label: '距上沿', value: k.dist_upper != null ? fmtNum(k.dist_upper) : '—' }, { label: '距下沿', value: k.dist_lower != null ? fmtNum(k.dist_lower) : '—' }, ]; } function closeDetailItems(c) { var net = c.pnl_net != null ? c.pnl_net : c.pnl; return [ { label: '品种', value: (c.symbol_name || c.symbol || '') + (c.symbol_code ? ' ' + c.symbol_code : '') }, { label: '方向', html: directionBadgeHtml(c) }, { label: '手数', value: String(c.lots) }, { label: '开仓价', value: fmtNum(c.entry_price) }, { label: '平仓价', value: fmtNum(c.close_price) }, { label: '盈亏', html: '' + fmtPnl(c.pnl) + '' }, { label: '净盈亏', html: '' + fmtPnl(net) + '' }, { label: '手续费', value: c.fee != null ? fmtMoney(c.fee) : '—' }, { label: '平仓时间', value: c.close_time || '—' }, { label: '结果', value: c.result || '—' }, ]; } function renderPositionsMobile(rows) { if (!posMobileList) return; if (!rows.length) { posMobileList.innerHTML = '
暂无持仓
'; posMobileCache = {}; return; } posMobileCache = {}; posMobileList.innerHTML = rows.map(function (r) { var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); posMobileCache[key] = r; var summary = mobileSummaryHtml( r.lots, '现价', r.current_price, r.float_pnl ); return ( '' ); }).join(''); } function patchPositionsMobile(rows) { if (!posMobileList || !rows) return; rows.forEach(function (r) { var key = r.key || r.position_key; posMobileCache[key] = r; var btn = posMobileList.querySelector('[data-pos-key="' + key + '"]'); if (!btn) return; var summaryEl = btn.querySelector('.dash-mobile-item-summary'); if (summaryEl) { summaryEl.innerHTML = mobileSummaryHtml( r.lots, '现价', r.current_price, r.float_pnl ); } }); } function renderKeysMobile(keys) { if (!keysMobileList) return; if (!keys || !keys.length) { keysMobileList.innerHTML = '
暂无关键位监控
'; keysMobileCache = {}; return; } keysMobileCache = {}; keysMobileList.innerHTML = keys.map(function (k) { keysMobileCache[String(k.id)] = k; var title = symbolCellHtml(k); var meta = escHtml(k.monitor_type || '—') + ' · ' + escHtml(k.bar_period || '—') + ' · 现价 ' + (k.price != null ? fmtNum(k.price) : '—'); var dist = '距上 ' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + ' / 距下 ' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—'); return ( '' ); }).join(''); } function patchKeysMobile(keys) { if (!keysMobileList || !keys) return; keys.forEach(function (k) { keysMobileCache[String(k.id)] = k; var btn = keysMobileList.querySelector('[data-key-id="' + k.id + '"]'); if (!btn) return; var metaEl = btn.querySelector('.dash-mobile-item-meta'); var distEl = btn.querySelector('.dash-mobile-item-foot span:first-child'); if (metaEl) { metaEl.innerHTML = escHtml(k.monitor_type || '—') + ' · ' + escHtml(k.bar_period || '—') + ' · 现价 ' + (k.price != null ? fmtNum(k.price) : '—'); } if (distEl) { distEl.textContent = '距上 ' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + ' / 距下 ' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—'); } }); } function initMobileLists() { if (posMobileList) { posMobileList.addEventListener('click', function (ev) { var btn = ev.target.closest('[data-pos-key]'); if (!btn) return; var key = btn.getAttribute('data-pos-key'); var row = posMobileCache[key]; if (!row) return; var name = row.symbol_name || row.symbol || row.symbol_code || '持仓'; openDetailModal(name, posDetailItems(row)); }); } if (keysMobileList) { keysMobileList.addEventListener('click', function (ev) { var btn = ev.target.closest('[data-key-id]'); if (!btn) return; var k = keysMobileCache[btn.getAttribute('data-key-id')]; if (!k) return; openDetailModal(k.symbol_name || k.symbol || '关键位', keyDetailItems(k)); }); } if (closesMobileList) { closesMobileList.addEventListener('click', function (ev) { var btn = ev.target.closest('[data-close-id]'); if (!btn) return; var c = closesMobileCache[btn.getAttribute('data-close-id')]; if (!c) return; openDetailModal(c.symbol_name || c.symbol || '平仓记录', closeDetailItems(c)); }); } } function renderPositions(rows) { lastPosRows = rows || []; if (isPhoneLayout()) { renderPositionsMobile(lastPosRows); } if (!posBody) return; if (!rows.length) { posBody.innerHTML = '暂无持仓'; posRowCache = {}; posRenderSig = ''; return; } posRowCache = {}; posBody.innerHTML = rows.map(function (r) { var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); posRowCache[key] = true; return ( '' + '' + symbolCellHtml(r) + '' + '' + directionBadgeHtml(r) + '' + '' + escHtml(String(r.lots)) + '' + '' + fmtNum(r.entry_price) + '' + '' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '' + '' + fmtPnl(r.float_pnl) + '' + '' + (r.margin != null ? fmtMoney(r.margin) : '—') + '' + '' + escHtml(slText(r)) + '' + '' + escHtml(tpText(r)) + '' + '' ); }).join(''); } function findPosRow(key) { if (!posBody || !key) return null; var rows = posBody.querySelectorAll('tr[data-pos-key]'); for (var i = 0; i < rows.length; i++) { if (rows[i].getAttribute('data-pos-key') === key) return rows[i]; } return null; } function patchPositionQuotes(quotes) { if (!quotes) return; quotes.forEach(function (q) { var key = q.key || q.position_key; if (isPhoneLayout() && posMobileCache[key]) { var cached = posMobileCache[key]; if (q.mark_price != null) cached.current_price = q.mark_price; if (q.entry_price != null) cached.entry_price = q.entry_price; if (q.float_pnl != null) cached.float_pnl = q.float_pnl; } var row = findPosRow(key); if (!row) return; var entryEl = row.querySelector('.dash-p-entry'); var markEl = row.querySelector('.dash-p-mark'); var pnlEl = row.querySelector('.dash-p-pnl'); if (entryEl && q.entry_price != null) entryEl.textContent = fmtNum(q.entry_price); if (markEl && q.mark_price != null) markEl.textContent = fmtNum(q.mark_price); if (pnlEl && q.float_pnl != null) { pnlEl.textContent = fmtPnl(q.float_pnl); pnlEl.className = 'dash-p-pnl ' + pnlClass(q.float_pnl); } }); if (isPhoneLayout()) patchPositionsMobile(Object.keys(posMobileCache).map(function (k) { return posMobileCache[k]; })); } function applyPositionsData(data) { if (!data) return; if (data.ctp_status) updateCtpBadge(data.ctp_status); if (data.risk_status) { applyRisk({ status: data.risk_status }); } if (data.trading_mode_label && modeBadge) { modeBadge.textContent = data.trading_mode_label; } if (equityEl && data.capital != null) { equityEl.textContent = fmtMoney(data.capital); } var rows = positionRows(data); var sig = rows.map(function (r) { var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); return key + '|' + (isBreakevenLocked(r) ? '1' : '0') + '|' + slText(r) + '|' + tpText(r) + '|' + String(r.lots); }).sort().join('|'); if (sig !== posRenderSig) { posRenderSig = sig; renderPositions(rows); } else { rows.forEach(function (r) { var key = r.key || r.position_key; var row = findPosRow(key); if (!row) return; var lotsEl = row.querySelector('.dash-p-lots'); var entryEl = row.querySelector('.dash-p-entry'); var markEl = row.querySelector('.dash-p-mark'); var pnlEl = row.querySelector('.dash-p-pnl'); var marginEl = row.querySelector('.dash-p-margin'); var slEl = row.querySelector('.dash-p-sl'); var tpEl = row.querySelector('.dash-p-tp'); if (lotsEl) lotsEl.textContent = String(r.lots); if (entryEl) entryEl.textContent = fmtNum(r.entry_price); if (markEl) markEl.textContent = r.current_price != null ? fmtNum(r.current_price) : '—'; if (pnlEl) { pnlEl.textContent = fmtPnl(r.float_pnl); pnlEl.className = 'dash-p-pnl ' + pnlClass(r.float_pnl); } if (marginEl) marginEl.textContent = r.margin != null ? fmtMoney(r.margin) : '—'; if (slEl) slEl.textContent = slText(r); if (tpEl) tpEl.textContent = tpText(r); }); if (isPhoneLayout()) patchPositionsMobile(rows); } } function pollIntervalMs() { if (isPhoneLayout() || isTabletLayout()) return 3000; return 2000; } function pollDashboard() { if (pollInFlight) return; pollInFlight = true; fetch('/api/dashboard/live') .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (data) { pollFailStreak = 0; if (!data.ok) { if (updatedEl) updatedEl.textContent = data.error || '看板数据暂不可用'; return; } applyAccount(data); applyRisk(data.risk, data.account); applyKeys(data.keys || []); applyCloses(data.closes || []); }) .catch(function () { pollFailStreak += 1; if (updatedEl) { updatedEl.textContent = pollFailStreak >= 3 ? '看板连接失败,正在重试…' : '看板数据加载失败'; } }) .finally(function () { pollInFlight = false; }); } function connectPositionStream() { if (positionSource) { positionSource.close(); positionSource = null; } positionSource = new EventSource('/api/trading/stream'); positionSource.addEventListener('positions', function (ev) { try { applyPositionsData(JSON.parse(ev.data)); } catch (e) { /* ignore */ } }); positionSource.addEventListener('position_quotes', function (ev) { try { var data = JSON.parse(ev.data); patchPositionQuotes(data.quotes || []); } catch (e) { /* ignore */ } }); positionSource.onerror = function () { if (positionSource) { positionSource.close(); positionSource = null; } var delay = (isPhoneLayout() || isTabletLayout()) ? 8000 : 3000; setTimeout(connectPositionStream, delay); }; } function startPolling() { if (pollTimer) clearInterval(pollTimer); pollDashboard(); pollTimer = setInterval(pollDashboard, pollIntervalMs()); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (positionSource) { positionSource.close(); positionSource = null; } } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { startPolling(); if (!positionSource) connectPositionStream(); } else { stopPolling(); } }); startPolling(); connectPositionStream(); initRiskToggle(); initDetailModal(); initMobileLists(); })();