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:
@@ -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 : [];
|
||||
|
||||
Reference in New Issue
Block a user