From d1ad0f9253272b1319c0c35540d97c4eecb19c44 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 29 Jun 2026 22:50:48 +0800 Subject: [PATCH] Improve dashboard responsive layout, collapsible risk section, and breakeven badge. Co-authored-by: Cursor --- install_trading.py | 34 +++++ static/css/dashboard.css | 185 +++++++++++++++++++++++++ static/js/dashboard.js | 289 ++++++++++++++++++++++++++++++++++++++- static/js/trade.js | 18 ++- templates/dashboard.html | 31 +++-- 5 files changed, 543 insertions(+), 14 deletions(-) diff --git a/install_trading.py b/install_trading.py index 4218e9f..16b65a4 100644 --- a/install_trading.py +++ b/install_trading.py @@ -166,6 +166,33 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "symbol_is_main": bool(meta.get("is_main")), } + def _breakeven_locked( + *, + entry: Optional[float], + stop_loss: Optional[float], + direction: str, + tick_size: Optional[float] = None, + trailing_r_locked: int = 0, + ) -> bool: + if int(trailing_r_locked or 0) >= 1: + return True + if entry is None or stop_loss is None: + return False + try: + entry_f = float(entry) + sl_f = float(stop_loss) + except (TypeError, ValueError): + return False + if entry_f <= 0: + return False + tick = float(tick_size or 0) or max(abs(entry_f) * 1e-6, 0.01) + buf = tick * max(2, get_trailing_be_tick_buffer(get_setting)) + d = (direction or "long").strip().lower() + near = abs(sl_f - entry_f) <= buf + tick + if d == "long": + return near and sl_f >= entry_f - tick * 0.05 + return near and sl_f <= entry_f + tick * 0.05 + def _schedule_recommend_refresh() -> None: from db_conn import DB_PATH @@ -1208,6 +1235,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "pending_orders": pending_for_row, "trailing_be": bool(mon.get("trailing_be")) if mon else False, "trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0, + "breakeven_locked": _breakeven_locked( + entry=entry, + stop_loss=sl, + direction=direction, + tick_size=tick.get("tick_size"), + trailing_r_locked=int(mon.get("trailing_r_locked") or 0) if mon else 0, + ), } def _compose_pending_row( diff --git a/static/css/dashboard.css b/static/css/dashboard.css index 5d54b24..df6186a 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -80,6 +80,7 @@ align-items: baseline; flex-wrap: wrap; gap: 0.35rem; + width: 100%; } .dash-risk-doc-ref { @@ -262,3 +263,187 @@ padding: 0.35rem 0.4rem; } } + +/* ---- 风控折叠 / 平板两行 ---- */ +.dash-section-toggle { + cursor: pointer; + user-select: none; +} + +.dash-section-toggle-label { + margin-right: 0.15rem; +} + +.dash-toggle-icon { + margin-left: auto; + font-size: 0.68rem; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.dashboard-risk-card.is-expanded .dash-toggle-icon { + transform: rotate(180deg); +} + +html:is([data-layout="phone"], [data-layout="tablet"], .layout-phone, .layout-tablet) +.dashboard-risk-card:not(.is-expanded) .dash-risk-body { + display: none; +} + +html[data-layout="tablet"] .dashboard-risk-grid, +html.layout-tablet .dashboard-risk-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + overflow-x: visible; +} + +html[data-layout="tablet"] .dashboard-risk-item, +html.layout-tablet .dashboard-risk-item { + flex: none; + min-width: 0; + border-right: 1px solid var(--table-border); + border-bottom: 1px solid var(--table-border); +} + +html[data-layout="tablet"] .dashboard-risk-item:nth-child(7n), +html.layout-tablet .dashboard-risk-item:nth-child(7n) { + border-right: none; +} + +html[data-layout="tablet"] .dashboard-risk-item:nth-last-child(-n+6), +html.layout-tablet .dashboard-risk-item:nth-last-child(-n+6) { + border-bottom: none; +} + +html:is([data-layout="phone"], .layout-phone) .dashboard-risk-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow-x: visible; +} + +html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item { + flex: none; + min-width: 0; + border-right: 1px solid var(--table-border); + border-bottom: 1px solid var(--table-border); +} + +html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item:nth-child(2n) { + border-right: none; +} + +html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item:nth-last-child(-n+1) { + border-bottom: none; +} + +/* ---- 持仓 / 关键位:桌面平板最多 3 行后滚动 ---- */ +html:not([data-layout="phone"]):not(.layout-phone) .dash-pos-table-wrap, +html:not([data-layout="phone"]):not(.layout-phone) .dash-keys-table-wrap { + max-height: 12rem; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +html:not([data-layout="phone"]):not(.layout-phone) .dash-pos-table-wrap thead th, +html:not([data-layout="phone"]):not(.layout-phone) .dash-keys-table-wrap thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--card-inner); +} + +/* ---- 手机简要列表 ---- */ +.dash-mobile-list { + display: none; +} + +html:is([data-layout="phone"], .layout-phone) .dash-mobile-list { + display: block; +} + +html:is([data-layout="phone"], .layout-phone) .dash-pos-table-wrap, +html:is([data-layout="phone"], .layout-phone) .dash-keys-table-wrap { + display: none; +} + +.dash-mobile-item { + display: block; + width: 100%; + text-align: left; + border: 1px solid var(--table-border); + border-radius: 10px; + background: var(--card-inner); + padding: 0.65rem 0.75rem; + margin-bottom: 0.5rem; + cursor: pointer; + color: inherit; + font: inherit; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.dash-mobile-item:hover, +.dash-mobile-item:focus-visible { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 1px rgba(76, 194, 255, 0.15); +} + +.dash-mobile-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.45rem; + margin-bottom: 0.3rem; +} + +.dash-mobile-item-title { + min-width: 0; + flex: 1; +} + +.dash-mobile-item-meta { + font-size: 0.74rem; + color: var(--text-muted); + line-height: 1.45; +} + +.dash-mobile-item-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.35rem; + font-size: 0.78rem; +} + +.dash-mobile-chevron { + font-size: 0.72rem; + color: var(--accent); + white-space: nowrap; +} + +.dash-mobile-empty { + padding: 0.85rem 0.35rem; + text-align: center; + color: var(--text-muted); + font-size: 0.82rem; +} + +.dash-be-badge { + font-size: 0.66rem; + vertical-align: middle; +} + +.dash-detail-modal { + max-width: 520px; + width: 100%; +} + +.dash-detail-modal .modal-grid .item.wide { + grid-column: 1 / -1; +} + +.dash-detail-modal .modal-actions { + margin-top: 1rem; + text-align: right; +} diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 9c7e67b..00a7420 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -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) + ? ' 已保本' : ''; + } + + function openDetailModal(title, items) { + if (!detailModal || !detailGridEl || !detailTitleEl) return; + detailTitleEl.textContent = title || '详情'; + detailGridEl.innerHTML = (items || []).map(function (it) { + var cls = it.wide ? ' item wide' : ' item'; + return ( + '
' + + '' + + '
' + (it.html != null ? it.html : escHtml(it.value)) + '
' + + '
' + ); + }).join(''); + detailModal.hidden = false; + detailModal.classList.add('show'); + } + + function closeDetailModal() { + if (!detailModal) return; + detailModal.hidden = true; + detailModal.classList.remove('show'); + } + + function initDetailModal() { + if (detailCloseBtn) detailCloseBtn.addEventListener('click', closeDetailModal); + if (detailModal) { + detailModal.addEventListener('click', function (ev) { + if (ev.target === detailModal) closeDetailModal(); + }); + } + } + function fmtNum(v, digits) { if (v === null || v === undefined || v === '') return '—'; var n = Number(v); @@ -85,6 +195,7 @@ titleInner += ' ' + escHtml(exchange) + ''; } titleInner += mainBadge; + titleInner += breakevenBadgeHtml(row); if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { titleInner += ' ' + escHtml(code) + ''; } else if (!name && code) { @@ -358,13 +469,18 @@ if (!ids) { keysBody.innerHTML = '暂无关键位监控'; lastKeyIds = ''; + lastKeyRows = []; + if (keysMobileList) keysMobileList.innerHTML = '
暂无关键位监控
'; return; } if (ids !== lastKeyIds) { lastKeyIds = ids; + lastKeyRows = keys || []; renderKeys(keys); + if (isPhoneLayout()) renderKeysMobile(lastKeyRows); } else { patchKeys(keys); + if (isPhoneLayout()) patchKeysMobile(keys); } } @@ -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: '' + fmtPnl(r.float_pnl) + '' }, + { label: '保证金', value: r.margin != null ? fmtMoney(r.margin) : '—' }, + { label: '止损', value: slText(r) }, + { label: '止盈', value: tpText(r) }, + ]; + } + + function keyDetailItems(k) { + return [ + { label: '品种', value: (k.symbol_name || k.symbol || k.symbol_code || '') }, + { label: '类型', value: k.monitor_type || '—' }, + { label: '周期', value: k.bar_period || '—' }, + { label: '上沿', value: fmtNum(k.upper) }, + { label: '下沿', value: fmtNum(k.lower) }, + { label: '现价', value: k.price != null ? fmtNum(k.price) : '—' }, + { label: '距上沿', value: k.dist_upper != null ? fmtNum(k.dist_upper) : '—' }, + { label: '距下沿', value: k.dist_lower != null ? fmtNum(k.dist_lower) : '—' }, + ]; + } + + function renderPositionsMobile(rows) { + if (!posMobileList) return; + if (!rows.length) { + posMobileList.innerHTML = '
暂无持仓
'; + posMobileCache = {}; + return; + } + posMobileCache = {}; + posMobileList.innerHTML = rows.map(function (r) { + var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); + posMobileCache[key] = r; + var title = symbolCellHtml(r) + ' ' + directionBadgeHtml(r); + var meta = fmtNum(r.lots) + ' 手 · 现价 ' + + (r.current_price != null ? fmtNum(r.current_price) : '—'); + return ( + '' + ); + }).join(''); + } + + function patchPositionsMobile(rows) { + if (!posMobileList || !rows) return; + rows.forEach(function (r) { + var key = r.key || r.position_key; + posMobileCache[key] = r; + var btn = posMobileList.querySelector('[data-pos-key="' + key + '"]'); + if (!btn) return; + var 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 = '
暂无关键位监控
'; + keysMobileCache = {}; + return; + } + keysMobileCache = {}; + keysMobileList.innerHTML = keys.map(function (k) { + keysMobileCache[String(k.id)] = k; + var title = symbolCellHtml(k); + var meta = escHtml(k.monitor_type || '—') + ' · ' + escHtml(k.bar_period || '—') + + ' · 现价 ' + (k.price != null ? fmtNum(k.price) : '—'); + var dist = '距上 ' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + + ' / 距下 ' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—'); + return ( + '' + ); + }).join(''); + } + + function patchKeysMobile(keys) { + if (!keysMobileList || !keys) return; + keys.forEach(function (k) { + keysMobileCache[String(k.id)] = k; + var btn = keysMobileList.querySelector('[data-key-id="' + k.id + '"]'); + if (!btn) return; + var metaEl = btn.querySelector('.dash-mobile-item-meta'); + var distEl = btn.querySelector('.dash-mobile-item-foot span:first-child'); + if (metaEl) { + metaEl.innerHTML = escHtml(k.monitor_type || '—') + ' · ' + escHtml(k.bar_period || '—') + + ' · 现价 ' + (k.price != null ? fmtNum(k.price) : '—'); + } + if (distEl) { + distEl.textContent = '距上 ' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + + ' / 距下 ' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—'); + } + }); + } + + function initMobileLists() { + if (posMobileList) { + posMobileList.addEventListener('click', function (ev) { + var btn = ev.target.closest('[data-pos-key]'); + if (!btn) return; + var key = btn.getAttribute('data-pos-key'); + var row = posMobileCache[key]; + if (!row) return; + var name = row.symbol_name || row.symbol || row.symbol_code || '持仓'; + openDetailModal(name, posDetailItems(row)); + }); + } + if (keysMobileList) { + keysMobileList.addEventListener('click', function (ev) { + var btn = ev.target.closest('[data-key-id]'); + if (!btn) return; + var k = keysMobileCache[btn.getAttribute('data-key-id')]; + if (!k) return; + openDetailModal(k.symbol_name || k.symbol || '关键位', keyDetailItems(k)); + }); + } + } + function renderPositions(rows) { + lastPosRows = rows || []; + if (isPhoneLayout()) { + renderPositionsMobile(lastPosRows); + } if (!posBody) return; if (!rows.length) { posBody.innerHTML = '暂无持仓'; 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(); })(); diff --git a/static/js/trade.js b/static/js/trade.js index 1cba767..8acbfc8 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -1024,7 +1024,23 @@ var name = row.symbol_name || row.symbol || ''; var code = row.symbol_code || ''; var mainBadge = row.symbol_is_main ? ' 主力' : ''; - var inner = name + mainBadge; + var beBadge = (function () { + if (row.breakeven_locked) return ' 已保本'; + if ((row.trailing_r_locked || 0) >= 1) return ' 已保本'; + 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 ' 已保本'; + } + return ''; + }()); + var inner = name + mainBadge + beBadge; if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { inner += ' ' + code + ''; } else if (!name && code) { diff --git a/templates/dashboard.html b/templates/dashboard.html index a016865..3efc761 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -32,22 +32,26 @@ -
-

- 风控说明 +
+ -

加载中…

-
+
+

加载中…

+
+

持仓信息

-
+
+
@@ -71,7 +75,8 @@

关键位监控

-
+
+
@@ -116,6 +121,16 @@ + + {% endblock %} {% block extra_js %}