fb23ee891c
Co-authored-by: Cursor <cursoragent@cursor.com>
995 lines
41 KiB
JavaScript
995 lines
41 KiB
JavaScript
/* 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)
|
||
? ' <span class="badge profit dash-be-badge">已保本</span>' : '';
|
||
}
|
||
|
||
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 (
|
||
'<div class="' + cls.trim() + '">' +
|
||
'<label>' + escHtml(it.label) + '</label>' +
|
||
'<div>' + (it.html != null ? it.html : escHtml(it.value)) + '</div>' +
|
||
'</div>'
|
||
);
|
||
}).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, '>')
|
||
.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 '<span class="' + pnlClass(v) + '">' + escHtml(fmtMobilePnlNum(v)) + '</span>';
|
||
}
|
||
|
||
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
|
||
? ' <span class="badge planned dash-main-badge">主力</span>' : '';
|
||
var titleInner = escHtml(name);
|
||
if (exchange) {
|
||
titleInner += ' <span class="dash-symbol-ex text-muted">' + escHtml(exchange) + '</span>';
|
||
}
|
||
titleInner += mainBadge;
|
||
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
|
||
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span>';
|
||
titleInner += breakevenBadgeHtml(row);
|
||
titleInner += ' ' + directionBadgeHtml(row);
|
||
} else if (!name && code) {
|
||
titleInner = (exchange
|
||
? '<span class="dash-symbol-ex text-muted">' + escHtml(exchange) + '</span> '
|
||
: '') + '<span class="text-accent">' + escHtml(code) + '</span>';
|
||
titleInner += breakevenBadgeHtml(row);
|
||
titleInner += ' ' + directionBadgeHtml(row);
|
||
} else {
|
||
titleInner += ' ' + directionBadgeHtml(row);
|
||
titleInner += breakevenBadgeHtml(row);
|
||
}
|
||
return titleInner;
|
||
}
|
||
|
||
function mobileItemFootHtml(summaryHtml) {
|
||
return (
|
||
'<div class="dash-mobile-item-foot">' +
|
||
'<span class="dash-mobile-item-summary">' + summaryHtml + '</span>' +
|
||
'<span class="dash-mobile-chevron">查看详情 ›</span>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
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
|
||
? ' <span class="badge planned dash-main-badge">主力</span>' : '';
|
||
var titleInner = escHtml(name);
|
||
if (exchange) {
|
||
titleInner += ' <span class="dash-symbol-ex text-muted">' + escHtml(exchange) + '</span>';
|
||
}
|
||
titleInner += mainBadge;
|
||
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
|
||
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span>';
|
||
titleInner += breakevenBadgeHtml(row);
|
||
} else if (!name && code) {
|
||
titleInner = (exchange
|
||
? '<span class="dash-symbol-ex text-muted">' + escHtml(exchange) + '</span> '
|
||
: '') + '<span class="text-accent">' + escHtml(code) + '</span>';
|
||
titleInner += breakevenBadgeHtml(row);
|
||
} else {
|
||
titleInner += breakevenBadgeHtml(row);
|
||
}
|
||
return (
|
||
'<div class="dash-symbol-cell">' +
|
||
'<div class="dash-symbol-title">' + titleInner + '</div>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
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 '<span class="badge ' + cls + '">' + escHtml(label) + '</span>';
|
||
}
|
||
|
||
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 = '<span class="' + usedCls + '">' + escHtml(fmtNum(used) + '%') + '</span>';
|
||
if (limit != null) {
|
||
html += ' <span class="risk-margin-sep">/</span> ' +
|
||
'<span class="risk-margin-cap-inline">' + escHtml(fmtNum(limit) + '%') + '</span>';
|
||
}
|
||
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 (
|
||
'<div class="dashboard-risk-item">' +
|
||
'<div class="dashboard-risk-label">' + escHtml(it.label) + '</div>' +
|
||
'<div class="' + cls + '">' + val + '</div>' +
|
||
'</div>'
|
||
);
|
||
}).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 = '<tr><td colspan="8" class="text-muted">暂无关键位监控</td></tr>';
|
||
return;
|
||
}
|
||
keysBody.innerHTML = keys.map(function (k) {
|
||
return (
|
||
'<tr data-key-id="' + k.id + '">' +
|
||
'<td>' + symbolCellHtml(k) + '</td>' +
|
||
'<td>' + escHtml(k.monitor_type || '—') + '</td>' +
|
||
'<td>' + escHtml(k.bar_period || '—') + '</td>' +
|
||
'<td>' + fmtNum(k.upper) + '</td>' +
|
||
'<td>' + fmtNum(k.lower) + '</td>' +
|
||
'<td class="dash-k-price">' + (k.price != null ? fmtNum(k.price) : '—') + '</td>' +
|
||
'<td class="dash-k-dist-up">' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + '</td>' +
|
||
'<td class="dash-k-dist-down">' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—') + '</td>' +
|
||
'</tr>'
|
||
);
|
||
}).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 = '<div class="dash-mobile-empty">暂无平仓记录</div>';
|
||
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 (
|
||
'<button type="button" class="dash-mobile-item" data-close-id="' + c.id + '">' +
|
||
'<div class="dash-mobile-item-head">' +
|
||
'<div class="dash-mobile-item-title">' + mobileSymbolTitleHtml(c) + '</div>' +
|
||
'</div>' +
|
||
mobileItemFootHtml(summary) +
|
||
'</button>'
|
||
);
|
||
}).join('');
|
||
}
|
||
|
||
function renderCloses(closes) {
|
||
if (!closesBody) return;
|
||
if (!closes || !closes.length) {
|
||
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
|
||
if (isPhoneLayout() && closesMobileList) {
|
||
closesMobileList.innerHTML = '<div class="dash-mobile-empty">暂无平仓记录</div>';
|
||
}
|
||
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 (
|
||
'<tr data-close-id="' + c.id + '">' +
|
||
'<td>' + symbolCellHtml(c) + '</td>' +
|
||
'<td>' + directionBadgeHtml(c) + '</td>' +
|
||
'<td>' + fmtNum(c.lots) + '</td>' +
|
||
'<td>' + fmtNum(c.entry_price) + '</td>' +
|
||
'<td>' + fmtNum(c.close_price) + '</td>' +
|
||
'<td class="' + pnlClass(c.pnl) + '">' + fmtPnl(c.pnl) + '</td>' +
|
||
'<td class="' + pc + '">' + fmtPnl(c.pnl_net) + '</td>' +
|
||
'<td>' + escHtml(c.close_time || '—') + '</td>' +
|
||
'</tr>'
|
||
);
|
||
}).join('');
|
||
}
|
||
|
||
function applyKeys(keys) {
|
||
if (!keysBody) return;
|
||
var ids = (keys || []).map(function (k) { return String(k.id); }).join(',');
|
||
if (!ids) {
|
||
keysBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无关键位监控</td></tr>';
|
||
lastKeyIds = '';
|
||
lastKeyRows = [];
|
||
if (keysMobileList) keysMobileList.innerHTML = '<div class="dash-mobile-empty">暂无关键位监控</div>';
|
||
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 = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
|
||
lastCloseHeadId = null;
|
||
if (isPhoneLayout() && closesMobileList) {
|
||
closesMobileList.innerHTML = '<div class="dash-mobile-empty">暂无平仓记录</div>';
|
||
}
|
||
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: '<span class="' + pnlClass(r.float_pnl) + '">' + fmtPnl(r.float_pnl) + '</span>' },
|
||
{ 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: '<span class="' + pnlClass(c.pnl) + '">' + fmtPnl(c.pnl) + '</span>' },
|
||
{ label: '净盈亏', html: '<span class="' + pnlClass(net) + '">' + fmtPnl(net) + '</span>' },
|
||
{ 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 = '<div class="dash-mobile-empty">暂无持仓</div>';
|
||
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 (
|
||
'<button type="button" class="dash-mobile-item" data-pos-key="' + escHtml(key) + '">' +
|
||
'<div class="dash-mobile-item-head">' +
|
||
'<div class="dash-mobile-item-title">' + mobileSymbolTitleHtml(r) + '</div>' +
|
||
'</div>' +
|
||
mobileItemFootHtml(summary) +
|
||
'</button>'
|
||
);
|
||
}).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 = '<div class="dash-mobile-empty">暂无关键位监控</div>';
|
||
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 (
|
||
'<button type="button" class="dash-mobile-item" data-key-id="' + k.id + '">' +
|
||
'<div class="dash-mobile-item-head"><div class="dash-mobile-item-title">' + title + '</div></div>' +
|
||
'<div class="dash-mobile-item-meta">' + meta + '</div>' +
|
||
'<div class="dash-mobile-item-foot">' +
|
||
'<span class="text-muted">' + escHtml(dist) + '</span>' +
|
||
'<span class="dash-mobile-chevron">查看详情 ›</span>' +
|
||
'</div></button>'
|
||
);
|
||
}).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 = '<tr><td colspan="9" class="text-muted">暂无持仓</td></tr>';
|
||
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 (
|
||
'<tr data-pos-key="' + escHtml(key) + '">' +
|
||
'<td>' + symbolCellHtml(r) + '</td>' +
|
||
'<td>' + directionBadgeHtml(r) + '</td>' +
|
||
'<td class="dash-p-lots">' + escHtml(String(r.lots)) + '</td>' +
|
||
'<td class="dash-p-entry">' + fmtNum(r.entry_price) + '</td>' +
|
||
'<td class="dash-p-mark">' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '</td>' +
|
||
'<td class="dash-p-pnl ' + pnlClass(r.float_pnl) + '">' + fmtPnl(r.float_pnl) + '</td>' +
|
||
'<td class="dash-p-margin">' + (r.margin != null ? fmtMoney(r.margin) : '—') + '</td>' +
|
||
'<td class="dash-p-sl">' + escHtml(slText(r)) + '</td>' +
|
||
'<td class="dash-p-tp">' + escHtml(tpText(r)) + '</td>' +
|
||
'</tr>'
|
||
);
|
||
}).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.float_pnl != null) cached.float_pnl = q.float_pnl;
|
||
}
|
||
var row = findPosRow(key);
|
||
if (!row) return;
|
||
var markEl = row.querySelector('.dash-p-mark');
|
||
var pnlEl = row.querySelector('.dash-p-pnl');
|
||
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();
|
||
})();
|