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:
@@ -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")
|
||||
|
||||
+20
-1
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
+167
-22
@@ -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 '<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) {
|
||||
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 = '<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) {
|
||||
if (!closesBody) return;
|
||||
if (!closes || !closes.length) {
|
||||
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
|
||||
if (isPhoneLayout() && closesMobileList) {
|
||||
closesMobileList.innerHTML = '<div class="dash-mobile-empty">暂无平仓记录</div>';
|
||||
}
|
||||
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 = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
|
||||
lastCloseHeadId = null;
|
||||
if (isPhoneLayout() && closesMobileList) {
|
||||
closesMobileList.innerHTML = '<div class="dash-mobile-empty">暂无平仓记录</div>';
|
||||
}
|
||||
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: '<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) {
|
||||
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 (
|
||||
'<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 class="dash-mobile-item-title">' + mobileSymbolTitleHtml(r) + '</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>'
|
||||
mobileItemFootHtml(summary) +
|
||||
'</button>'
|
||||
);
|
||||
}).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() {
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
|
||||
<div class="card dashboard-section">
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
Reference in New Issue
Block a user