feat(hub): mobile 2x2 exchange alert dashboard tiles

Phone monitor list shows per-exchange tiles with offline, >10% float loss, and missing SL alerts; tap opens full desktop-style detail view.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-03 23:07:59 +08:00
parent d07357b98e
commit ed3ff747f4
3 changed files with 317 additions and 8 deletions
+167 -4
View File
@@ -9,6 +9,8 @@
const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
const MONITOR_BOARD_SNAPSHOT_URL = "/api/monitor/board/snapshot";
const HUB_MONITOR_SNAPSHOT_TIMEOUT_MS = 15000;
/** 关注:浮亏超过交易账户余额的比例(10%) */
const HUB_ALERT_FLOAT_LOSS_RATIO = 0.1;
let lastMonitorBoardUpdatedAt = "";
let localBoardVersion = 0;
let monitorBoardInFlight = false;
@@ -497,6 +499,7 @@
? `UPD ${ts}`
: "";
}
updateMonitorAlertSummary(rows || []);
renderMonitorGrid(rows || []);
}
@@ -520,11 +523,125 @@
return window.matchMedia("(max-width: 720px)").matches;
}
/** 监控卡片列数:桌面 3/2 列;手机端固定单列 */
function positionHasContracts(p) {
const c = Number(p && p.contracts);
return Number.isFinite(c) && Math.abs(c) >= 1e-12;
}
function exchangeNeedsFlask(row) {
const caps = row.capabilities || [];
return caps.includes("key") || caps.includes("trend");
}
function positionMissingStopLoss(pos, orders, trends) {
if (!positionHasContracts(pos)) return false;
const mo = findMonitorOrder(orders, pos.symbol, pos.side);
const tp = findTrendPlan(trends, pos.symbol, pos.side);
const tpsl = resolvePositionTpsl(pos, mo, tp);
const sl = tpsl.sl;
if (sl !== "" && sl != null && Number.isFinite(Number(sl))) return false;
const cond = condOrdersFromPosition(pos);
const picked = pickExTpslOrders(cond);
if (picked.sl && picked.sl.trigger_price != null) return false;
const et = pos.exchange_tpsl;
if (et && et.sl) return false;
return true;
}
function analyzeExchangeAlert(row) {
const ag = row.agent || {};
const hm = row.hub_monitor || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const flaskOk = row.flask_ok !== false && hm.ok !== false;
const upnl = Number(ag.total_unrealized_pnl);
const balance = Number(ag.balance_usdt);
const sortUpnl = Number.isFinite(upnl) ? upnl : 0;
if (!row.http_ok) {
return { level: "error", summary: "子代理离线", sortUpnl: 0 };
}
if (ag.ok === false) {
return {
level: "error",
summary: (ag.error || row.error || "子代理异常").slice(0, 24),
sortUpnl: 0,
};
}
if (exchangeNeedsFlask(row) && !flaskOk) {
const fe = row.flask_error || hm.error || hm.msg || "Flask未连通";
return { level: "error", summary: String(fe).slice(0, 24), sortUpnl };
}
const orders = flaskOk ? hm.orders || [] : [];
const trends = flaskOk ? hm.trends || [] : [];
let missingSl = false;
for (const p of pos) {
if (positionMissingStopLoss(p, orders, trends)) {
missingSl = true;
break;
}
}
if (Number.isFinite(upnl) && upnl < 0 && Number.isFinite(balance) && balance > 0) {
const lossPct = (Math.abs(upnl) / balance) * 100;
if (lossPct >= HUB_ALERT_FLOAT_LOSS_RATIO * 100) {
return {
level: "warn",
summary: `浮亏超10% · ${fmt(upnl, 2)}U`,
sortUpnl,
};
}
}
if (missingSl) {
return { level: "warn", summary: "缺止损", sortUpnl };
}
const openCount = pos.filter(positionHasContracts).length;
return {
level: "ok",
summary: openCount ? "正常" : "空仓",
sortUpnl,
};
}
function sortRowsForMobileDashboard(rows) {
const levelOrder = { error: 0, warn: 1, ok: 2 };
return rows
.map((r) => ({ r, a: analyzeExchangeAlert(r) }))
.sort((x, y) => {
const ld = levelOrder[x.a.level] - levelOrder[y.a.level];
if (ld !== 0) return ld;
return (x.a.sortUpnl || 0) - (y.a.sortUpnl || 0);
})
.map((x) => x.r);
}
function updateMonitorAlertSummary(rows) {
const el = document.getElementById("monitor-alert-summary");
if (!el) return;
if (!isMobileLayout() || !rows.length) {
el.classList.add("hidden");
el.innerHTML = "";
return;
}
let err = 0;
let warn = 0;
let ok = 0;
rows.forEach((r) => {
const lv = analyzeExchangeAlert(r).level;
if (lv === "error") err += 1;
else if (lv === "warn") warn += 1;
else ok += 1;
});
el.classList.remove("hidden");
el.innerHTML = `<span class="mas-item mas-ok">正常 ${ok}</span><span class="mas-sep">·</span><span class="mas-item mas-warn">关注 ${warn}</span><span class="mas-sep">·</span><span class="mas-item mas-err">异常 ${err}</span>`;
}
/** 监控卡片列数:桌面 3/2 列;手机端 2 列瓦片 */
function syncMonitorGridColumns(gridEl, count) {
if (!gridEl) return;
if (isMobileLayout()) {
gridEl.style.gridTemplateColumns = "minmax(0, 1fr)";
gridEl.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))";
return;
}
let cols = 3;
@@ -538,12 +655,22 @@
function initMobileLayout() {
let resizeTimer = null;
let wasMobile = isMobileLayout();
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const nowMobile = isMobileLayout();
if (lastMonitorRows.length && nowMobile !== wasMobile) {
wasMobile = nowMobile;
renderMonitorGrid(lastMonitorRows);
updateMonitorAlertSummary(lastMonitorRows);
return;
}
wasMobile = nowMobile;
const box = document.getElementById("monitor-grid");
if (box && lastMonitorRows.length) {
syncMonitorGridColumns(box, lastMonitorRows.length);
updateMonitorAlertSummary(lastMonitorRows);
}
}, 120);
});
@@ -766,9 +893,14 @@
if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) {
closeExchangeFullscreen();
}
const mobileTiles = isMobileLayout() && !expandedExchangeId;
const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows;
box.classList.toggle("grid-monitor-tiles", mobileTiles);
box.innerHTML =
rows.map((r) => renderMonitorCard(r)).join("") || '<div class="err">无已启用账户</div>';
syncMonitorGridColumns(box, rows.length);
displayRows
.map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r)))
.join("") || '<div class="err">无已启用账户</div>';
syncMonitorGridColumns(box, displayRows.length);
bindMonitorInteractions(box);
if (expandedExchangeId && fs && fsInner) {
@@ -1775,6 +1907,37 @@
}
}
function renderMonitorTile(row) {
const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const alert = analyzeExchangeAlert(row);
const upnl = ag.total_unrealized_pnl;
const openCount = pos.filter(positionHasContracts).length;
const dotCls =
alert.level === "error" ? "bad" : alert.level === "warn" ? "warn" : "ok";
const tileCls =
alert.level === "error"
? "hub-tile-error"
: alert.level === "warn"
? "hub-tile-warn"
: "hub-tile-ok";
const ts = (lastMonitorBoardUpdatedAt || "").replace("T", " ");
const tsShort = ts ? ts.slice(-8) : "—";
const posLine =
openCount > 0 ? `${openCount}仓 · ${alert.summary}` : alert.summary;
return `<div class="card hub-tile ${tileCls}" data-ex-id="${esc(row.id)}">
<div class="hub-tile-body card-expand-zone" title="点击进入全屏详情">
<div class="hub-tile-top">
<span class="status-dot ${dotCls}" aria-hidden="true"></span>
<span class="hub-tile-name">${esc(row.name)}</span>
</div>
<div class="hub-tile-pnl ${pnlCls(upnl)}">${fmt(upnl, 2)} <small>U</small></div>
<div class="hub-tile-meta">${esc(posLine)}</div>
<div class="hub-tile-foot">UPD ${esc(tsShort)}</div>
</div>
</div>`;
}
function renderMonitorCard(row) {
const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];