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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-29 23:10:20 +08:00
parent d1ad0f9253
commit 2f5b5c4aae
6 changed files with 225 additions and 34 deletions
+12 -8
View File
@@ -1667,14 +1667,18 @@ def api_dashboard_live():
from dashboard_lib import build_dashboard_payload from dashboard_lib import build_dashboard_payload
_dashboard_sync_tick["n"] += 1 _dashboard_sync_tick["n"] += 1
sync_trades = _dashboard_sync_tick["n"] % 5 == 0 sync_trades = _dashboard_sync_tick["n"] % 15 == 0
payload = build_dashboard_payload( try:
get_db=get_db, payload = build_dashboard_payload(
get_setting=get_setting, get_db=get_db,
fetch_price=fetch_price, get_setting=get_setting,
sync_ctp_trades=sync_trades, fetch_price=fetch_price,
) sync_ctp_trades=sync_trades,
return jsonify(payload) )
return jsonify(payload)
except Exception as exc:
app.logger.exception("dashboard live: %s", exc)
return jsonify({"ok": False, "error": "看板数据暂时不可用"}), 503
@app.route("/market") @app.route("/market")
+20 -1
View File
@@ -10,6 +10,25 @@ from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
_TZ = ZoneInfo("Asia/Shanghai") _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: def _direction_label(direction: str) -> str:
@@ -174,7 +193,7 @@ def build_dashboard_payload(
sina = r["sina_code"] or "" sina = r["sina_code"] or ""
upper = float(r["upper"] or 0) upper = float(r["upper"] or 0)
lower = float(r["lower"] 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 dist_upper = dist_lower = None
if price is not None: if price is not None:
dist_upper = round(upper - float(price), 2) dist_upper = round(upper - float(price), 2)
+23 -1
View File
@@ -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-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; display: none;
} }
@@ -416,6 +417,27 @@ html:is([data-layout="phone"], .layout-phone) .dash-keys-table-wrap {
font-size: 0.78rem; 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 { .dash-mobile-chevron {
font-size: 0.72rem; font-size: 0.72rem;
color: var(--accent); color: var(--accent);
+167 -22
View File
@@ -20,17 +20,21 @@
var riskBodyEl = document.getElementById('dash-risk-body'); var riskBodyEl = document.getElementById('dash-risk-body');
var posMobileList = document.getElementById('dash-pos-mobile-list'); var posMobileList = document.getElementById('dash-pos-mobile-list');
var keysMobileList = document.getElementById('dash-keys-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 detailModal = document.getElementById('dash-detail-modal');
var detailTitleEl = document.getElementById('dash-detail-title'); var detailTitleEl = document.getElementById('dash-detail-title');
var detailGridEl = document.getElementById('dash-detail-grid'); var detailGridEl = document.getElementById('dash-detail-grid');
var detailCloseBtn = document.getElementById('dash-detail-close'); var detailCloseBtn = document.getElementById('dash-detail-close');
var pollTimer = null; var pollTimer = null;
var pollInFlight = false;
var pollFailStreak = 0;
var positionSource = null; var positionSource = null;
var posRowCache = {}; var posRowCache = {};
var posRenderSig = ''; var posRenderSig = '';
var posMobileCache = {}; var posMobileCache = {};
var keysMobileCache = {}; var keysMobileCache = {};
var closesMobileCache = {};
var lastPosRows = []; var lastPosRows = [];
var lastKeyRows = []; var lastKeyRows = [];
var lastKeyIds = ''; var lastKeyIds = '';
@@ -181,6 +185,77 @@
return '—'; 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;
titleInner += breakevenBadgeHtml(row);
if (code && String(name).toLowerCase() !== String(code).toLowerCase()) {
titleInner += ' <span class="text-accent">' + escHtml(code) + '</span> ' + 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> ' + directionBadgeHtml(row);
} else {
titleInner += ' ' + directionBadgeHtml(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) { function symbolCellHtml(row) {
var name = row.symbol_name || row.symbol || ''; var name = row.symbol_name || row.symbol || '';
var code = row.symbol_code || ''; var code = row.symbol_code || '';
@@ -440,12 +515,40 @@
}); });
} }
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) { function renderCloses(closes) {
if (!closesBody) return; if (!closesBody) return;
if (!closes || !closes.length) { if (!closes || !closes.length) {
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>'; closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
if (isPhoneLayout() && closesMobileList) {
closesMobileList.innerHTML = '<div class="dash-mobile-empty">暂无平仓记录</div>';
}
closesMobileCache = {};
return; return;
} }
if (isPhoneLayout()) renderClosesMobile(closes);
closesBody.innerHTML = closes.map(function (c) { closesBody.innerHTML = closes.map(function (c) {
var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl); var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl);
return ( return (
@@ -489,6 +592,10 @@
if (!closes || !closes.length) { if (!closes || !closes.length) {
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>'; closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
lastCloseHeadId = null; lastCloseHeadId = null;
if (isPhoneLayout() && closesMobileList) {
closesMobileList.innerHTML = '<div class="dash-mobile-empty">暂无平仓记录</div>';
}
closesMobileCache = {};
return; return;
} }
var headId = closes[0].id; 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: '<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) { function renderPositionsMobile(rows) {
if (!posMobileList) return; if (!posMobileList) return;
if (!rows.length) { if (!rows.length) {
@@ -542,19 +665,16 @@
posMobileList.innerHTML = rows.map(function (r) { posMobileList.innerHTML = rows.map(function (r) {
var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
posMobileCache[key] = r; posMobileCache[key] = r;
var title = symbolCellHtml(r) + ' ' + directionBadgeHtml(r); var summary = mobileSummaryHtml(
var meta = fmtNum(r.lots) + ' 手 · 现价 ' + r.lots, '现价', r.current_price, r.float_pnl
(r.current_price != null ? fmtNum(r.current_price) : '—'); );
return ( return (
'<button type="button" class="dash-mobile-item" data-pos-key="' + escHtml(key) + '">' + '<button type="button" class="dash-mobile-item" data-pos-key="' + escHtml(key) + '">' +
'<div class="dash-mobile-item-head">' + '<div class="dash-mobile-item-head">' +
'<div class="dash-mobile-item-title">' + title + '</div>' + '<div class="dash-mobile-item-title">' + mobileSymbolTitleHtml(r) + '</div>' +
'</div>' + '</div>' +
'<div class="dash-mobile-item-meta">' + escHtml(meta) + '</div>' + mobileItemFootHtml(summary) +
'<div class="dash-mobile-item-foot">' + '</button>'
'<span class="' + pnlClass(r.float_pnl) + '">' + fmtPnl(r.float_pnl) + '</span>' +
'<span class="dash-mobile-chevron">查看详情 </span>' +
'</div></button>'
); );
}).join(''); }).join('');
} }
@@ -566,15 +686,11 @@
posMobileCache[key] = r; posMobileCache[key] = r;
var btn = posMobileList.querySelector('[data-pos-key="' + key + '"]'); var btn = posMobileList.querySelector('[data-pos-key="' + key + '"]');
if (!btn) return; if (!btn) return;
var metaEl = btn.querySelector('.dash-mobile-item-meta'); var summaryEl = btn.querySelector('.dash-mobile-item-summary');
var pnlEl = btn.querySelector('.dash-mobile-item-foot span:first-child'); if (summaryEl) {
if (metaEl) { summaryEl.innerHTML = mobileSummaryHtml(
metaEl.textContent = fmtNum(r.lots) + ' 手 · 现价 ' + r.lots, '现价', r.current_price, r.float_pnl
(r.current_price != null ? fmtNum(r.current_price) : '—'); );
}
if (pnlEl) {
pnlEl.textContent = fmtPnl(r.float_pnl);
pnlEl.className = pnlClass(r.float_pnl);
} }
}); });
} }
@@ -646,6 +762,15 @@
openDetailModal(k.symbol_name || k.symbol || '关键位', keyDetailItems(k)); 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) { function renderPositions(rows) {
@@ -760,21 +885,40 @@
} }
} }
function pollIntervalMs() {
if (isPhoneLayout() || isTabletLayout()) return 3000;
return 2000;
}
function pollDashboard() { function pollDashboard() {
if (pollInFlight) return;
pollInFlight = true;
fetch('/api/dashboard/live') fetch('/api/dashboard/live')
.then(function (r) { .then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status); if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json(); return r.json();
}) })
.then(function (data) { .then(function (data) {
if (!data.ok) return; pollFailStreak = 0;
if (!data.ok) {
if (updatedEl) updatedEl.textContent = data.error || '看板数据暂不可用';
return;
}
applyAccount(data); applyAccount(data);
applyRisk(data.risk, data.account); applyRisk(data.risk, data.account);
applyKeys(data.keys || []); applyKeys(data.keys || []);
applyCloses(data.closes || []); applyCloses(data.closes || []);
}) })
.catch(function () { .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.close();
positionSource = null; positionSource = null;
} }
setTimeout(connectPositionStream, 3000); var delay = (isPhoneLayout() || isTabletLayout()) ? 8000 : 3000;
setTimeout(connectPositionStream, delay);
}; };
} }
function startPolling() { function startPolling() {
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
pollDashboard(); pollDashboard();
pollTimer = setInterval(pollDashboard, 1000); pollTimer = setInterval(pollDashboard, pollIntervalMs());
} }
function stopPolling() { function stopPolling() {
+1 -1
View File
@@ -2,7 +2,7 @@
* 专有软件 未经授权禁止复制传播转售 * 专有软件 未经授权禁止复制传播转售
* 详见 LICENSE.zh-CN.txt * 详见 LICENSE.zh-CN.txt
*/ */
var CACHE_VERSION = 'qihuo-v9'; var CACHE_VERSION = 'qihuo-v10';
var STATIC_CACHE = CACHE_VERSION + '-static'; var STATIC_CACHE = CACHE_VERSION + '-static';
var STATIC_ASSETS = [ var STATIC_ASSETS = [
'/static/css/base.css', '/static/css/base.css',
+2 -1
View File
@@ -99,7 +99,8 @@
<div class="card dashboard-section"> <div class="card dashboard-section">
<h2>平仓记录</h2> <h2>平仓记录</h2>
<div class="card-scroll"> <div class="dash-mobile-list" id="dash-closes-mobile-list"></div>
<div class="card-scroll dash-closes-table-wrap">
<table class="dashboard-table" id="dash-closes-table"> <table class="dashboard-table" id="dash-closes-table">
<thead> <thead>
<tr> <tr>