Improve dashboard responsive layout, collapsible risk section, and breakeven badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-29 22:50:48 +08:00
parent 8b4b1a875c
commit d1ad0f9253
5 changed files with 543 additions and 14 deletions
+284 -5
View File
@@ -15,14 +15,124 @@
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 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 positionSource = null;
var posRowCache = {};
var posRenderSig = '';
var posMobileCache = {};
var keysMobileCache = {};
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 buf = tick * 2.5;
var dir = (row.direction || 'long').toString().toLowerCase();
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);
@@ -85,6 +195,7 @@
titleInner += ' <span class="dash-symbol-ex text-muted">' + escHtml(exchange) + '</span>';
}
titleInner += mainBadge;
titleInner += breakevenBadgeHtml(row);
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span>';
} else if (!name && code) {
@@ -358,13 +469,18 @@
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);
}
}
@@ -388,11 +504,160 @@
});
}
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 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 title = symbolCellHtml(r) + ' ' + directionBadgeHtml(r);
var meta = fmtNum(r.lots) + ' 手 · 现价 ' +
(r.current_price != null ? fmtNum(r.current_price) : '—');
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">' + title + '</div>' +
'</div>' +
'<div class="dash-mobile-item-meta">' + escHtml(meta) + '</div>' +
'<div class="dash-mobile-item-foot">' +
'<span class="' + pnlClass(r.float_pnl) + '">' + fmtPnl(r.float_pnl) + '</span>' +
'<span class="dash-mobile-chevron">查看详情 </span>' +
'</div></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 metaEl = btn.querySelector('.dash-mobile-item-meta');
var pnlEl = btn.querySelector('.dash-mobile-item-foot span:first-child');
if (metaEl) {
metaEl.textContent = fmtNum(r.lots) + ' 手 · 现价 ' +
(r.current_price != null ? fmtNum(r.current_price) : '—');
}
if (pnlEl) {
pnlEl.textContent = fmtPnl(r.float_pnl);
pnlEl.className = pnlClass(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));
});
}
}
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 = {};
@@ -427,7 +692,13 @@
function patchPositionQuotes(quotes) {
if (!quotes) return;
quotes.forEach(function (q) {
var row = findPosRow(q.key || q.position_key);
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');
@@ -437,6 +708,9 @@
pnlEl.className = 'dash-p-pnl ' + pnlClass(q.float_pnl);
}
});
if (isPhoneLayout()) patchPositionsMobile(Object.keys(posMobileCache).map(function (k) {
return posMobileCache[k];
}));
}
function applyPositionsData(data) {
@@ -452,11 +726,12 @@
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 || ''));
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('|');
var cachedKeys = Object.keys(posRowCache).sort().join('|');
if (keys !== cachedKeys) {
if (sig !== posRenderSig) {
posRenderSig = sig;
renderPositions(rows);
} else {
rows.forEach(function (r) {
@@ -481,6 +756,7 @@
if (slEl) slEl.textContent = slText(r);
if (tpEl) tpEl.textContent = tpText(r);
});
if (isPhoneLayout()) patchPositionsMobile(rows);
}
}
@@ -556,4 +832,7 @@
startPolling();
connectPositionStream();
initRiskToggle();
initDetailModal();
initMobileLists();
})();
+17 -1
View File
@@ -1024,7 +1024,23 @@
var name = row.symbol_name || row.symbol || '';
var code = row.symbol_code || '';
var mainBadge = row.symbol_is_main ? ' <span class="badge planned pos-main-badge">主力</span>' : '';
var inner = name + mainBadge;
var beBadge = (function () {
if (row.breakeven_locked) return ' <span class="badge profit dash-be-badge">已保本</span>';
if ((row.trailing_r_locked || 0) >= 1) return ' <span class="badge profit dash-be-badge">已保本</span>';
if (row.stop_loss == null || row.entry_price == null) return '';
var entry = Number(row.entry_price);
var sl = Number(row.stop_loss);
if (isNaN(entry) || isNaN(sl)) return '';
var tick = Number(row.tick_size) || Math.max(Math.abs(entry) * 1e-6, 0.01);
var buf = tick * 2.5;
var dir = (row.direction || 'long').toString().toLowerCase();
if (Math.abs(sl - entry) > buf + tick) return '';
if (dir === 'short' ? sl <= entry + tick * 0.05 : sl >= entry - tick * 0.05) {
return ' <span class="badge profit dash-be-badge">已保本</span>';
}
return '';
}());
var inner = name + mainBadge + beBadge;
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
inner += ' <span class="text-accent">' + code + '</span>';
} else if (!name && code) {