Files
qihuo/static/js/dashboard.js
T

995 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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();
})();