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
+146 -1
View File
@@ -471,6 +471,129 @@ button:disabled {
box-shadow: 0 0 8px var(--red);
}
.status-dot.warn {
background: #ffb020;
box-shadow: 0 0 8px rgba(255, 176, 32, 0.45);
}
/* —— 手机监控总览瓦片 —— */
.monitor-alert-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px 10px;
margin: 0 0 10px;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--border-soft);
background: var(--panel);
font-size: 12px;
}
.monitor-alert-summary.hidden {
display: none !important;
}
.mas-item.mas-ok {
color: var(--green);
}
.mas-item.mas-warn {
color: #ffb020;
}
.mas-item.mas-err {
color: var(--red);
}
.mas-sep {
color: var(--muted);
}
.grid-monitor.grid-monitor-tiles {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 10px;
align-content: start;
}
.hub-tile {
margin: 0;
padding: 0;
min-height: 118px;
overflow: hidden;
}
.hub-tile .hub-tile-body {
cursor: pointer;
padding: 12px 12px 10px;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 118px;
}
.hub-tile-top {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.hub-tile-name {
font-family: var(--display);
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hub-tile-pnl {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
}
.hub-tile-pnl small {
font-size: 11px;
font-weight: 500;
color: var(--muted);
}
.hub-tile-meta {
font-size: 11px;
color: var(--muted);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hub-tile-foot {
margin-top: auto;
font-size: 10px;
color: var(--muted);
}
.hub-tile-error {
border-color: rgba(255, 77, 109, 0.45);
box-shadow: 0 0 0 1px rgba(255, 77, 109, 0.12);
}
.hub-tile-warn {
border-color: rgba(255, 176, 32, 0.45);
box-shadow: 0 0 0 1px rgba(255, 176, 32, 0.1);
}
.hub-tile-ok {
border-color: var(--border-soft);
}
.hub-tile-body:hover .hub-tile-name {
color: var(--accent);
}
.card-title {
font-family: var(--display);
font-size: 13px;
@@ -1784,12 +1907,34 @@ body.login-page {
min-height: 44px;
}
.grid-monitor,
.grid-monitor:not(.grid-monitor-tiles),
.settings-grid-wrap {
grid-template-columns: minmax(0, 1fr) !important;
gap: 12px;
}
.grid-monitor.grid-monitor-tiles {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 10px;
}
#page-monitor .hint-box,
#page-monitor .page-desc {
display: none;
}
#page-monitor .page-head {
margin-bottom: 8px;
}
#page-monitor .page-head h1 {
margin-bottom: 0;
}
.monitor-alert-summary {
margin-bottom: 8px;
}
.card-head {
flex-direction: column;
align-items: stretch;
+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 : [];
+4 -3
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260528-hub-ind-fs" />
<link rel="stylesheet" href="/assets/app.css?v=20260604-hub-mobile-tiles" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -35,7 +35,7 @@
<div id="page-monitor" class="page">
<div class="page-head">
<h1><span class="head-tag">MON</span> 监控区</h1>
<p class="page-desc">实时聚合持仓、关键位与趋势计划</p>
<p class="page-desc">实时聚合持仓、关键位与趋势计划;手机端为四所状态总览,点卡片进入全屏详情。</p>
</div>
<details class="hint-box">
<summary>数据来源与复盘链接</summary>
@@ -45,6 +45,7 @@
点「实例 / 策略交易 / 复盘」在<strong>本页内嵌</strong>打开(SSO 免密);按住 Ctrl 点击可在新标签打开。
</div>
</details>
<div id="monitor-alert-summary" class="monitor-alert-summary hidden" aria-live="polite"></div>
<div class="toolbar">
<button type="button" id="btn-monitor-refresh" class="primary">立即刷新</button>
<label class="chk-label">
@@ -245,6 +246,6 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260603-hub-binance-tick"></script>
<script src="/assets/app.js?v=20260604-hub-board-refresh"></script>
<script src="/assets/app.js?v=20260604-hub-mobile-tiles"></script>
</body>
</html>