(function () { const toast = document.getElementById("toast"); let settingsCache = null; let authState = { required: false, logged_in: true }; let tpslPending = null; let lastMonitorRows = []; let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || ""; const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1"; 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; let monitorBoardFetchPending = false; let monitorBoardSlowHintTimer = null; let boardEventSource = null; let sseReconnectTimer = null; async function apiFetch(url, opts) { const r = await fetch(url, opts); if (r.status === 401) { const next = encodeURIComponent(location.pathname + location.search); location.href = "/login?next=" + next; throw new Error("未登录"); } return r; } let instanceFrameUrl = ""; /** @type {{ exchangeId: string, nextPath: string, title: string } | null} */ let instanceFrameCtx = null; function isHubEmbedded() { try { return window.self !== window.top; } catch (_) { return true; } } /** 在 LocalNav 等父页 iframe 内:直接替换本 iframe 地址,避免 postMessage / 三层嵌套 */ function openInstanceInParentFrame(url) { try { window.location.assign(url); return true; } catch (_) { return false; } } async function fetchInstanceOpenUrl(exchangeId, nextPath, opts) { const options = opts || {}; const next = nextPath || "/"; const q = new URLSearchParams({ exchange_id: String(exchangeId), next }); if (options.embed) q.set("embed", "1"); const r = await apiFetch("/api/instance/open-url?" + q.toString()); const j = await r.json(); if (!j.ok || !j.url) { throw new Error(j.detail || "无法生成打开链接"); } return j.url; } async function openInstance(exchangeId, nextPath, opts) { const options = opts || {}; const newTab = !!options.newTab; const next = nextPath || "/"; try { const embedded = isHubEmbedded(); const url = await fetchInstanceOpenUrl(exchangeId, next, { embed: embedded }); if (newTab) { window.open(url, "_blank", "noopener"); return; } const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId)); const title = row ? row.name : exchangeId; instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title }; if (embedded) { try { window.parent.postMessage( { type: "hub:open-instance-nav", exchangeId: String(exchangeId), nextPath: next, title, }, "*" ); } catch (_) {} if (openInstanceInParentFrame(url)) return; } openInstanceFrame(url, title); } catch (e) { showToast(String(e), true); } } async function refreshInstanceFrame() { if (!instanceFrameCtx) { if (instanceFrameUrl) { const frame = document.getElementById("instance-frame"); if (frame) frame.src = instanceFrameUrl; } return; } try { const url = await fetchInstanceOpenUrl( instanceFrameCtx.exchangeId, instanceFrameCtx.nextPath, { embed: isHubEmbedded() } ); instanceFrameUrl = url; const frame = document.getElementById("instance-frame"); if (frame) frame.src = url; } catch (e) { showToast(String(e), true); } } function openInstanceFrame(url, title) { const shell = document.getElementById("instance-frame-shell"); const frame = document.getElementById("instance-frame"); const titleEl = document.getElementById("instance-frame-title"); if (!shell || !frame) { window.open(url, "_blank", "noopener"); return; } closeExchangeFullscreen(); instanceFrameUrl = url; if (titleEl) titleEl.textContent = title || "实例"; frame.src = url; shell.classList.remove("hidden"); shell.setAttribute("aria-hidden", "false"); document.body.classList.add("hub-instance-frame-open"); } function closeInstanceFrame() { const shell = document.getElementById("instance-frame-shell"); const frame = document.getElementById("instance-frame"); instanceFrameUrl = ""; instanceFrameCtx = null; if (frame) frame.src = "about:blank"; if (shell) { shell.classList.add("hidden"); shell.setAttribute("aria-hidden", "true"); } document.body.classList.remove("hub-instance-frame-open"); } /** @deprecated use openInstance */ async function openInstanceInBrowser(exchangeId, nextPath) { return openInstance(exchangeId, nextPath, { newTab: false }); } async function initAuth() { try { const r = await fetch("/api/auth/status"); authState = await r.json(); const btn = document.getElementById("btn-logout"); if (btn) btn.style.display = authState.required ? "" : "none"; if (authState.required && !authState.logged_in) { location.href = "/login?next=" + encodeURIComponent(location.pathname + location.search); return false; } return true; } catch (_) { return true; } } function showToast(msg, isErr) { toast.textContent = msg; toast.style.borderColor = isErr ? "var(--red)" : "var(--border)"; toast.classList.add("show"); clearTimeout(showToast._t); showToast._t = setTimeout(() => toast.classList.remove("show"), 7000); } function esc(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmt(n, d) { if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); } /** 交易所持仓开仓价(四所子代理 entry_price) */ function positionEntryPrice(pos) { if (!pos) return null; const n = Number(pos.entry_price); if (!Number.isFinite(n) || n <= 0) return null; return n; } function symbolPriceKey(sym) { return (sym || "").trim().toUpperCase(); } function buildPriceTickMap(row) { const map = Object.create(null); const put = (sym, tick) => { const k = symbolPriceKey(sym); if (!k || tick == null || !Number.isFinite(Number(tick))) return; if (map[k] == null) map[k] = Number(tick); }; ((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick)); const hm = (row && row.hub_monitor) || {}; (hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick)); (hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick)); return map; } function lookupPriceTick(symbol, tickMap) { if (!tickMap || !symbol) return null; const k = symbolPriceKey(symbol); if (tickMap[k] != null) return tickMap[k]; const base = normSym(symbol); if (base && tickMap[base] != null) return tickMap[base]; return null; } function decimalsFromTick(tick) { if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null; const t = Number(tick); if (t >= 1) return 0; const s = t.toFixed(12).replace(/0+$/, ""); const frac = s.split(".")[1]; return frac ? Math.min(12, frac.length) : 0; } function defaultPriceDecimals(value) { const n = Number(value); if (!Number.isFinite(n)) return 4; const av = Math.abs(n); if (av >= 10000) return 2; if (av >= 100) return 3; if (av >= 1) return 4; if (av >= 0.01) return 6; return 8; } /** 按交易所 tick(子代理/Flask 下发)格式化价格 */ function fmtSymbolPrice(value, symbol, tickMap, displayFallback) { if (displayFallback != null && displayFallback !== "") return String(displayFallback); if (value == null || value === "") return "—"; const n = Number(value); if (!Number.isFinite(n)) return "—"; const tick = lookupPriceTick(symbol, tickMap); const d = decimalsFromTick(tick); return fmt(n, d != null ? d : defaultPriceDecimals(n)); } function fmtEntryPrice(pos, tickMap) { if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt); return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap); } function positionMarkPrice(pos) { if (!pos) return null; const n = Number(pos.mark_price); if (!Number.isFinite(n) || n <= 0) return null; return n; } function fmtMarkPrice(pos, tickMap) { if (pos && pos.mark_price_fmt) return String(pos.mark_price_fmt); return fmtSymbolPrice(positionMarkPrice(pos), pos && pos.symbol, tickMap); } function pnlCls(v) { const n = Number(v); if (!Number.isFinite(n) || n === 0) return ""; return n > 0 ? "pnl-pos" : "pnl-neg"; } function normSide(side) { const s = (side || "").toLowerCase(); if (s === "buy") return "long"; if (s === "sell") return "short"; return s; } function sideDirCls(side) { const s = normSide(side); if (s === "long") return "side-long"; if (s === "short") return "side-short"; return ""; } function sideDirLabel(side) { const s = normSide(side); if (s === "long") return "做多"; if (s === "short") return "做空"; return side || "—"; } function monitorOrderSourceLabel(mo) { const o = mo || {}; const tid = Number(o.trend_plan_id); if (Number.isFinite(tid) && tid > 0) return "趋势回调"; const mt = String(o.monitor_type || "").trim(); if (mt === "趋势回调") return "趋势回调"; const kst = String(o.key_signal_type || "").trim(); if (kst === "趋势回调" || kst === "趋势回调计划") return "趋势回调"; return mt || "下单监控"; } function monitorOrderSourceHtml(mo) { const src = monitorOrderSourceLabel(mo); const kst = String((mo && mo.key_signal_type) || "").trim(); let text = src; if (kst && kst !== src && !text.includes(kst)) { text += " · " + kst; } return `来源: ${esc(text)}`; } function renderDirectionHtml(side) { const cls = sideDirCls(side); const label = sideDirLabel(side); if (!cls) return esc(String(label)); return `${esc(label)}`; } function keyHasPendingOrder(keyRow, keyPrice) { const kp = keyPrice || {}; const oid = keyRow.fib_limit_order_id; if (oid != null && String(oid).trim() !== "") return true; const gm = String(kp.gate_metrics || ""); if (gm.includes("限价单") || gm.includes("挂单")) return true; const gs = String(kp.gate_summary || ""); if (/挂|限价|等待成交/.test(gs)) return true; return false; } function fmtKeyOrderAmount(keyRow) { const raw = keyRow.fib_order_amount; if (raw == null || raw === "") return ""; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) return ""; return `${fmt(n, 4)} 张`; } /** 全屏持仓区:按仓位数量附加布局 class(1~6 固定列数,7+ 自动填充) */ function hubPosListCountClass(n) { const c = Math.max(0, parseInt(n, 10) || 0); if (c <= 0) return "count-0"; if (c <= 6) return `count-${c}`; return "count-many"; } function currentPage() { const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; if (p.includes("settings")) return "settings"; if (p.includes("market")) return "market"; return "monitor"; } function setActiveNav() { const page = currentPage(); document.querySelectorAll(".top-nav a").forEach((a) => { a.classList.toggle("active", a.getAttribute("href").includes(page)); }); document.querySelectorAll(".page").forEach((el) => { el.classList.toggle("hidden", !el.id.includes(page)); }); if (page === "monitor") startMonitorPoll(); else stopMonitorPoll(); if (page === "settings") loadSettingsUI(); if (page === "market" && window.hubMarketChart) { window.hubMarketChart.init(); } else if (window.hubMarketChart) { if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh(); if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer(); } } function stopMonitorPoll() { closeMonitorBoardStream(); if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; } } function closeMonitorBoardStream() { if (boardEventSource) { boardEventSource.close(); boardEventSource = null; } } function connectMonitorBoardStream() { closeMonitorBoardStream(); if (!document.getElementById("auto-monitor")?.checked) return; if (currentPage() !== "monitor") return; boardEventSource = new EventSource("/api/monitor/board/stream"); boardEventSource.addEventListener("board", (ev) => { try { const st = JSON.parse(ev.data || "{}"); const ver = Number(st.board_version) || 0; if (ver !== localBoardVersion) { void fetchMonitorBoardSnapshot({ background: true }); } else if (st.aggregating && lastMonitorRows.length) { applyMonitorBoardUi(lastMonitorRows, st.updated_at || lastMonitorBoardUpdatedAt, { stale: true, }); } } catch (_) {} }); boardEventSource.onerror = () => { closeMonitorBoardStream(); if (sseReconnectTimer) clearTimeout(sseReconnectTimer); sseReconnectTimer = setTimeout(() => { if (currentPage() === "monitor" && document.getElementById("auto-monitor")?.checked) { connectMonitorBoardStream(); void fetchMonitorBoardSnapshot({ background: true }); } }, 8000); }; } async function requestMonitorBoardRefresh() { await apiFetch("/api/monitor/board/refresh", { method: "POST" }); } function clearMonitorBoardSlowHint() { if (monitorBoardSlowHintTimer) { clearTimeout(monitorBoardSlowHintTimer); monitorBoardSlowHintTimer = null; } } function scheduleMonitorBoardSlowHint(box) { clearMonitorBoardSlowHint(); if (!box) return; monitorBoardSlowHintTimer = setTimeout(() => { if (lastMonitorRows.length) return; const el = box.querySelector(".board-loading"); if (!el) return; const sub = el.querySelector(".board-loading-sub"); if (sub) { sub.textContent = "后台首次聚合较慢(四所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速。"; } }, 12000); } function saveMonitorBoardCache(rows, updatedAt, boardVersion) { try { sessionStorage.setItem( HUB_MONITOR_BOARD_CACHE_KEY, JSON.stringify({ version: 1, board_version: boardVersion != null ? boardVersion : localBoardVersion, updated_at: updatedAt || "", rows: rows || [], saved_at: Date.now(), }) ); } catch (_) {} } function loadMonitorBoardFromCache() { try { const raw = sessionStorage.getItem(HUB_MONITOR_BOARD_CACHE_KEY); if (!raw) return null; const data = JSON.parse(raw); if (!data || !Array.isArray(data.rows) || !data.rows.length) return null; const age = Date.now() - Number(data.saved_at || 0); if (!Number.isFinite(age) || age > HUB_MONITOR_CACHE_MAX_AGE_MS) { sessionStorage.removeItem(HUB_MONITOR_BOARD_CACHE_KEY); return null; } return data; } catch (_) { return null; } } function restoreMonitorBoardFromCache() { const cached = loadMonitorBoardFromCache(); if (!cached) return false; lastMonitorRows = cached.rows; lastMonitorBoardUpdatedAt = cached.updated_at || ""; localBoardVersion = 0; applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true }); return true; } function applyMonitorBoardUi(rows, updatedAt, opts) { const options = opts || {}; const tsRaw = updatedAt || lastMonitorBoardUpdatedAt || ""; if (updatedAt) lastMonitorBoardUpdatedAt = updatedAt; const online = (rows || []).filter((x) => x.http_ok && (x.agent || {}).ok !== false).length; const pill = document.getElementById("sys-status"); if (pill) { pill.textContent = rows.length ? `LINK ${online}/${rows.length}` : "NO DATA"; pill.classList.toggle("warn", rows.length && online < rows.length); if (options.stale) pill.classList.add("syncing"); else pill.classList.remove("syncing"); } const upd = document.getElementById("monitor-updated"); if (upd) { const ts = tsRaw.replace("T", " "); upd.textContent = options.stale ? ts ? `缓存 ${ts} · 后台聚合中…` : "后台聚合中…" : ts ? `UPD ${ts}` : ""; } updateMonitorAlertSummary(rows || []); renderMonitorGrid(rows || []); } function startMonitorPoll() { const hadCache = restoreMonitorBoardFromCache(); void fetchMonitorBoardSnapshot({ showLoading: !hadCache }); connectMonitorBoardStream(); } async function loadSettings() { const r = await apiFetch("/api/settings"); settingsCache = await r.json(); return settingsCache; } function enabledAccounts() { return (settingsCache?.exchanges || []).filter((x) => x.enabled); } function isMobileLayout() { return window.matchMedia("(max-width: 720px)").matches; } 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 = "repeat(2, minmax(0, 1fr))"; return; } let cols = 3; if (count <= 1) cols = 1; else if (count === 2) cols = 2; else if (count === 3) cols = 3; else if (count === 4) cols = 2; else cols = 3; gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; } 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); }); } function normSym(s) { return String(s || "") .toUpperCase() .replace(/:USDT$/i, "") .replace(/\/USDT:USDT$/i, "") .replace(/\/USDT$/i, ""); } function symbolsMatchHub(a, b) { const x = normSym(a); const y = normSym(b); if (!x || !y) return false; return x === y; } function ordersCollapseKey(exchangeId, symbol) { const sym = normSym(symbol) || "unknown"; return `hub_orders_${exchangeId}_${sym}`; } function isOrdersCollapseOpen(exchangeId, symbol) { return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1"; } function condOrdersFromPosition(pos) { const cond = Array.isArray(pos.conditional_orders) ? pos.conditional_orders : []; if (cond.length) return cond; const et = pos.exchange_tpsl; if (!et) return []; const out = []; if (et.sl && et.sl.trigger_price != null) { out.push({ label: "止损", trigger_price: Number(et.sl.trigger_price), amount: null, id: et.sl.order_id, channel: "algo", }); } if (et.tp && et.tp.trigger_price != null) { out.push({ label: "止盈", trigger_price: Number(et.tp.trigger_price), amount: null, id: et.tp.order_id, channel: "algo", }); } return out; } function findMonitorOrder(orders, symbol, side) { const want = (side || "").toLowerCase(); for (const o of orders || []) { const sym = o.exchange_symbol || o.symbol || ""; if (!symbolsMatchHub(sym, symbol)) continue; const d = (o.direction || "").toLowerCase(); if (!d || d === want) return o; } return null; } function calcRrRatio(side, entry, sl, tp) { const e = Number(entry); const s = Number(sl); const t = Number(tp); if (![e, s, t].every((n) => Number.isFinite(n) && n > 0)) return null; if ((side || "long").toLowerCase() === "short") { const risk = s - e; const reward = e - t; if (risk <= 0 || reward <= 0) return null; return reward / risk; } const risk = e - s; const reward = t - e; if (risk <= 0 || reward <= 0) return null; return reward / risk; } function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored) { if (tpMonitored) return null; const snap = mo && mo.rr_ratio; if (snap != null && snap !== "") { const n = Number(snap); if (Number.isFinite(n)) return n; } const initSl = mo && (mo.initial_stop_loss != null ? mo.initial_stop_loss : mo.stop_loss); return calcRrRatio(side, entry, initSl || sl, tp); } function isBreakevenSecured(side, entry, monitorOrder, cond, pos) { const mo = monitorOrder || {}; const p = pos || {}; if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true; if (p.sl_breakeven_secured === true || p.sl_breakeven_secured === 1) return true; const { sl } = pickExTpslOrders(cond); const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN; const e = Number(entry); if (!Number.isFinite(trig) || !Number.isFinite(e)) return false; if ((side || "long").toLowerCase() === "short") return trig <= e; return trig >= e; } function breakevenBadgeHtml() { return `已保本`; } async function fetchMonitorBoardSnapshot(opts) { const options = opts || {}; const background = !!options.background; const showLoading = !!options.showLoading && !lastMonitorRows.length; const box = document.getElementById("monitor-grid"); if (monitorBoardInFlight) { if (background) monitorBoardFetchPending = true; else return; } if (showLoading && box) { box.innerHTML = '
正在加载监控快照…

'; scheduleMonitorBoardSlowHint(box); } else if (background && lastMonitorRows.length) { applyMonitorBoardUi(lastMonitorRows, null, { stale: true }); } monitorBoardInFlight = true; const ctrl = new AbortController(); const fetchTimer = setTimeout(() => ctrl.abort(), HUB_MONITOR_SNAPSHOT_TIMEOUT_MS); try { const r = await apiFetch(MONITOR_BOARD_SNAPSHOT_URL, { signal: ctrl.signal }); const data = await r.json(); if (!r.ok) { throw new Error(data.msg || data.detail || `HTTP ${r.status}`); } const ver = Number(data.board_version) || 0; const rows = data.rows || []; const waitingFirst = data.aggregating && !rows.length && ver <= localBoardVersion; if (waitingFirst && showLoading) { if (box) { const sub = box.querySelector(".board-loading-sub"); if (sub) sub.textContent = "后台正在首次聚合四所数据(约 5~15 秒)…"; } return; } const ts = data.updated_at || ""; const versionChanged = ver !== localBoardVersion; const timeChanged = ts && ts !== lastMonitorBoardUpdatedAt; if (versionChanged || timeChanged || !lastMonitorRows.length) { localBoardVersion = ver; lastMonitorRows = rows; saveMonitorBoardCache(lastMonitorRows, ts, ver); applyMonitorBoardUi(lastMonitorRows, ts, { stale: !!data.aggregating, }); } else if (data.aggregating && lastMonitorRows.length) { applyMonitorBoardUi(lastMonitorRows, data.updated_at || lastMonitorBoardUpdatedAt, { stale: true, }); } if (data.ok === false && data.msg && !background) { showToast(String(data.msg), true); } } catch (e) { const msg = e && e.name === "AbortError" ? "读取监控快照超时,请检查中控是否运行" : String(e); if (background && lastMonitorRows.length) { showToast("快照读取失败,仍显示上次数据", true); applyMonitorBoardUi(lastMonitorRows, null, { stale: false }); return; } if (box) box.innerHTML = `
${esc(msg)}
`; } finally { clearTimeout(fetchTimer); clearMonitorBoardSlowHint(); monitorBoardInFlight = false; if (monitorBoardFetchPending) { monitorBoardFetchPending = false; void fetchMonitorBoardSnapshot({ background: true }); } } } async function refreshMonitorBoardNow() { if (lastMonitorRows.length) { applyMonitorBoardUi(lastMonitorRows, lastMonitorBoardUpdatedAt, { stale: true }); } try { await requestMonitorBoardRefresh(); await fetchMonitorBoardSnapshot({ background: false }); } catch (e) { showToast(String(e), true); } } function closeExchangeFullscreen() { expandedExchangeId = ""; sessionStorage.removeItem("hub_expanded_ex"); const fs = document.getElementById("exchange-fullscreen"); if (fs) { fs.classList.add("hidden"); fs.setAttribute("aria-hidden", "true"); } document.body.classList.remove("hub-fullscreen-open"); } function openExchangeFullscreen(exId) { expandedExchangeId = String(exId); sessionStorage.setItem("hub_expanded_ex", expandedExchangeId); renderMonitorGrid(lastMonitorRows); } function renderMonitorGrid(rows) { const box = document.getElementById("monitor-grid"); const fs = document.getElementById("exchange-fullscreen"); const fsInner = document.getElementById("exchange-fullscreen-inner"); if (!box) return; 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 = displayRows .map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r))) .join("") || '
无已启用账户
'; syncMonitorGridColumns(box, displayRows.length); bindMonitorInteractions(box); if (expandedExchangeId && fs && fsInner) { const row = rows.find((r) => String(r.id) === String(expandedExchangeId)); if (row) { try { fsInner.innerHTML = renderFullscreenExchange(row); fs.classList.remove("hidden"); fs.setAttribute("aria-hidden", "false"); document.body.classList.add("hub-fullscreen-open"); bindMonitorInteractions(fsInner); fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => { btn.onclick = (ev) => { ev.stopPropagation(); closeExchangeFullscreen(); renderMonitorGrid(lastMonitorRows); }; }); } catch (err) { console.error("renderFullscreenExchange", err); closeExchangeFullscreen(); showToast("全屏渲染失败: " + err, true); } } else { closeExchangeFullscreen(); } } else { closeExchangeFullscreen(); } } function normalizeMarketSymbol(raw) { let s = (raw || "").trim().toUpperCase(); if (!s) return ""; if (s.includes(":")) { const base = s.split(":")[0]; if (base.includes("/")) return base; } return s; } function resolveExchangeKey(exchangeId) { const row = (lastMonitorRows || []).find((r) => String(r.id) === String(exchangeId)); return (row && (row.key || row.id)) || exchangeId; } function findTrendPlan(trends, symbol, side) { const want = (side || "").toLowerCase(); for (const t of trends || []) { const sym = t.symbol || t.exchange_symbol || ""; if (!symbolsMatchHub(sym, symbol)) continue; const d = (t.direction || "").toLowerCase(); if (!d || d === want) return t; } return null; } function inferTpslFromCondOrders(side, cond, entry) { const picked = pickExTpslOrders(cond); let sl = picked.sl && picked.sl.trigger_price != null ? picked.sl.trigger_price : ""; let tp = picked.tp && picked.tp.trigger_price != null ? picked.tp.trigger_price : ""; if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) { return { sl, tp }; } const triggers = (cond || []) .map(function (o) { return { price: Number(o.trigger_price), label: o.label || "" }; }) .filter(function (o) { return o.price != null && !Number.isNaN(o.price) && o.price > 0; }); if (!triggers.length) return { sl: sl || "", tp: tp || "" }; const s = (side || "long").toLowerCase(); const e = entry != null && Number.isFinite(Number(entry)) ? Number(entry) : null; if (e != null) { const below = triggers.filter(function (t) { return t.price < e; }); const above = triggers.filter(function (t) { return t.price > e; }); if (s === "long") { if (sl === "" && below.length) { sl = Math.max.apply( null, below.map(function (t) { return t.price; }) ); } if (tp === "" && above.length) { tp = Math.min.apply( null, above.map(function (t) { return t.price; }) ); } } else { if (sl === "" && above.length) { sl = Math.min.apply( null, above.map(function (t) { return t.price; }) ); } if (tp === "" && below.length) { tp = Math.max.apply( null, below.map(function (t) { return t.price; }) ); } } } if (triggers.length === 1 && sl === "" && tp === "") { const one = triggers[0]; const p = one.price; const lbl = one.label; if (e != null) { if (s === "long") { if (p < e) sl = p; else if (p > e) tp = p; } else if (p > e) sl = p; else if (p < e) tp = p; } else if (/止损/.test(lbl)) sl = p; else if (/止盈/.test(lbl) && !/止盈止损/.test(lbl)) tp = p; } if (sl !== "" && tp !== "" && Number(sl) === Number(tp)) tp = ""; return { sl: sl || "", tp: tp || "" }; } function resolvePositionTpsl(pos, monitorOrder, trendPlan) { const mo = monitorOrder || {}; const tp = trendPlan || {}; const cond = condOrdersFromPosition(pos); const entryRaw = pos.entry_price != null ? pos.entry_price : mo.trigger_price != null ? mo.trigger_price : tp.avg_entry_price; const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null; const isTrend = !!(trendPlan && trendPlan.id) || String(mo.monitor_type || "").trim() === "趋势回调" || (mo.trend_plan_id != null && Number(mo.trend_plan_id) > 0); let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : ""; let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : ""; let tpMonitored = false; if (isTrend) { tpMonitored = true; takeProfit = ""; if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") { sl = trendPlan.stop_loss; } } const inferred = inferTpslFromCondOrders(pos.side, cond, entryN); if (sl === "" || sl == null) sl = inferred.sl; if (!tpMonitored && (takeProfit === "" || takeProfit == null)) takeProfit = inferred.tp; if (sl !== "" && takeProfit !== "" && Number(sl) === Number(takeProfit)) { takeProfit = ""; } return { entry: entryRaw, sl, tp: takeProfit, tp_monitored: tpMonitored, is_trend: isTrend, }; } function buildPositionMarketContext(pos, monitorOrder, trendPlan) { const tpsl = resolvePositionTpsl(pos, monitorOrder, trendPlan); const cond = condOrdersFromPosition(pos); const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : []; const num = function (v) { if (v == null || v === "") return null; const n = Number(v); return Number.isFinite(n) ? n : null; }; const orders = []; cond.forEach(function (o) { orders.push({ kind: "条件", label: o.label || "条件单", price: num(o.trigger_price), amount: num(o.amount), }); }); reg.forEach(function (o) { orders.push({ kind: "普通", label: o.label || o.type || "委托", price: num(o.price != null ? o.price : o.trigger_price), amount: num(o.amount), }); }); return { side: (pos.side || "long").toLowerCase(), entry: num(tpsl.entry), stop_loss: num(tpsl.sl), take_profit: num(tpsl.tp), tp_monitored: !!tpsl.tp_monitored, is_trend: !!tpsl.is_trend, contracts: num(pos.contracts), orders: orders, }; } const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext"; function encodePosCtx(ctx) { try { return btoa(unescape(encodeURIComponent(JSON.stringify(ctx)))); } catch (e) { return ""; } } function decodePosCtx(raw) { if (!raw) return null; try { return JSON.parse(decodeURIComponent(escape(atob(raw)))); } catch (e) { return null; } } function marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan) { const symAttr = esc(symbol || "").replace(/"/g, """); const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """); const ctxEnc = esc(encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan))).replace( /"/g, """ ); return ( 'data-ex-id="' + esc(exchangeId) + '" data-ex-key="' + exKeyAttr + '" data-symbol="' + symAttr + '" data-pos-ctx="' + ctxEnc + '"' ); } function openMarketForPosition(exchangeId, symbol, exchangeKey, posCtxRaw) { const exKey = exchangeKey || resolveExchangeKey(exchangeId); const sym = normalizeMarketSymbol(symbol); if (!exKey || !sym) { showToast("无法打开行情:缺少交易所或合约", true); return; } const ctx = decodePosCtx(posCtxRaw); if (ctx) { ctx.symbol = sym; ctx.exchange_key = exKey; sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(ctx)); } else { sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY); } if (expandedExchangeId) { closeExchangeFullscreen(); } const qs = new URLSearchParams({ exchange_key: exKey, symbol: sym }); history.pushState({}, "", "/market?" + qs.toString()); setActiveNav(); if (window.hubMarketChart && window.hubMarketChart.openWith) { window.hubMarketChart.openWith(exKey, sym); } } function bindMonitorInteractions(box) { box.querySelectorAll(".btn-open-market").forEach((btn) => { btn.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); openMarketForPosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.exKey, btn.dataset.posCtx); }; }); box.querySelectorAll(".btn-open-instance").forEach((btn) => { btn.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); openInstance(btn.dataset.exId, btn.dataset.next || "/", { newTab: ev.ctrlKey || ev.metaKey, }); }; }); box.querySelectorAll(".btn-close-ex").forEach((btn) => { btn.onclick = () => closeOne(btn.dataset.id); }); box.querySelectorAll(".btn-close-pos").forEach((btn) => { btn.onclick = (ev) => { ev.stopPropagation(); closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side); }; }); box.querySelectorAll(".btn-cancel-order").forEach((btn) => { btn.onclick = (ev) => { ev.stopPropagation(); cancelOneOrder( btn.dataset.exId, btn.dataset.symbol, btn.dataset.orderId, btn.dataset.channel ); }; }); box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => { btn.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional"); }; }); box.querySelectorAll(".btn-place-tpsl").forEach((btn) => { btn.onclick = (ev) => { ev.stopPropagation(); openTpslModal( btn.dataset.exId, btn.dataset.symbol, btn.dataset.side, btn.dataset.contracts, btn.dataset.sl || "", btn.dataset.tp || "" ); }; }); box.querySelectorAll(".card-expand-zone").forEach((zone) => { zone.onclick = (ev) => { if (ev.target.closest("a, button, input, summary, details, .card-actions")) return; const id = zone.closest(".card")?.dataset.exId; if (id) openExchangeFullscreen(id); }; }); box.querySelectorAll("details.pos-orders-collapse[data-collapse-key]").forEach((el) => { el.addEventListener("toggle", () => { const k = el.dataset.collapseKey; if (k) localStorage.setItem(k, el.open ? "1" : "0"); }); }); } function renderOrderRows(exchangeId, symbol, orders, kind, tickMap) { if (!orders || !orders.length) { const hint = kind === "conditional" ? "暂无条件单(止盈/止损等)" : "暂无普通委托"; return `
${hint}
`; } const symAttr = esc(symbol || "").replace(/"/g, """); const rows = orders .map((o) => { const oidAttr = esc(o.id || "").replace(/"/g, """); const chAttr = esc(o.channel || "regular").replace(/"/g, """); const trig = o.trigger_price != null ? fmtSymbolPrice(o.trigger_price, symbol, tickMap) : o.price != null ? fmtSymbolPrice(o.price, symbol, tickMap) : "—"; return ` ${esc(o.label || o.type || "委托")} ${fmt(o.amount, 4)} ${trig} `; }) .join(""); return `${rows}
类型数量触发/价格操作
`; } function guessTpslFromCondOrders(side, cond, entry) { return inferTpslFromCondOrders(side, cond, entry); } function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) { const symAttr = esc(symbol || "").replace(/"/g, """); const orderTotal = cond.length + reg.length; const collapseKey = ordersCollapseKey(exchangeId, symbol); const openAttr = isOrdersCollapseOpen(exchangeId, symbol) ? " open" : ""; const condAllBtn = cond.length > 0 ? `` : ""; const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional", tickMap); const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap); return `
委托单 ${orderTotal} 条件 ${cond.length} · 普通 ${reg.length} ${condAllBtn}
条件单
${condBody}
普通委托
${regBody}
`; } function pickExTpslOrders(cond) { let sl = cond.find((o) => /^止损\b/.test(o.label || "")); let tp = cond.find((o) => /^止盈\b/.test(o.label || "") && !(o.label || "").includes("止盈止损")); if (!sl || !tp) { const combo = cond.find((o) => (o.label || "").includes("止盈止损")); if (combo) { const m = (combo.label || "").match(/SL=([\d.eE+-]+).*TP=([\d.eE+-]+)/i); if (m) { if (!sl) sl = { ...combo, label: "止损", trigger_price: Number(m[1]) }; if (!tp) tp = { ...combo, label: "止盈", trigger_price: Number(m[2]) }; } } } if (!sl) sl = cond.find((o) => (o.label || "").includes("止损")); if (!tp) tp = cond.find((o) => (o.label || "").includes("止盈") && o !== sl); return { sl, tp }; } function renderExTpslRows(exchangeId, symbol, cond, tickMap) { const symAttr = esc(symbol || "").replace(/"/g, """); const { sl, tp } = pickExTpslOrders(cond); function row(label, o) { if (!o) { return `
${label}:—
`; } const oid = esc(o.id || "").replace(/"/g, """); const ch = esc(o.channel || "regular").replace(/"/g, """); const trig = o.trigger_price != null ? fmtSymbolPrice(o.trigger_price, symbol, tickMap) : "—"; return `
${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}
`; } return row("止损", sl) + row("止盈", tp); } function trendAddSummaryHtml(t, tickMap) { const done = t.add_count != null ? t.add_count : t.legs_done; const total = t.add_count_total != null ? t.add_count_total : t.dca_legs; const sym = t.exchange_symbol || t.symbol || ""; let html = ""; if (done != null && Number(done) >= 0) { html += total != null ? ` · 补仓 ${esc(done)}/${esc(total)}` : ` · 补仓 ${esc(done)} 次`; const pxs = t.add_prices_display; if (Array.isArray(pxs) && pxs.length) { html += ` · 加仓价 ${pxs.map((p) => esc(p)).join(" / ")}`; } else if (Array.isArray(t.add_prices) && t.add_prices.length) { html += ` · 加仓价 ${t.add_prices.map((p) => esc(fmtSymbolPrice(p, sym, tickMap))).join(" / ")}`; } else if (Number(done) === 0) { html += " · 加仓价 —"; } } return html; } function renderTrendSection(trends, tickMap) { if (!trends || !trends.length) return ""; return trends .map((t) => { const sym = t.exchange_symbol || t.symbol || ""; const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap); const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap); const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap); return `
#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}
均价 ${esc(avg)} · SL ${esc(sl)} · TP ${esc(tp)}${trendAddSummaryHtml(t, tickMap)} · 状态 ${esc(t.status || "active")}
`; }) .join(""); } function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) { const symbol = pos.symbol || ""; const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """); const side = (pos.side || "long").toLowerCase(); const sideCn = sideDirLabel(side); const sideCls = sideDirCls(side) || "side-long"; const mo = monitorOrder || {}; const cond = condOrdersFromPosition(pos); const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : []; const tpsl = resolvePositionTpsl(pos, mo, trendPlan); const symAttr = esc(symbol).replace(/"/g, """); const sideAttr = esc(side).replace(/"/g, """); const contractsAttr = esc(String(pos.contracts != null ? pos.contracts : "")).replace(/"/g, """); const slAttr = esc(String(tpsl.sl)).replace(/"/g, """); const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """); const entry = tpsl.entry; const sl = tpsl.sl; const tp = tpsl.tp; const tpMonitored = tpsl.tp_monitored; const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored); const beSecured = isBreakevenSecured(side, entry, mo, cond, pos); const upnl = pos.unrealized_pnl; let pnlText = fmt(upnl, 2) + "U"; if (pos.notional_usdt && upnl != null && Math.abs(Number(pos.notional_usdt)) > 1e-8) { const pct = (Number(upnl) / Math.abs(Number(pos.notional_usdt))) * 100; pnlText += ` (${pct >= 0 ? "" : ""}${pct.toFixed(2)}%)`; } const meta = []; if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) { meta.push(monitorOrderSourceHtml(mo)); } else { meta.push("来源: 交易所持仓"); } if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`); else meta.push("风格: —"); if (mo.risk_percent != null) { meta.push(`风险: ${esc(mo.risk_percent)}%`); } const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true; meta.push( `移动保本:${beOn ? "开" : "关"}` ); if (trendPlan && trendPlan.id) { meta.push(`趋势回调${trendAddSummaryHtml(trendPlan, tickMap)}`); } const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); return `
${symBeBadge} ${sideCn}
${meta.map((m) => `${m}`).join("")}
开仓价${fmtEntryPrice(pos, tickMap)}
标记价${fmtMarkPrice(pos, tickMap)}
止损${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}
止盈${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmtSymbolPrice(tp, symbol, tickMap) : "—"}
盈亏比${tpMonitored ? "—" : rr != null ? fmt(rr, 2) + ":1" : "-:1"}
张数${fmt(pos.contracts, 4)}
浮盈亏${pnlText}
交易所止盈止损
${renderExTpslRows(exchangeId, symbol, cond, tickMap)}
${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
`; } function renderHubSectionCard(title, bodyHtml, emptyHint) { const inner = bodyHtml || `
${esc(emptyHint || "暂无")}
`; return `
${esc(title)}
${inner}
`; } function renderKeySection(keys, kmap) { if (!keys.length) return ""; const cards = keys .map((k) => { const kp = kmap[k.id] || kmap[String(k.id)] || {}; const mt = k.monitor_type || k.type || ""; const pending = keyHasPendingOrder(k, kp); const cardCls = pending ? "hub-mini-card hub-key-pending" : "hub-mini-card"; const dir = k.direction ? ` · ${renderDirectionHtml(k.direction)}` : ""; const pendingTag = pending ? `挂单中` : ""; const amtTxt = fmtKeyOrderAmount(k); const amtLine = amtTxt ? `
挂单数量 ${esc(amtTxt)}
` : ""; return `
${esc(k.symbol)} · ${esc(mt)}${dir} ${pendingTag}
上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}
${amtLine}
${esc(kp.gate_summary || kp.price_display || kp.price || "—")}${kp.gate_metrics ? ` · ${esc(kp.gate_metrics)}` : ""}
`; }) .join(""); return `
${cards}
`; } function renderOrderMonitorSection(orders, tickMap) { if (!orders || !orders.length) return ""; return orders .map((o) => { const sym = o.exchange_symbol || o.symbol || ""; return `
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)}
触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}
`; }) .join(""); } function renderRollSection(rolls, tickMap) { if (!rolls || !rolls.length) return ""; return rolls .map( (g) => `
组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmtSymbolPrice(g.current_stop_loss, g.symbol, tickMap)} · ${esc(g.status || "active")}
` ) .join(""); } function renderPositionTableRow( exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts ) { const options = opts || {}; const compact = !!options.compact; const symAttr = esc(x.symbol || "").replace(/"/g, """); const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """); const side = sideAttr || "long"; const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace( /"/g, """ ); const cond = condOrdersFromPosition(x); const tpsl = resolvePositionTpsl(x, monitorOrder, trendPlan); const beSecured = isBreakevenSecured(side, tpsl.entry, monitorOrder, cond, x); const slAttr = esc(String(tpsl.sl)).replace(/"/g, """); const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """); const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, x.symbol, x, monitorOrder, trendPlan); const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; const actionCell = compact ? `` : `
`; return ` ${symBeBadge} ${renderDirectionHtml(x.side)} ${fmtEntryPrice(x, tickMap)} ${fmtMarkPrice(x, tickMap)} ${fmt(x.contracts, 4)} ${fmt(x.unrealized_pnl, 2)} ${actionCell} `; } function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts) { const options = opts || {}; const compact = !!options.compact; const reg = Array.isArray(x.regular_orders) ? x.regular_orders : []; const cond = condOrdersFromPosition(x); const ordersBlock = compact ? "" : renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap); const rowHtml = renderPositionTableRow( exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts ); return `
${rowHtml}
合约方向开仓价标记价张数浮盈操作
${ordersBlock}
`; } const KEY_BUCKET_FIB_TYPES = new Set([ "斐波回调0.618", "斐波回调0.786", "关键位斐波0.618", "关键位斐波0.786", ]); const KEY_BUCKET_BREAKOUT_TYPES = new Set([ "箱体突破", "收敛突破", "关键位箱体突破", "关键位收敛突破", "关键位收敛结构", ]); const KEY_BUCKET_WATCH_TYPES = new Set([ "关键阻力位", "关键支撑位", "关键位监控", ]); function classifyKeyMonitorBucket(monitorType) { const t = String(monitorType || "").trim(); if (!t) return "watch"; if (KEY_BUCKET_FIB_TYPES.has(t) || /斐波/.test(t)) return "fib"; if (KEY_BUCKET_BREAKOUT_TYPES.has(t) || /突破/.test(t)) return "breakout"; if (KEY_BUCKET_WATCH_TYPES.has(t) || /阻力|支撑/.test(t)) return "watch"; return "watch"; } function countKeyMonitorsByBucket(keys) { const counts = { breakout: 0, fib: 0, watch: 0 }; (keys || []).forEach((k) => { if (!k || typeof k !== "object") return; const bucket = classifyKeyMonitorBucket(k.monitor_type || k.type); if (bucket === "breakout") counts.breakout += 1; else if (bucket === "fib") counts.fib += 1; else counts.watch += 1; }); return counts; } function renderCardStrategyStats(row, hm, flaskOk) { if (!flaskOk || !hm || typeof hm !== "object") return ""; const caps = row.capabilities || []; const chips = []; if (caps.includes("key")) { const kc = countKeyMonitorsByBucket(hm.keys || []); if (kc.breakout > 0) chips.push({ kind: "key-breakout", label: `突破 ${kc.breakout}` }); if (kc.fib > 0) chips.push({ kind: "key-breakout", label: `斐波 ${kc.fib}` }); if (kc.watch > 0) chips.push({ kind: "key-watch", label: `监控 ${kc.watch}` }); } if (caps.includes("trend")) { const trendN = Array.isArray(hm.trends) ? hm.trends.length : 0; if (trendN > 0) chips.push({ kind: "trend", label: `趋势回调 ${trendN}` }); } const rollN = Array.isArray(hm.rolls) ? hm.rolls.length : 0; if (rollN > 0) chips.push({ kind: "roll", label: `顺势加仓 ${rollN}` }); if (!chips.length) return ""; return `
${chips .map( (c) => `${esc(c.label)}` ) .join("")}
`; } function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap) { const rows = positions .map((p) => renderPositionTableRow( exchangeId, exchangeKey, p, findMonitorOrder(orders, p.symbol, p.side), findTrendPlan(trends, p.symbol, p.side), tickMap, { compact: true } ) ) .join(""); return `
${rows}
合约方向开仓价标记价张数浮盈操作
`; } function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) { const tickMap = buildPriceTickMap(row); let inner = `
余额
${fmt(ag.balance_usdt, 2)} U
浮盈合计
${fmt(ag.total_unrealized_pnl, 2)}
`; inner += `
交易所持仓 · ${pos.length} 仓
`; if (pos.length) { inner += renderGridPositionsTable( row.id, row.key || row.id, pos, orders, trends, tickMap ); } else { inner += '
无持仓
'; } inner += renderCardStrategyStats(row, hm, flaskOk); inner += `
点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓
`; return inner; } function renderFullscreenExchange(row) { const tickMap = buildPriceTickMap(row); const ag = row.agent || {}; const pos = Array.isArray(ag.positions) ? ag.positions : []; const hm = row.hub_monitor || {}; const flaskOk = row.flask_ok !== false && hm.ok !== false; const keys = flaskOk ? hm.keys || [] : []; const orders = flaskOk ? hm.orders || [] : []; const trends = flaskOk ? hm.trends || [] : []; const rolls = flaskOk ? hm.rolls || [] : []; const kmap = {}; (row.key_prices || []).forEach((k) => { kmap[k.id] = k; }); const flaskOpen = row.flask_url_browser || row.flask_url; let html = `

${esc(row.name)}

${esc(flaskOpen || "")}
${flaskOpen ? `打开实例` : ""} ${flaskOpen ? `策略交易` : ""}
`; if (!row.http_ok || ag.ok === false) { html += `
${esc(row.error || ag.error || "子代理不可用")}
`; return html; } html += `
余额
${fmt(ag.balance_usdt, 2)} U
浮盈合计
${fmt(ag.total_unrealized_pnl, 2)}
`; const posCount = pos.length; const posListCls = hubPosListCountClass(posCount); html += `
持仓(${posCount} 仓 · 每币种一卡)
`; html += `
`; if (posCount) { pos.forEach((p) => { html += renderLivePositionCard( row.id, row.key || row.id, p, findMonitorOrder(orders, p.symbol, p.side), findTrendPlan(trends, p.symbol, p.side), tickMap ); }); } else { html += '
暂无持仓
'; } html += "
"; if ((row.capabilities || []).includes("key")) { if (!flaskOk) { html += renderHubSectionCard("关键位", `
${esc(row.flask_error || hm.error || "Flask 未连通")}
`, ""); } else { html += renderHubSectionCard( `关键位 · ${keys.length}`, renderKeySection(keys, kmap), "当前无关键位记录" ); } } html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders, tickMap), "暂无运行中的下单监控"); if ((row.capabilities || []).includes("trend")) { html += renderHubSectionCard("趋势回调", renderTrendSection(trends, tickMap), "暂无运行中的趋势回调计划"); } html += renderHubSectionCard("顺势加仓", renderRollSection(rolls, tickMap), "暂无运行中的顺势加仓组"); return html; } function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) { tpslPending = { exchangeId, symbol, side: (side || "long").toLowerCase(), contracts: parseFloat(contracts), }; const modal = document.getElementById("tpsl-modal"); const meta = document.getElementById("tpsl-modal-meta"); const slIn = document.getElementById("tpsl-sl"); const tpIn = document.getElementById("tpsl-tp"); if (!modal || !meta || !slIn || !tpIn) return; meta.textContent = `${symbol} · ${side} · ${contracts} 张`; slIn.value = slHint !== "" && slHint != null ? String(slHint) : ""; tpIn.value = tpHint !== "" && tpHint != null ? String(tpHint) : ""; modal.classList.remove("hidden"); modal.setAttribute("aria-hidden", "false"); slIn.focus(); } function closeTpslModal() { tpslPending = null; const modal = document.getElementById("tpsl-modal"); if (modal) { modal.classList.add("hidden"); modal.setAttribute("aria-hidden", "true"); } } async function submitTpslModal() { if (!tpslPending) return; const slIn = document.getElementById("tpsl-sl"); const tpIn = document.getElementById("tpsl-tp"); const sl = parseFloat(slIn && slIn.value); const tp = parseFloat(tpIn && tpIn.value); if (!sl || sl <= 0 || !tp || tp <= 0) { showToast("请填写有效的止损价与止盈价", true); return; } const { exchangeId, symbol, side, contracts } = tpslPending; if ( !confirm( `确认 ${symbol} ${side}\n先撤销全部条件单,再挂止损 ${sl}、止盈 ${tp}?` ) ) { return; } const btn = document.getElementById("tpsl-submit"); if (btn) btn.disabled = true; try { const r = await apiFetch( "/api/orders/" + encodeURIComponent(exchangeId) + "/place-tpsl", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ symbol, side, stop_loss: sl, take_profit: tp, contracts: contracts > 0 ? contracts : null, }), } ); const j = await r.json(); const pl = j.payload || {}; const ok = j.ok && pl.ok !== false; const n = pl.placed && pl.placed.cancelled_conditional; showToast( ok ? `已挂单(已撤 ${n != null ? n : "?"} 笔旧条件单)` : pl.error || JSON.stringify(j), !ok ); if (ok) { closeTpslModal(); refreshMonitorBoardNow(); } } catch (e) { showToast(String(e), true); } finally { if (btn) btn.disabled = false; } } function initInstanceFrame() { const back = document.getElementById("instance-frame-back"); const refresh = document.getElementById("instance-frame-refresh"); const newTab = document.getElementById("instance-frame-newtab"); const frame = document.getElementById("instance-frame"); if (back) back.onclick = () => closeInstanceFrame(); if (refresh) refresh.onclick = () => refreshInstanceFrame(); if (newTab) { newTab.onclick = () => { if (instanceFrameCtx) { openInstance(instanceFrameCtx.exchangeId, instanceFrameCtx.nextPath, { newTab: true, }); return; } if (instanceFrameUrl) window.open(instanceFrameUrl, "_blank", "noopener"); }; } } function initFullscreen() { const backdrop = document.getElementById("exchange-fullscreen-backdrop"); if (backdrop) { backdrop.onclick = () => { closeExchangeFullscreen(); renderMonitorGrid(lastMonitorRows); }; } const fs = document.getElementById("exchange-fullscreen"); if (fs && !expandedExchangeId) { fs.classList.add("hidden"); fs.setAttribute("aria-hidden", "true"); } } function initTpslModal() { const backdrop = document.getElementById("tpsl-modal-backdrop"); const cancel = document.getElementById("tpsl-cancel"); const submit = document.getElementById("tpsl-submit"); if (backdrop) backdrop.onclick = closeTpslModal; if (cancel) cancel.onclick = closeTpslModal; if (submit) submit.onclick = () => submitTpslModal(); document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") { closeTpslModal(); const shell = document.getElementById("instance-frame-shell"); if (shell && !shell.classList.contains("hidden")) { closeInstanceFrame(); return; } if (expandedExchangeId) { closeExchangeFullscreen(); renderMonitorGrid(lastMonitorRows); } } }); } async function cancelOneOrder(exchangeId, symbol, orderId, channel) { if (!confirm(`撤销委托 ${symbol} #${orderId}?`)) return; try { const r = await apiFetch("/api/orders/" + encodeURIComponent(exchangeId) + "/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ symbol, order_id: orderId, channel: channel || "regular" }), }); const j = await r.json(); const pl = j.payload || {}; const ok = j.ok && pl.ok !== false; showToast(ok ? "已撤单" : pl.error || JSON.stringify(j), !ok); refreshMonitorBoardNow(); } catch (e) { showToast(String(e), true); } } async function cancelSymbolOrders(exchangeId, symbol, scope) { const label = scope === "conditional" ? "全部条件单" : "全部委托"; if (!confirm(`确认撤销 ${symbol} 的${label}?`)) return; try { const r = await apiFetch( "/api/orders/" + encodeURIComponent(exchangeId) + "/cancel-symbol", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ symbol, scope }), } ); const j = await r.json(); const pl = j.payload || {}; const ok = j.ok && pl.ok !== false; const n = pl.cancelled_count != null ? pl.cancelled_count : "?"; showToast(ok ? `已撤销 ${n} 笔` : pl.error || JSON.stringify(j), !ok); refreshMonitorBoardNow(); } catch (e) { showToast(String(e), true); } } 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; const hm = row.hub_monitor || {}; const flaskOk = row.flask_ok !== false && hm.ok !== false; const strategyStats = renderCardStrategyStats(row, hm, flaskOk); return `
${esc(row.name)}
${fmt(upnl, 2)} U
${esc(posLine)}
${strategyStats}
UPD ${esc(tsShort)}
`; } function renderMonitorCard(row) { const ag = row.agent || {}; const pos = Array.isArray(ag.positions) ? ag.positions : []; const hm = row.hub_monitor || {}; const flaskOk = row.flask_ok !== false && hm.ok !== false; const keys = flaskOk ? hm.keys || [] : []; const orders = flaskOk ? hm.orders || [] : []; const trends = flaskOk ? hm.trends || [] : []; const rolls = flaskOk ? hm.rolls || [] : []; const kmap = {}; (row.key_prices || []).forEach((k) => { kmap[k.id] = k; }); let inner = ""; const agOk = ag.ok !== false; const agErr = ag.error || row.error || ""; if (!row.http_ok) { inner = `
${esc(row.error || "子代理不可用")}
`; } else if (!agOk) { inner = `
${esc(agErr || "子代理返回失败")}
`; inner += `
请检查 PM2 子代理与 ${esc(row.agent_url || "")}/status
`; } else { inner = renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap); } const online = row.http_ok && agOk; const cardCls = online ? "card-online" : "card-offline"; const dotCls = online ? "ok" : "bad"; const flaskOpen = row.flask_url_browser || row.flask_url; const openFlask = flaskOpen ? `实例` : ""; const openReview = flaskOpen ? `复盘` : ""; return `
${esc(row.name)}
${esc(flaskOpen || "")}
${openFlask} ${openReview}
${inner}
`; } async function closeOnePosition(exchangeId, symbol, side) { const label = `${symbol} · ${side}`; if (!confirm(`确认对该账户市价平仓:${label}?`)) return; try { const r = await apiFetch( "/api/close/" + encodeURIComponent(exchangeId) + "/position", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ symbol, side }), } ); const j = await r.json(); const pl = j.payload || {}; const ok = j.ok && pl.ok !== false; const msg = (ok && pl.closed ? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}` : pl.error) || JSON.stringify(j, null, 2); showToast(msg, !ok); refreshMonitorBoardNow(); } catch (e) { showToast(String(e), true); } } async function closeOne(id) { if (!confirm("确认对该账户市价全平?")) return; try { const r = await apiFetch("/api/close/" + encodeURIComponent(id), { method: "POST" }); const j = await r.json(); showToast(JSON.stringify(j, null, 2), !r.ok); refreshMonitorBoardNow(); } catch (e) { showToast(String(e), true); } } async function closeAll() { const n = enabledAccounts().length; if (!confirm(`对 ${n} 个已启用账户执行紧急全平?`)) return; try { const r = await apiFetch("/api/close-all", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ exclude_ids: [] }), }); const j = await r.json(); showToast(JSON.stringify(j, null, 2), !r.ok); refreshMonitorBoardNow(); } catch (e) { showToast(String(e), true); } } async function loadSettingsMetaLine() { try { const r = await apiFetch("/api/settings/meta"); const m = await r.json(); const el = document.getElementById("settings-meta-line"); if (!el) return; const parts = []; if (m.password_required) parts.push("已启用用户名+密码登录"); else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置 HUB_USERNAME + HUB_PASSWORD)"); if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin); else parts.push("未设 HUB_PUBLIC_ORIGIN(复盘链接仅本机可开)"); if ((m.env_disabled_ids || []).length) { parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ") + "(改 .env 后须重启 hub)"); } else { parts.push("HUB_DISABLED_IDS 未强制关闭任何账户"); } el.textContent = parts.join(" · "); } catch (_) {} } function renderSettingsList(data) { const list = document.getElementById("settings-list"); if (!list) return; list.innerHTML = (data.exchanges || []) .map((ex, idx) => renderSettingsCard(ex, idx)) .join(""); list.querySelectorAll(".btn-del-ex").forEach((btn) => { btn.onclick = () => { const i = Number(btn.dataset.idx); data.exchanges.splice(i, 1); settingsCache = data; renderSettingsList(data); }; }); } function loadSettingsUI() { loadSettingsMetaLine(); loadSettings().then((data) => { renderSettingsList(data); }); } function renderSettingsCard(ex, idx) { const caps = ex.capabilities || []; const envOff = ex.env_disabled ? '环境变量强制关' : ""; return `
${envOff}
`; } function collectSettingsFromUI() { const rows = [...document.querySelectorAll("#settings-list .settings-card")]; return { version: 1, exchanges: rows.map((card) => { const caps = []; if (card.querySelector(".cap-key").checked) caps.push("key"); if (card.querySelector(".cap-trend").checked) caps.push("trend"); const id = card.querySelector(".ex-id").value.trim(); const stableKey = (card.dataset.key || id).trim(); return { id: id, key: stableKey, name: card.querySelector(".ex-name").value.trim(), flask_url: card.querySelector(".ex-flask").value.trim(), agent_url: card.querySelector(".ex-agent").value.trim(), review_url: card.querySelector(".ex-review").value.trim(), enabled: card.querySelector(".ex-enabled").checked, capabilities: caps, }; }), }; } async function saveSettings() { const body = collectSettingsFromUI(); try { const r = await apiFetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const j = await r.json(); if (j.ok) { showToast("设置已保存(已写入 hub_settings.json)"); if (j.settings) { settingsCache = j.settings; renderSettingsList(j.settings); loadSettingsMetaLine(); } else { await loadSettingsUI(); } } else showToast("保存失败", true); } catch (e) { showToast(String(e), true); } } document.getElementById("btn-logout").onclick = async () => { try { await fetch("/api/auth/logout", { method: "POST" }); } catch (_) {} location.href = "/login"; }; document.getElementById("btn-monitor-refresh").onclick = () => refreshMonitorBoardNow(); document.getElementById("auto-monitor").onchange = () => { if (document.getElementById("auto-monitor").checked) { connectMonitorBoardStream(); } else { closeMonitorBoardStream(); } }; document.getElementById("btn-close-all").onclick = closeAll; document.getElementById("btn-settings-save").onclick = saveSettings; document.getElementById("btn-settings-reload").onclick = loadSettingsUI; document.getElementById("btn-settings-add").onclick = () => { const data = settingsCache || { exchanges: [] }; const nid = String(Date.now() % 100000); data.exchanges.push({ id: nid, key: "custom_" + nid, name: "新交易所", flask_url: "http://127.0.0.1:5000", agent_url: "http://127.0.0.1:15200", review_url: "", enabled: false, capabilities: ["key"], }); settingsCache = data; renderSettingsList(data); showToast("已添加一行,请填写 URL 后点「保存设置」"); }; initTpslModal(); initInstanceFrame(); initFullscreen(); initMobileLayout(); function initShellNav() { document.querySelectorAll(".top-nav a[href^='/']").forEach((a) => { a.addEventListener("click", (ev) => { const href = a.getAttribute("href"); if (!href || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; ev.preventDefault(); const path = href.split("?")[0]; if (path === window.location.pathname) { setActiveNav(); return; } history.pushState({}, "", href); setActiveNav(); }); }); window.addEventListener("popstate", setActiveNav); } initAuth().then((ok) => { if (!ok) return; initShellNav(); setActiveNav(); if (currentPage() === "settings") { loadSettings().catch(() => {}); } }); })();