From 2f5b5c4aae0d414cc0c0ffe48dbefcd676530e31 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 29 Jun 2026 23:10:20 +0800 Subject: [PATCH] Fix dashboard mobile load issues and simplify card layout. Reduce poll pressure on phone/tablet, cache key prices, and handle live API errors gracefully. Rework mobile position and close cards with inline direction, compact P/L line, and detail modal. Co-authored-by: Cursor --- app.py | 20 +++-- dashboard_lib.py | 21 ++++- static/css/dashboard.css | 24 ++++- static/js/dashboard.js | 189 ++++++++++++++++++++++++++++++++++----- static/sw.js | 2 +- templates/dashboard.html | 3 +- 6 files changed, 225 insertions(+), 34 deletions(-) diff --git a/app.py b/app.py index cd1e5af..5a5106f 100644 --- a/app.py +++ b/app.py @@ -1667,14 +1667,18 @@ def api_dashboard_live(): from dashboard_lib import build_dashboard_payload _dashboard_sync_tick["n"] += 1 - sync_trades = _dashboard_sync_tick["n"] % 5 == 0 - payload = build_dashboard_payload( - get_db=get_db, - get_setting=get_setting, - fetch_price=fetch_price, - sync_ctp_trades=sync_trades, - ) - return jsonify(payload) + sync_trades = _dashboard_sync_tick["n"] % 15 == 0 + try: + payload = build_dashboard_payload( + get_db=get_db, + get_setting=get_setting, + fetch_price=fetch_price, + sync_ctp_trades=sync_trades, + ) + return jsonify(payload) + except Exception as exc: + app.logger.exception("dashboard live: %s", exc) + return jsonify({"ok": False, "error": "看板数据暂时不可用"}), 503 @app.route("/market") diff --git a/dashboard_lib.py b/dashboard_lib.py index 99e3071..2c2178d 100644 --- a/dashboard_lib.py +++ b/dashboard_lib.py @@ -10,6 +10,25 @@ from typing import Any, Callable, Optional from zoneinfo import ZoneInfo _TZ = ZoneInfo("Asia/Shanghai") +_PRICE_CACHE: dict[str, tuple[float, float]] = {} +_PRICE_CACHE_TTL = 2.0 + + +def _cached_fetch_price( + fetch_price: Callable[[str, str, str], Optional[float]], + sym: str, + market: str, + sina: str, +) -> Optional[float]: + key = sym or "" + now = datetime.now().timestamp() + hit = _PRICE_CACHE.get(key) + if hit and (now - hit[1]) < _PRICE_CACHE_TTL: + return hit[0] + price = fetch_price(sym, market, sina) + if price is not None: + _PRICE_CACHE[key] = (float(price), now) + return price def _direction_label(direction: str) -> str: @@ -174,7 +193,7 @@ def build_dashboard_payload( sina = r["sina_code"] or "" upper = float(r["upper"] or 0) lower = float(r["lower"] or 0) - price = fetch_price(sym, market, sina) + price = _cached_fetch_price(fetch_price, sym, market, sina) dist_upper = dist_lower = None if price is not None: dist_upper = round(upper - float(price), 2) diff --git a/static/css/dashboard.css b/static/css/dashboard.css index df6186a..4cff042 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -362,7 +362,8 @@ html:is([data-layout="phone"], .layout-phone) .dash-mobile-list { } html:is([data-layout="phone"], .layout-phone) .dash-pos-table-wrap, -html:is([data-layout="phone"], .layout-phone) .dash-keys-table-wrap { +html:is([data-layout="phone"], .layout-phone) .dash-keys-table-wrap, +html:is([data-layout="phone"], .layout-phone) .dash-closes-table-wrap { display: none; } @@ -416,6 +417,27 @@ html:is([data-layout="phone"], .layout-phone) .dash-keys-table-wrap { font-size: 0.78rem; } +.dash-mobile-item-summary { + flex: 1; + min-width: 0; + line-height: 1.45; + color: var(--text-muted); +} + +.dash-mobile-item-summary .pnl-pos { + color: var(--profit); + font-weight: 600; +} + +.dash-mobile-item-summary .pnl-neg { + color: var(--loss); + font-weight: 600; +} + +.dash-mobile-item-title .badge { + vertical-align: middle; +} + .dash-mobile-chevron { font-size: 0.72rem; color: var(--accent); diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 00a7420..469a755 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -20,17 +20,21 @@ 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 = ''; @@ -181,6 +185,77 @@ 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 '' + escHtml(fmtMobilePnlNum(v)) + ''; + } + + 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 + ? ' 主力' : ''; + var titleInner = escHtml(name); + if (exchange) { + titleInner += ' ' + escHtml(exchange) + ''; + } + titleInner += mainBadge; + titleInner += breakevenBadgeHtml(row); + if (code && String(name).toLowerCase() !== String(code).toLowerCase()) { + titleInner += ' ' + escHtml(code) + ' ' + directionBadgeHtml(row); + } else if (!name && code) { + titleInner = (exchange + ? '' + escHtml(exchange) + ' ' + : '') + '' + escHtml(code) + ' ' + directionBadgeHtml(row); + } else { + titleInner += ' ' + directionBadgeHtml(row); + } + return titleInner; + } + + function mobileItemFootHtml(summaryHtml) { + return ( + '
' + + '' + summaryHtml + '' + + '查看详情 ›' + + '
' + ); + } + function symbolCellHtml(row) { var name = row.symbol_name || row.symbol || ''; var code = row.symbol_code || ''; @@ -440,12 +515,40 @@ }); } + function renderClosesMobile(closes) { + if (!closesMobileList) return; + if (!closes || !closes.length) { + closesMobileList.innerHTML = '
暂无平仓记录
'; + 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 ( + '' + ); + }).join(''); + } + function renderCloses(closes) { if (!closesBody) return; if (!closes || !closes.length) { closesBody.innerHTML = '暂无平仓记录'; + if (isPhoneLayout() && closesMobileList) { + closesMobileList.innerHTML = '
暂无平仓记录
'; + } + 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 ( @@ -489,6 +592,10 @@ if (!closes || !closes.length) { closesBody.innerHTML = '暂无平仓记录'; lastCloseHeadId = null; + if (isPhoneLayout() && closesMobileList) { + closesMobileList.innerHTML = '
暂无平仓记录
'; + } + closesMobileCache = {}; return; } var headId = closes[0].id; @@ -531,6 +638,22 @@ ]; } + 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: '' + fmtPnl(c.pnl) + '' }, + { label: '净盈亏', html: '' + fmtPnl(net) + '' }, + { 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) { @@ -542,19 +665,16 @@ 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) : '—'); + var summary = mobileSummaryHtml( + r.lots, '现价', r.current_price, r.float_pnl + ); return ( '' + mobileItemFootHtml(summary) + + '' ); }).join(''); } @@ -566,15 +686,11 @@ 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); + var summaryEl = btn.querySelector('.dash-mobile-item-summary'); + if (summaryEl) { + summaryEl.innerHTML = mobileSummaryHtml( + r.lots, '现价', r.current_price, r.float_pnl + ); } }); } @@ -646,6 +762,15 @@ 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) { @@ -760,21 +885,40 @@ } } + 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) { - if (!data.ok) return; + 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 () { - if (updatedEl) updatedEl.textContent = '看板数据加载失败'; + pollFailStreak += 1; + if (updatedEl) { + updatedEl.textContent = pollFailStreak >= 3 + ? '看板连接失败,正在重试…' + : '看板数据加载失败'; + } + }) + .finally(function () { + pollInFlight = false; }); } @@ -800,14 +944,15 @@ positionSource.close(); positionSource = null; } - setTimeout(connectPositionStream, 3000); + var delay = (isPhoneLayout() || isTabletLayout()) ? 8000 : 3000; + setTimeout(connectPositionStream, delay); }; } function startPolling() { if (pollTimer) clearInterval(pollTimer); pollDashboard(); - pollTimer = setInterval(pollDashboard, 1000); + pollTimer = setInterval(pollDashboard, pollIntervalMs()); } function stopPolling() { diff --git a/static/sw.js b/static/sw.js index 892a171..bb7ec8c 100644 --- a/static/sw.js +++ b/static/sw.js @@ -2,7 +2,7 @@ * 专有软件 — 未经授权禁止复制、传播、转售。 * 详见 LICENSE.zh-CN.txt */ -var CACHE_VERSION = 'qihuo-v9'; +var CACHE_VERSION = 'qihuo-v10'; var STATIC_CACHE = CACHE_VERSION + '-static'; var STATIC_ASSETS = [ '/static/css/base.css', diff --git a/templates/dashboard.html b/templates/dashboard.html index 3efc761..ea27a66 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -99,7 +99,8 @@

平仓记录

-
+
+