Add real-time data dashboard with account, positions, keys, and closes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved. 详见 LICENSE.zh-CN.txt */
|
||||
|
||||
.dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.dash-updated {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dashboard-account-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dashboard-account-grid {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dashboard-account-grid .stat-item {
|
||||
min-width: 6.5rem;
|
||||
}
|
||||
|
||||
.dashboard-account-grid .stat-item .value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-section h2 {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.dashboard-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dashboard-table th,
|
||||
.dashboard-table td {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dashboard-table .pnl-pos {
|
||||
color: var(--profit);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-table .pnl-neg {
|
||||
color: var(--loss);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.dashboard-table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.dashboard-table th,
|
||||
.dashboard-table td {
|
||||
padding: 0.35rem 0.4rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/* 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 pollTimer = null;
|
||||
var positionSource = null;
|
||||
var posRowCache = {};
|
||||
var lastKeyIds = '';
|
||||
var lastCloseHeadId = null;
|
||||
|
||||
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 slTpText(row) {
|
||||
var parts = [];
|
||||
if (row.stop_loss != null) parts.push('SL ' + fmtNum(row.stop_loss));
|
||||
if (row.take_profit != null) parts.push('TP ' + fmtNum(row.take_profit));
|
||||
if (row.trailing_be) parts.push('移动保本');
|
||||
return parts.length ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
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>' + escHtml(k.symbol_name || k.symbol) + '</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 renderCloses(closes) {
|
||||
if (!closesBody) return;
|
||||
if (!closes || !closes.length) {
|
||||
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
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>' + escHtml(c.symbol) + '</td>' +
|
||||
'<td>' + escHtml(c.direction_label || c.direction) + '</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 = '';
|
||||
return;
|
||||
}
|
||||
if (ids !== lastKeyIds) {
|
||||
lastKeyIds = ids;
|
||||
renderKeys(keys);
|
||||
} else {
|
||||
patchKeys(keys);
|
||||
}
|
||||
}
|
||||
|
||||
function applyCloses(closes) {
|
||||
if (!closesBody) return;
|
||||
if (!closes || !closes.length) {
|
||||
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
|
||||
lastCloseHeadId = null;
|
||||
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 renderPositions(rows) {
|
||||
if (!posBody) return;
|
||||
if (!rows.length) {
|
||||
posBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无持仓</td></tr>';
|
||||
posRowCache = {};
|
||||
return;
|
||||
}
|
||||
posRowCache = {};
|
||||
posBody.innerHTML = rows.map(function (r) {
|
||||
var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
|
||||
posRowCache[key] = true;
|
||||
var name = r.symbol || r.symbol_name || r.symbol_code || '—';
|
||||
return (
|
||||
'<tr data-pos-key="' + escHtml(key) + '">' +
|
||||
'<td>' + escHtml(name) + '</td>' +
|
||||
'<td>' + escHtml(r.direction_label || r.direction) + '</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-sltp">' + escHtml(slTpText(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 row = findPosRow(q.key || q.position_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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyPositionsData(data) {
|
||||
if (!data) return;
|
||||
if (data.ctp_status) updateCtpBadge(data.ctp_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 keys = rows.map(function (r) {
|
||||
return r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
|
||||
}).sort().join('|');
|
||||
var cachedKeys = Object.keys(posRowCache).sort().join('|');
|
||||
if (keys !== cachedKeys) {
|
||||
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 sltpEl = row.querySelector('.dash-p-sltp');
|
||||
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 (sltpEl) sltpEl.textContent = slTpText(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pollDashboard() {
|
||||
fetch('/api/dashboard/live')
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data.ok) return;
|
||||
applyAccount(data);
|
||||
applyKeys(data.keys || []);
|
||||
applyCloses(data.closes || []);
|
||||
})
|
||||
.catch(function () {
|
||||
if (updatedEl) updatedEl.textContent = '看板数据加载失败';
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
setTimeout(connectPositionStream, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollDashboard();
|
||||
pollTimer = setInterval(pollDashboard, 1000);
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user