diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index c438534..b47dfb3 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index de9be2f..3281454 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -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 = `正常 ${ok}·关注 ${warn}·异常 ${err}`; + } + + /** 监控卡片列数:桌面 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("") || '
无已启用账户
'; - syncMonitorGridColumns(box, rows.length); + displayRows + .map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r))) + .join("") || '
无已启用账户
'; + 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 `
+
+
+ + ${esc(row.name)} +
+
${fmt(upnl, 2)} U
+
${esc(posLine)}
+
UPD ${esc(tsShort)}
+
+
`; + } + function renderMonitorCard(row) { const ag = row.agent || {}; const pos = Array.isArray(ag.positions) ? ag.positions : []; diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index f5c9557..8ce4c06 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -8,7 +8,7 @@ - + @@ -35,7 +35,7 @@

MON 监控区

-

实时聚合持仓、关键位与趋势计划

+

实时聚合持仓、关键位与趋势计划;手机端为四所状态总览,点卡片进入全屏详情。

数据来源与复盘链接 @@ -45,6 +45,7 @@ 点「实例 / 策略交易 / 复盘」在本页内嵌打开(SSO 免密);按住 Ctrl 点击可在新标签打开。
+