(function () { const toast = document.getElementById("toast"); let settingsCache = null; let authState = { required: false, logged_in: true }; function displayPref(key, defaultOn) { const d = settingsCache && settingsCache.display; if (!d || d[key] === undefined) return defaultOn !== false; return !!d[key]; } window.hubDisplayPref = displayPref; function showAccountPnlPref() { return displayPref("show_account_pnl", true); } window.hubShowAccountPnlPref = showAccountPnlPref; function showMonitorBinancePref() { return displayPref("show_monitor_binance", true); } function showMonitorOkxPerpPref() { return displayPref("show_monitor_okx_perp", true); } function showMonitorOkxOptionsPref() { return displayPref("show_monitor_okx_options", true); } function showMonitorGatePref() { return displayPref("show_monitor_gate", true); } function monitorExchangeKeyVisible(key) { const k = String(key || "").toLowerCase(); if (k === "binance") return showMonitorBinancePref(); if (k === "gate") return showMonitorGatePref(); if (k === "okx") return showMonitorOkxPerpPref() || showMonitorOkxOptionsPref(); return true; } function filterVisibleMonitorRows(rows) { return (rows || []).filter((r) => monitorExchangeKeyVisible(r && r.key)); } function showNavFundsPref() { return displayPref("show_nav_funds", true); } function showNavDashboardPref() { return displayPref("show_nav_dashboard", true); } function showNavPlanPref() { return displayPref("show_nav_plan", true); } function showNavArchivePref() { return displayPref("show_nav_archive", true); } function showNavQuotesPref() { return displayPref("show_nav_quotes", true); } function showNavAiPref() { return displayPref("show_nav_ai", true); } function showNavCalculatorPref() { return displayPref("show_nav_calculator", true); } function showNavComparePref() { return displayPref("show_nav_compare", true); } function showNavStrategyPref() { return displayPref("show_nav_strategy", true); } function showNavAmpStatsPref() { return displayPref("show_nav_amp_stats", true); } function showNavHelpPref() { return displayPref("show_nav_help", true); } function showNavLogsPref() { return displayPref("show_nav_logs", true); } function syncNavVisibility(data) { const d = (data && data.display) || {}; const pairs = [ ["nav-funds", "m-nav-funds", d.show_nav_funds === false], ["nav-dashboard", "m-nav-dashboard", d.show_nav_dashboard === false], ["nav-plan", "m-nav-plan", d.show_nav_plan === false], ["nav-archive", "m-nav-archive", d.show_nav_archive === false], ["nav-quotes", "m-nav-quotes", d.show_nav_quotes === false], ["nav-ai", "m-tab-ai", d.show_nav_ai === false], ["nav-calculator", "m-tab-calculator", d.show_nav_calculator === false], ["nav-compare", "m-nav-compare", d.show_nav_compare === false], ["nav-strategy", "m-nav-strategy", d.show_nav_strategy === false], ["nav-amp-stats", "m-nav-amp-stats", d.show_nav_amp_stats === false], ["nav-help", "m-nav-help", d.show_nav_help === false], ["nav-logs", "m-nav-logs", d.show_nav_logs === false], ]; pairs.forEach(([desktopId, mobileId, hide]) => { const a = document.getElementById(desktopId); const b = document.getElementById(mobileId); if (a) a.classList.toggle("nav-hidden", hide); if (b) b.classList.toggle("nav-hidden", hide); }); } const HUB_PHONE_PRIMARY = { monitor: 1, market: 1, calculator: 1, ai: 1 }; /** 窄屏布局:仅按视口宽度,监控区/行情等共用;手机端 UI 不改 */ function isMobileLayout() { return window.matchMedia("(max-width: 720px)").matches; } function syncHubPhoneShellClass() { document.body.classList.toggle("hub-phone", isMobileLayout()); document.body.classList.remove("hub-tablet"); } function closeHubMobileMore() { document.body.classList.remove("hub-mobile-more-open"); const more = document.getElementById("hub-mobile-more"); const btn = document.getElementById("m-tab-more"); if (more) more.setAttribute("aria-hidden", "true"); if (btn) btn.setAttribute("aria-expanded", "false"); syncHubMobileTabActive(currentPage()); } function openHubMobileMore() { if (!isMobileLayout()) return; document.body.classList.add("hub-mobile-more-open"); const more = document.getElementById("hub-mobile-more"); const btn = document.getElementById("m-tab-more"); if (more) more.setAttribute("aria-hidden", "false"); if (btn) btn.setAttribute("aria-expanded", "true"); syncHubMobileTabActive(currentPage()); } function toggleHubMobileMore() { if (document.body.classList.contains("hub-mobile-more-open")) closeHubMobileMore(); else openHubMobileMore(); } function syncHubMobileTabActive(page) { const primary = !!HUB_PHONE_PRIMARY[page]; const moreOpen = document.body.classList.contains("hub-mobile-more-open"); document.querySelectorAll(".hub-mobile-tabbar .hub-m-tab").forEach((el) => { const tab = el.getAttribute("data-hub-tab") || ""; let on = false; if (tab === "more") on = moreOpen || !primary; else on = !moreOpen && tab === page; el.classList.toggle("active", on); }); document.querySelectorAll(".hub-mobile-more-nav a").forEach((a) => { const href = (a.getAttribute("href") || "").split("?")[0]; a.classList.toggle("active", href === "/" + page); }); } function pageNavAllowed(page) { if (page === "funds") return showNavFundsPref(); if (page === "dashboard") return showNavDashboardPref(); if (page === "plan") return showNavPlanPref(); if (page === "archive") return showNavArchivePref(); if (page === "quotes") return showNavQuotesPref(); if (page === "ai") return showNavAiPref(); if (page === "calculator") return showNavCalculatorPref(); if (page === "compare") return showNavComparePref(); if (page === "strategy") return showNavStrategyPref(); if (page === "amp-stats") return showNavAmpStatsPref(); if (page === "help") return showNavHelpPref(); if (page === "logs") return showNavLogsPref(); return true; } function syncDisplayPrefsUI(data) { const d = (data && data.display) || {}; const setChk = (id, key) => { const el = document.getElementById(id); if (el) el.checked = d[key] !== false; }; setChk("pref-show-account-pnl", "show_account_pnl"); setChk("pref-show-nav-funds", "show_nav_funds"); setChk("pref-show-nav-dashboard", "show_nav_dashboard"); setChk("pref-show-nav-plan", "show_nav_plan"); setChk("pref-show-nav-archive", "show_nav_archive"); setChk("pref-show-nav-quotes", "show_nav_quotes"); setChk("pref-show-nav-ai", "show_nav_ai"); setChk("pref-show-nav-calculator", "show_nav_calculator"); setChk("pref-show-nav-compare", "show_nav_compare"); setChk("pref-show-nav-strategy", "show_nav_strategy"); setChk("pref-show-nav-amp-stats", "show_nav_amp_stats"); setChk("pref-show-nav-help", "show_nav_help"); setChk("pref-show-nav-logs", "show_nav_logs"); setChk("pref-show-monitor-binance", "show_monitor_binance"); setChk("pref-show-monitor-okx-perp", "show_monitor_okx_perp"); setChk("pref-show-monitor-okx-options", "show_monitor_okx_options"); setChk("pref-show-monitor-gate", "show_monitor_gate"); setChk("pref-show-strategy-playbook-v2", "show_strategy_playbook_v2"); setChk("pref-show-strategy-playbook", "show_strategy_playbook"); setChk("pref-show-strategy-behavior", "show_strategy_behavior"); setChk("pref-show-strategy-binance", "show_strategy_binance"); setChk("pref-show-strategy-okx", "show_strategy_okx"); setChk("pref-show-strategy-gate", "show_strategy_gate"); syncNavVisibility(data); } function syncSupervisorSettingsUI(data) { const s = (data && data.supervisor) || {}; const enabled = document.getElementById("supervisor-enabled"); const prog = document.getElementById("supervisor-wechat-program"); const webhook = document.getElementById("supervisor-wechat-webhook"); const link = document.getElementById("supervisor-wechat-link"); const prefix = document.getElementById("supervisor-wechat-prefix"); const daily = document.getElementById("supervisor-daily-warn"); const interval = document.getElementById("supervisor-interval-warn"); const freq30 = document.getElementById("supervisor-freq-30m"); const reopen = document.getElementById("supervisor-reopen-min"); if (enabled) enabled.checked = s.enabled !== false; if (prog) prog.checked = s.wechat_on_program_tp_sl !== false; if (webhook) webhook.value = s.wechat_webhook || ""; if (link) link.value = s.wechat_link_base || ""; if (prefix) prefix.value = s.wechat_prefix || "【交易监管】"; if (daily) daily.value = Number(s.manual_close_daily_warn) || 2; if (interval) interval.value = Number(s.interval_warn_minutes) || 15; if (freq30) freq30.value = Number(s.freq_30m_count) || 2; if (reopen) reopen.value = Number(s.reopen_after_close_minutes) || 30; } function positionTableHeadHtml(compact) { const pnlTh = showAccountPnlPref() ? "浮盈" : ""; const cls = compact ? " data-table data-table-positions" : ""; return `${pnlTh}`; } let tpslPending = null; let lastMonitorRows = []; let monitorGridOptionsSplit = false; let lastMonitorTotals = null; 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; let hostStatusTimer = null; const HOST_STATUS_POLL_MS = 5000; const HOST_STATUS_OPEN_KEY = "hub-host-status-open"; const HOST_RESOURCE_ALERT_THRESHOLD = 85; const hostResourceAlertLatch = { cpu: false, mem: false }; function loadBoolPref(key, defaultValue) { try { const raw = localStorage.getItem(key); if (raw === "1" || raw === "true") return true; if (raw === "0" || raw === "false") return false; } catch (_) {} return !!defaultValue; } function saveBoolPref(key, on) { try { localStorage.setItem(key, on ? "1" : "0"); } catch (_) {} } function fmtHostBytes(n) { const v = Number(n); if (!Number.isFinite(v)) return "—"; const abs = Math.abs(v); if (abs >= 1e12) return (v / 1e12).toFixed(2) + " TB"; if (abs >= 1e9) return (v / 1e9).toFixed(2) + " GB"; if (abs >= 1e6) return (v / 1e6).toFixed(2) + " MB"; if (abs >= 1e3) return (v / 1e3).toFixed(1) + " KB"; return v.toFixed(0) + " B"; } function fmtHostUptime(sec) { const s = Math.max(0, Number(sec) || 0); const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); if (d > 0) return d + "天" + h + "时"; if (h > 0) return h + "时" + m + "分"; return m + "分"; } function hostMetricLevel(percent) { const p = Number(percent); if (!Number.isFinite(p)) return "ok"; if (p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad"; return "ok"; } function hostOverallLevel(cpu, mem, disk) { const vals = [cpu && cpu.percent, mem && mem.percent, disk && disk.percent]; for (let i = 0; i < vals.length; i++) { const p = Number(vals[i]); if (Number.isFinite(p) && p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad"; } return "ok"; } function setHostMetricBar(fillEl, percent) { if (!fillEl) return; const p = Math.max(0, Math.min(100, Number(percent) || 0)); const level = hostMetricLevel(p); fillEl.style.width = p + "%"; fillEl.classList.remove("warn", "bad", "ok"); fillEl.classList.add(level === "bad" ? "bad" : "ok"); } function checkHostResourceAlert(cpu, mem) { const msgs = []; const cpuP = Number(cpu && cpu.percent); if (Number.isFinite(cpuP) && cpuP >= HOST_RESOURCE_ALERT_THRESHOLD) { if (!hostResourceAlertLatch.cpu) { msgs.push("CPU 使用率 " + cpuP + "%"); hostResourceAlertLatch.cpu = true; } } else { hostResourceAlertLatch.cpu = false; } const memP = Number(mem && mem.percent); if (Number.isFinite(memP) && memP >= HOST_RESOURCE_ALERT_THRESHOLD) { if (!hostResourceAlertLatch.mem) { msgs.push("内存使用率 " + memP + "%"); hostResourceAlertLatch.mem = true; } } else { hostResourceAlertLatch.mem = false; } if (msgs.length) { window.alert( "服务器资源告警\n\n" + msgs.join("\n") + "\n\n请及时关注中控服务器负载." ); } } function hostMetricSummaryHtml(label, percent) { const p = Number(percent); if (!Number.isFinite(p)) { return esc(label) + " —"; } const tone = hostMetricLevel(p); return ( esc(label) + ' ' + p + "%" ); } function renderHostStatusSummary(data, el) { if (!el) return; if (!data || !data.ok) { el.className = "host-status-summary-text bad"; el.textContent = (data && data.msg) || "状态不可用"; return; } const cpu = data.cpu || {}; const mem = data.memory || {}; const disk = data.disk || {}; const parts = []; const host = String(data.hostname || "").trim(); if (host) { parts.push('' + esc(host) + ""); } if (cpu.percent != null) parts.push(hostMetricSummaryHtml("CPU", cpu.percent)); if (mem.percent != null) parts.push(hostMetricSummaryHtml("内存", mem.percent)); if (disk.percent != null) parts.push(hostMetricSummaryHtml("硬盘", disk.percent)); el.className = "host-status-summary-text"; el.innerHTML = parts.length ? parts.join(' · ') : "—"; } function setHostMetricVal(el, percent) { if (!el) return; const p = Number(percent); el.classList.remove("ok", "bad"); if (!Number.isFinite(p)) { el.textContent = "—"; return; } el.textContent = p + "%"; el.classList.add(hostMetricLevel(p)); } let hostStatusPanelInited = false; function initHostStatusPanel() { const panel = document.getElementById("host-status-panel"); if (!panel) return; panel.classList.remove("hidden"); if (!hostStatusPanelInited) { panel.open = loadBoolPref(HOST_STATUS_OPEN_KEY, false); panel.addEventListener("toggle", function () { saveBoolPref(HOST_STATUS_OPEN_KEY, !!panel.open); }); hostStatusPanelInited = true; } } function renderHostStatusBar(data) { const panel = document.getElementById("host-status-panel"); const summaryText = document.getElementById("host-status-summary-text"); const bar = document.getElementById("host-status-bar"); if (!panel || !bar) return; const dot = document.getElementById("host-status-dot"); const name = document.getElementById("host-status-name"); const uptime = document.getElementById("host-status-uptime"); const updated = document.getElementById("host-status-updated"); const cpuVal = document.getElementById("host-cpu-val"); const cpuSub = document.getElementById("host-cpu-sub"); const memVal = document.getElementById("host-mem-val"); const memSub = document.getElementById("host-mem-sub"); const diskVal = document.getElementById("host-disk-val"); const diskSub = document.getElementById("host-disk-sub"); const netUp = document.getElementById("host-net-up"); const netDown = document.getElementById("host-net-down"); panel.classList.remove("hidden"); renderHostStatusSummary(data, summaryText); if (!data || !data.ok) { if (dot) dot.className = "host-status-dot bad"; if (name) { name.textContent = "服务器"; name.title = ""; } if (uptime) uptime.textContent = (data && data.msg) || "状态不可用"; if (updated) updated.textContent = ""; if (cpuVal) cpuVal.textContent = "—"; if (cpuSub) cpuSub.textContent = ""; if (memVal) memVal.textContent = "—"; if (memSub) memSub.textContent = ""; if (diskVal) diskVal.textContent = "—"; if (diskSub) diskSub.textContent = ""; if (netUp) netUp.textContent = "↑ —"; if (netDown) netDown.textContent = "↓ —"; return; } const cpu = data.cpu || {}; const mem = data.memory || {}; const disk = data.disk || {}; const net = data.network || {}; checkHostResourceAlert(cpu, mem); const overall = hostOverallLevel(cpu, mem, disk); if (dot) dot.className = "host-status-dot " + overall; const hostname = data.hostname || "服务器"; if (name) { name.textContent = hostname; name.title = hostname; } if (uptime) uptime.textContent = "运行 " + fmtHostUptime(data.uptime_sec); if (updated) updated.textContent = data.updated_at ? "更新 " + data.updated_at : ""; setHostMetricBar(document.getElementById("host-cpu-fill"), cpu.percent); setHostMetricBar(document.getElementById("host-mem-fill"), mem.percent); setHostMetricBar(document.getElementById("host-disk-fill"), disk.percent); setHostMetricVal(cpuVal, cpu.percent); setHostMetricVal(memVal, mem.percent); setHostMetricVal(diskVal, disk.percent); if (cpuSub) cpuSub.textContent = cpu.count ? cpu.count + " 核" : ""; if (memSub) { memSub.textContent = fmtHostBytes(mem.used_bytes) + " / " + fmtHostBytes(mem.total_bytes); } if (diskSub) { diskSub.textContent = fmtHostBytes(disk.used_bytes) + " / " + fmtHostBytes(disk.total_bytes); } if (netUp) netUp.textContent = "↑ " + fmtHostBytes(net.sent_rate_bps) + "/s"; if (netDown) netDown.textContent = "↓ " + fmtHostBytes(net.recv_rate_bps) + "/s"; } async function fetchHostStatus() { if (currentPage() !== "monitor") return; try { const r = await apiFetch("/api/host/status", { credentials: "same-origin" }); const data = await r.json(); renderHostStatusBar(data); } catch (err) { renderHostStatusBar({ ok: false, msg: String(err && err.message ? err.message : err) }); } } function stopHostStatusPoll() { if (hostStatusTimer) { clearInterval(hostStatusTimer); hostStatusTimer = null; } } function startHostStatusPoll() { stopHostStatusPoll(); initHostStatusPanel(); void fetchHostStatus(); hostStatusTimer = setInterval(fetchHostStatus, HOST_STATUS_POLL_MS); } 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"); if (options.embed && globalThis.HubTheme && typeof HubTheme.get === "function") { q.set("hub_theme", HubTheme.get()); } 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; } /** @type {number | null} */ let instanceFrameNavLoadingTimer = null; function setInstanceFrameNavLoading(loading) { const shell = document.getElementById("instance-frame-shell"); if (!shell) return; if (instanceFrameNavLoadingTimer != null) { clearTimeout(instanceFrameNavLoadingTimer); instanceFrameNavLoadingTimer = null; } if (loading) { instanceFrameNavLoadingTimer = window.setTimeout(() => { shell.classList.add("is-instance-nav-loading"); instanceFrameNavLoadingTimer = null; }, 140); return; } shell.classList.remove("is-instance-nav-loading"); } 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: !newTab, }); 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: true } ); instanceFrameUrl = url; const frame = document.getElementById("instance-frame"); if (frame) { setInstanceFrameNavLoading(true); 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 || "实例"; setInstanceFrameNavLoading(true); frame.src = url; shell.classList.remove("hidden"); shell.setAttribute("aria-hidden", "false"); document.body.classList.add("hub-instance-frame-open"); if (frame.dataset.themeSyncBound !== "1") { frame.dataset.themeSyncBound = "1"; frame.addEventListener("load", function syncInstanceFrameTheme() { requestAnimationFrame(() => { try { if (globalThis.HubTheme && typeof HubTheme.get === "function" && frame.contentWindow) { frame.contentWindow.postMessage( { type: "hub-theme-sync", theme: HubTheme.get() }, "*" ); } } catch (_) {} }); }); } } 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"); shell.classList.remove("is-instance-nav-loading"); } 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 formatRiskStatusBadge(riskStatus) { if (!riskStatus || typeof riskStatus !== "object") return ""; if (window.AccountRiskBadge) return AccountRiskBadge.formatBadgeHtml(riskStatus, esc); const st = riskStatus.status || "normal"; const label = esc(riskStatus.status_label || "正常"); const title = esc(riskStatus.reason || ""); return `${label}`; } function fmt(n, d) { if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); } /** 币本位权利金/盈亏:ETH/BTC 保留足量小数;USDC 两位. */ function optPremiumCcyOf(p, fallbackUnderly) { const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase(); if (ccy) return ccy; const mode = String((p && p.margin_mode) || "").toLowerCase(); const inst = String((p && p.inst_id) || ""); if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) { return (inst.split("-")[0] || fallbackUnderly || "ETH").toUpperCase() || "ETH"; } return "USDC"; } function optionsPanelCcy(optMeta) { if (!optMeta || typeof optMeta !== "object") return "USDC"; const mode = String(optMeta.options_margin_mode || optMeta.margin_mode || "").toLowerCase(); const underly = String(optMeta.options_underly || "ETH").toUpperCase() || "ETH"; if (mode === "coin") return underly; const pos = Array.isArray(optMeta.positions) ? optMeta.positions : []; for (let i = 0; i < pos.length; i++) { const c = optPremiumCcyOf(pos[i], underly); if (c && c !== "USDC") return c; } return "USDC"; } function fmtOptPnlAmt(v, ccy) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; const n = Number(v); const unit = String(ccy || "USDC").toUpperCase(); if (unit === "ETH" || unit === "BTC") { const s = n.toFixed(8).replace(/\.?0+$/, ""); return s || "0"; } return fmt(n, 2); } function spotPxFromOptMeta(optMeta, p) { if (p) { const n = Number(p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px); if (Number.isFinite(n) && n > 0) return n; } if (optMeta) { const n = Number(optMeta.options_index_px != null ? optMeta.options_index_px : optMeta.index_px); if (Number.isFinite(n) && n > 0) return n; const pos = Array.isArray(optMeta.positions) ? optMeta.positions : []; for (let i = 0; i < pos.length; i++) { const px = Number(pos[i] && (pos[i].idx_px != null ? pos[i].idx_px : pos[i].idxPx)); if (Number.isFinite(px) && px > 0) return px; } } return null; } function fmtOptPnlText(v, ccy, spotPx) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; const n = Number(v); const unit = String(ccy || "USDC").toUpperCase(); const sign = n > 0 ? "+" : ""; if (unit === "ETH" || unit === "BTC") { const coin = `${sign}${fmtOptPnlAmt(Math.abs(n), unit)}`; const signedCoin = n < 0 ? `-${fmtOptPnlAmt(Math.abs(n), unit)}` : coin; const px = Number(spotPx); if (!Number.isFinite(px) || !(px > 0)) return `${signedCoin} ${unit}`; const u = n * px; const uAbs = Math.abs(u).toFixed(2); const uTxt = u < 0 ? `-${uAbs}` : u > 0 ? `+${uAbs}` : uAbs; return `${signedCoin} ${unit} / ${uTxt}U`; } return `${sign}${fmt(n, 2)}U`; } /** 交易所持仓开仓价(三所子代理 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 resolveTrendPositionRatioPct(trendPlan) { const t = trendPlan || {}; if (t.position_ratio_pct != null && t.position_ratio_pct !== "") { const n = Number(t.position_ratio_pct); if (Number.isFinite(n)) return n; } const snap = Number(t.snapshot_available_usdt); const margin = Number(t.plan_margin_capital); if (Number.isFinite(snap) && snap > 0 && Number.isFinite(margin) && margin > 0) { return Math.round((margin / snap) * 10000) / 100; } return null; } function resolveTrendSizingFooter(mo, trendPlan, isTrend, pos) { const m = mo || {}; const p = pos || {}; if (!isTrend || !trendPlan || !trendPlan.id) { return { margin: m.exchange_initial_margin ?? p.exchange_initial_margin ?? m.plan_margin ?? p.plan_margin ?? null, leverage: m.leverage, planBase: m.margin_capital, positionRatio: m.position_ratio, }; } const base = trendPlan.snapshot_available_usdt != null && trendPlan.snapshot_available_usdt !== "" ? trendPlan.snapshot_available_usdt : trendPlan.plan_margin_capital; return { margin: m.exchange_initial_margin ?? trendPlan.plan_margin_capital ?? null, leverage: trendPlan.leverage, planBase: base, positionRatio: resolveTrendPositionRatioPct(trendPlan), }; } function resolvePositionOpenMeta(mo, trendPlan, isTrend) { const useTrend = isTrend && trendPlan && trendPlan.id; const src = useTrend ? trendPlan : mo || {}; let ms = Number(src.opened_at_ms); if (!Number.isFinite(ms) || ms <= 0) { const s = String(src.opened_at || "").trim(); if (s) { const parsed = Date.parse(s.replace(" ", "T")); ms = Number.isFinite(parsed) ? parsed : null; } else { ms = null; } } else { ms = Math.round(ms); } let display = "—"; if (src.opened_at) { display = String(src.opened_at).replace("T", " ").slice(0, 16); } else if (ms) { display = new Date(ms).toISOString().slice(0, 16).replace("T", " "); } return { openedAtMs: ms, openedAtDisplay: display }; } function formatLiveHoldDuration(openedMs, nowMs) { if (openedMs == null || !Number.isFinite(Number(openedMs))) return "—"; const ms = Number(openedMs); const now = nowMs != null ? nowMs : Date.now(); let sec = Math.floor((now - ms) / 1000); if (sec < 0) sec = 0; if (sec <= 0) return "0分钟"; const d = Math.floor(sec / 86400); sec %= 86400; const h = Math.floor(sec / 3600); sec %= 3600; const m = Math.floor(sec / 60); const parts = []; if (d) parts.push(`${d}天`); if (h) parts.push(`${h}小时`); if (m || !parts.length) parts.push(`${m}分钟`); return parts.join(""); } let hubHoldDurationTimer = null; function tickHubHoldDurations() { const now = Date.now(); document.querySelectorAll(".pos-hold-duration[data-opened-ms]").forEach((el) => { const ms = Number(el.getAttribute("data-opened-ms")); if (!Number.isFinite(ms) || ms <= 0) return; el.textContent = formatLiveHoldDuration(ms, now); }); } function ensureHubHoldDurationTimer() { tickHubHoldDurations(); if (hubHoldDurationTimer) return; hubHoldDurationTimer = setInterval(tickHubHoldDurations, 1000); } function estimateLatestRiskUsdt(side, entry, sl, pos, mo) { const e = Number(entry); const s = Number(sl); if (!Number.isFinite(e) || !Number.isFinite(s) || e <= 0) return null; const sd = (side || "long").toLowerCase(); const rf = sd === "short" ? (s - e) / e : (e - s) / e; if (!Number.isFinite(rf)) return null; if (rf <= 0) return 0; const m = mo || {}; const p = pos || {}; let notional = Number(p.notional_usdt); if (!Number.isFinite(notional) || notional <= 0) { notional = Number(m.exchange_notional); } if (!Number.isFinite(notional) || notional <= 0) { const mc = Number(m.margin_capital); const lev = Number(m.leverage); if (Number.isFinite(mc) && mc > 0 && Number.isFinite(lev) && lev > 0) { notional = mc * lev; } } if (!Number.isFinite(notional) || notional <= 0) { const c = Math.abs(Number(p.contracts)); const cs = Number(p.contract_size); const mult = Number.isFinite(cs) && cs > 0 ? cs : 1; const px = Number(p.mark_price); const mark = Number.isFinite(px) && px > 0 ? px : e; if (Number.isFinite(c) && c > 0) notional = c * mult * mark; } if (!Number.isFinite(notional) || notional <= 0) return null; return Math.round(notional * rf * 100) / 100; } function formatLatestRiskMeta(mo, trendPlan, pos, tpsl) { const m = mo || {}; const t = trendPlan || {}; let v = m.latest_risk_amount != null && m.latest_risk_amount !== "" ? Number(m.latest_risk_amount) : pos && pos.latest_risk_amount != null && pos.latest_risk_amount !== "" ? Number(pos.latest_risk_amount) : t.latest_risk_amount != null && t.latest_risk_amount !== "" ? Number(t.latest_risk_amount) : null; if ((v == null || !Number.isFinite(v)) && tpsl && pos) { v = estimateLatestRiskUsdt( pos.side || m.direction, tpsl.entry, tpsl.sl, pos, m ); } if (v != null && Number.isFinite(v)) { return `最新风险: ${fmt(v, 2)}U`; } return null; } function resolveTpProfitUsdt(mo, pos) { const m = mo || {}; const p = pos || {}; const raw = m.reward_at_tp_usdt != null && m.reward_at_tp_usdt !== "" ? m.reward_at_tp_usdt : p.reward_at_tp_usdt != null && p.reward_at_tp_usdt !== "" ? p.reward_at_tp_usdt : null; if (raw == null || raw === "") return null; const n = Number(raw); return Number.isFinite(n) ? n : null; } function formatTpProfitCell(mo, pos) { const n = resolveTpProfitUsdt(mo, pos); if (n == null) return "—"; return `${fmt(n, 2)}U`; } function formatMonitorRiskMeta(mo, trendPlan) { const m = mo || {}; const t = trendPlan || {}; const amt = m.risk_amount != null && m.risk_amount !== "" ? Number(m.risk_amount) : t.risk_amount != null && t.risk_amount !== "" ? Number(t.risk_amount) : null; const pctRaw = m.risk_percent != null && m.risk_percent !== "" ? m.risk_percent : t.risk_percent != null && t.risk_percent !== "" ? t.risk_percent : null; if (pctRaw == null || pctRaw === "") { if (amt != null && Number.isFinite(amt)) { return `风险: ${fmt(amt, 2)}U`; } return null; } const pct = esc(pctRaw); if (amt != null && Number.isFinite(amt)) { return `风险: ${pct}%≈${fmt(amt, 2)}U`; } return `风险: ${pct}%`; } function resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) { const fromPos = fmtMarkPrice(pos, tickMap); if (fromPos && fromPos !== "—") return fromPos; const t = trendPlan || {}; const sym = symbol || (pos && pos.symbol) || t.exchange_symbol || t.symbol || ""; if (t.floating_mark != null && t.floating_mark !== "") { return fmtSymbolPrice(t.floating_mark, sym, tickMap); } if (t.last_mark_price != null && t.last_mark_price !== "") { return fmtSymbolPrice(t.last_mark_price, sym, tickMap); } return "—"; } function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) { const e = Number(entry); const m = Number(mark); const c = Math.abs(Number(contracts)); let mult = Number(contractSize); if (!Number.isFinite(mult) || mult <= 0) mult = 1; if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) { return null; } const diff = (side || "long").toLowerCase() === "long" ? m - e : e - m; return Math.round(diff * c * mult * 100) / 100; } /** 展示浮盈:子代理 unrealized_pnl;与 entry/mark/张数 推算偏差 >20% 时用推算值 */ function resolvePositionUpnlUsdt(pos, trendPlan, markOverride) { const p = pos || {}; const t = trendPlan || {}; let exchange = p.unrealized_pnl != null && p.unrealized_pnl !== "" ? Number(p.unrealized_pnl) : null; if (exchange != null && !Number.isFinite(exchange)) exchange = null; const entry = t.avg_entry_price != null && t.avg_entry_price !== "" ? Number(t.avg_entry_price) : p.entry_price != null && p.entry_price !== "" ? Number(p.entry_price) : t.trigger_price != null ? Number(t.trigger_price) : null; let mark = markOverride != null && Number.isFinite(Number(markOverride)) ? Number(markOverride) : p.mark_price != null && p.mark_price !== "" ? Number(p.mark_price) : t.floating_mark != null ? Number(t.floating_mark) : t.last_mark_price != null ? Number(t.last_mark_price) : null; const contracts = p.contracts; const cs = p.contract_size != null && p.contract_size !== "" ? Number(p.contract_size) : 1; const computed = estimateLinearSwapUpnl( p.side || t.direction, entry, mark, contracts, cs ); if (computed == null) { if (exchange != null) return exchange; if (t.floating_pnl != null && t.floating_pnl !== "") { const n = Number(t.floating_pnl); if (Number.isFinite(n)) return n; } return null; } if (exchange == null) return computed; const ref = Math.max(Math.abs(computed), 1); if (Math.abs(exchange - computed) / ref > 0.2) return computed; return exchange; } function resolveTrendFloatingPnl(pos, trendPlan, markOverride) { return resolvePositionUpnlUsdt(pos, trendPlan, markOverride); } function formatFloatingPnlText(upnl, notionalUsdt) { if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" }; let pnlText = fmt(upnl, 2) + "U"; const notional = Number(notionalUsdt); if (Number.isFinite(notional) && Math.abs(notional) > 1e-8) { const pct = (Number(upnl) / Math.abs(notional)) * 100; pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; } return { text: pnlText, cls: pnlCls(upnl) }; } /** 与实例策略页一致:浮盈亏 % = 浮盈亏 / 计划保证金 */ function formatTrendPlanFloatingPnl(upnl, planMargin) { if (upnl == null || !Number.isFinite(Number(upnl))) { return { text: "—", cls: "" }; } let pnlText = fmt(upnl, 2) + "U"; const margin = Number(planMargin); if (Number.isFinite(margin) && margin > 0) { const pct = (Number(upnl) / margin) * 100; pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`; } const n = Number(upnl); let cls = "pnl-neutral"; if (n > 0) cls = "pnl-profit"; else if (n < 0) cls = "pnl-loss"; return { text: pnlText, cls }; } function renderDirectionBadge(side) { const s = normSide(side); const label = sideDirLabel(side); const cls = s === "long" ? "direction-long" : s === "short" ? "direction-short" : ""; if (!cls) return esc(String(label)); return `${esc(label)}`; } function resolveTrendDcaLevels(t) { if (Array.isArray(t.dca_levels) && t.dca_levels.length) return t.dca_levels; const plan = t || {}; let grid = []; let legAmounts = []; try { grid = JSON.parse(plan.grid_prices_json || "[]"); if (!Array.isArray(grid)) grid = []; } catch (_e) { grid = []; } try { legAmounts = JSON.parse(plan.leg_amounts_json || "[]"); if (!Array.isArray(legAmounts)) legAmounts = []; } catch (_e2) { legAmounts = []; } const legsDone = Number(plan.legs_done) || 0; const dcaLegs = Number(plan.dca_legs) || 0; const firstDone = Number(plan.first_order_done) !== 0; const out = [ { label: "首仓", price: null, contracts: plan.first_order_amount, status: firstDone ? "done" : "pending", status_label: firstDone ? "已开仓" : "待开仓", }, ]; const n = Math.max(grid.length, legAmounts.length, dcaLegs); for (let idx = 0; idx < n; idx += 1) { const legI = idx + 1; const done = legI <= legsDone; out.push({ label: `补仓${legI}`, price: idx < grid.length ? grid[idx] : null, contracts: idx < legAmounts.length ? legAmounts[idx] : null, status: done ? "done" : "pending", status_label: done ? "已补仓" : "待补仓", }); } return out; } 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 isTrendHandoffOrder(monitorOrder) { const mo = monitorOrder || {}; return String(mo.trade_style || "").toLowerCase() === "trend_pullback_handoff"; } function isTrendContext(monitorOrder, trendPlan) { const mo = monitorOrder || {}; const tp = trendPlan || {}; if (tp.id != null && Number(tp.id) > 0) return true; const tid = Number(mo.trend_plan_id); if (Number.isFinite(tid) && tid > 0) return true; const mt = String(mo.monitor_type || "").trim(); if (mt === "趋势回调") return true; const kst = String(mo.key_signal_type || "").trim(); return kst === "趋势回调" || kst === "趋势回调计划"; } function trendAddZoneLabel(direction) { return (direction || "long").toLowerCase() === "short" ? "补仓下沿" : "补仓上沿"; } function monitorOrderSourceLabel(mo, trendPlan) { if (isTrendContext(mo, trendPlan)) return "趋势回调计划"; const o = mo || {}; const mt = String(o.monitor_type || "").trim(); return mt || "下单监控"; } function monitorEntryStyleHtml(mo, intradayDiscipline) { const o = mo || {}; if (o.entry_model_label) return `开仓: ${esc(o.entry_model_label)}`; if (intradayDiscipline) return "开仓: —"; const ts = String(o.trade_style || "").toLowerCase(); if (ts === "swing") return "风格: 波段单"; if (ts === "trend") return "风格: 趋势单"; if (o.trade_style) return `风格: ${esc(o.trade_style)}`; return "风格: —"; } function monitorOrderSourceHtml(mo, trendPlan) { if (isTrendContext(mo, trendPlan)) { return `来源: ${esc(monitorOrderSourceLabel(mo, trendPlan))}`; } const src = monitorOrderSourceLabel(mo, trendPlan); 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("archive")) return "archive"; if (p.includes("quotes")) return "quotes"; if (p.includes("dashboard")) return "dashboard"; if (p.includes("funds")) return "funds"; if (p.includes("plan")) return "plan"; if (p.includes("calculator")) return "calculator"; if (p.includes("compare")) return "compare"; if (p.includes("help")) return "help"; if (p.includes("amp-stats")) return "amp-stats"; if (p.includes("strategy")) return "strategy"; if (p.includes("logs")) return "logs"; if (p.includes("market")) return "market"; if (p.includes("/ai")) return "ai"; return "monitor"; } function pageElementId(page) { if (page === "settings") return "page-settings"; if (page === "archive") return "page-archive"; if (page === "quotes") return "page-quotes"; if (page === "dashboard") return "page-dashboard"; if (page === "funds") return "page-funds"; if (page === "plan") return "page-plan"; if (page === "calculator") return "page-calculator"; if (page === "compare") return "page-compare"; if (page === "help") return "page-help"; if (page === "strategy") return "page-strategy"; if (page === "amp-stats") return "page-amp-stats"; if (page === "logs") return "page-logs"; if (page === "market") return "page-market"; if (page === "ai") return "page-ai"; return "page-monitor"; } function setActiveNav() { let page = currentPage(); if (!pageNavAllowed(page)) { history.replaceState({}, "", "/monitor"); page = "monitor"; } const pageId = pageElementId(page); document.querySelectorAll(".top-nav a").forEach((a) => { const href = (a.getAttribute("href") || "").split("?")[0]; a.classList.toggle( "active", href === "/" + page || (page === "monitor" && (href === "/" || href === "/monitor")) ); }); document.querySelectorAll(".page").forEach((el) => { el.classList.toggle("hidden", el.id !== pageId); }); document.body.classList.toggle("hub-page-ai", page === "ai"); document.body.classList.toggle("hub-page-funds", page === "funds"); document.body.classList.toggle("hub-page-dashboard", page === "dashboard"); document.body.classList.toggle("hub-page-monitor", page === "monitor"); document.body.classList.toggle("hub-page-market", page === "market"); document.body.classList.toggle("hub-page-calculator", page === "calculator"); document.body.classList.toggle("hub-page-compare", page === "compare"); document.body.classList.toggle("hub-page-settings", page === "settings"); document.body.classList.toggle("hub-page-archive", page === "archive"); document.body.classList.toggle("hub-page-quotes", page === "quotes"); document.body.classList.toggle("hub-page-plan", page === "plan"); document.body.classList.toggle("hub-page-strategy", page === "strategy"); document.body.classList.toggle("hub-page-amp-stats", page === "amp-stats"); document.body.classList.toggle("hub-page-logs", page === "logs"); document.body.classList.toggle("hub-page-help", page === "help"); syncHubPhoneShellClass(); if (HUB_PHONE_PRIMARY[page]) closeHubMobileMore(); syncHubMobileTabActive(page); syncHubAiMobileViewport(); if (page === "monitor") startMonitorPoll(); else stopMonitorPoll(); if (page !== "ai") closeSupervisorStream(); if (page === "dashboard" && window.hubDashboardPage) { window.hubDashboardPage.init(); } else if (window.hubDashboardPage && window.hubDashboardPage.destroy) { window.hubDashboardPage.destroy(); } if (page === "settings") loadSettingsUI(); if (page === "ai") loadAiPage(); if (page === "archive" && window.hubArchivePage) { window.hubArchivePage.init(); } else if (window.hubArchivePage && window.hubArchivePage.destroy) { window.hubArchivePage.destroy(); } if (page === "quotes" && window.hubQuotesPage) { window.hubQuotesPage.init(); } else if (window.hubQuotesPage && window.hubQuotesPage.destroy) { window.hubQuotesPage.destroy(); } if (page === "plan" && window.hubPlanPage) { window.hubPlanPage.init(); } else if (window.hubPlanPage && window.hubPlanPage.destroy) { window.hubPlanPage.destroy(); } if (page === "calculator" && window.hubCalculatorPage) { window.hubCalculatorPage.init(); } if (page === "compare" && window.hubComparePage) { window.hubComparePage.init(); } else if (window.hubComparePage && window.hubComparePage.destroy) { window.hubComparePage.destroy(); } if (page === "funds" && window.hubFundsPage) { window.hubFundsPage.init(); } else if (window.hubFundsPage && window.hubFundsPage.destroy) { window.hubFundsPage.destroy(); } if (page === "strategy" && window.hubStrategyPage) { window.hubStrategyPage.init(); } else if (window.hubStrategyPage && window.hubStrategyPage.destroy) { window.hubStrategyPage.destroy(); } if (page === "amp-stats" && window.hubAmpStatsPage) { window.hubAmpStatsPage.init(); } if (page === "help" && window.hubHelpPage) { window.hubHelpPage.init(); } else if (window.hubHelpPage && window.hubHelpPage.destroy) { window.hubHelpPage.destroy(); } if (page === "logs" && window.hubLogsPage) { window.hubLogsPage.init(); } else if (window.hubLogsPage && window.hubLogsPage.destroy) { window.hubLogsPage.destroy(); } if (page === "market" && window.hubMarketChart) { window.hubMarketChart.init(); } else if (window.hubMarketChart) { if (window.hubMarketChart.stopChartLive) window.hubMarketChart.stopChartLive(); else { if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh(); } if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer(); } } function stopMonitorPoll() { closeMonitorBoardStream(); stopHostStatusPoll(); stopMacroBannerPoll(); 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, totals) { try { sessionStorage.setItem( HUB_MONITOR_BOARD_CACHE_KEY, JSON.stringify({ version: 2, board_version: boardVersion != null ? boardVersion : localBoardVersion, updated_at: updatedAt || "", rows: rows || [], totals: totals || null, 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; lastMonitorTotals = cached.totals || null; 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"); const updSum = document.getElementById("monitor-updated-summary"); if (upd || updSum) { const ts = tsRaw.replace("T", " "); const txt = options.stale ? ts ? `缓存 ${ts} · 后台聚合中…` : "后台聚合中…" : ts ? `UPD ${ts}` : ""; if (upd) upd.textContent = txt; if (updSum) updSum.textContent = txt; } updateMonitorAlertSummary(filterVisibleMonitorRows(rows || [])); void refreshMacroRiskBanner(rows || []); renderMonitorGrid(rows || []); } let macroBannerTimer = null; let macroCalendarEditId = null; function monitorHasOpenPositions(rows) { return (rows || []).some((row) => { const pos = (row.agent && row.agent.positions) || []; return Array.isArray(pos) && pos.length > 0; }); } function macroAlertMessage(alert, hasPositions) { const label = alert.event_type_label || alert.event_type || "宏观数据"; const phase = alert.phase || "window"; const mins = Number(alert.minutes_to_event || 0); if (hasPositions) { if (phase === "imminent" && mins > 0) { return ( `「${label}」即将发布(约 ${mins} 分钟),` + "注意仓位风险:勿加仓,检查止损/减仓" ); } return `「${label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓`; } if (phase === "imminent" && mins > 0) { return `「${label}」即将发布(约 ${mins} 分钟),建议等待,避免新开仓`; } return `「${label}」高波动窗口(±1h),建议等待,避免新开仓`; } async function refreshMacroRiskBanner(rows) { if (currentPage() !== "monitor") return; const el = document.getElementById("monitor-macro-banner"); const textEl = document.getElementById("monitor-macro-banner-text"); if (!el || !textEl) return; try { const r = await apiFetch("/api/macro-calendar/active"); const j = await r.json(); const alerts = (j.ok && j.alerts) || []; if (!alerts.length) { el.classList.add("hidden"); el.classList.remove("phase-imminent"); textEl.textContent = ""; return; } const alert = alerts[0]; const hasPos = monitorHasOpenPositions(rows || lastMonitorRows); textEl.textContent = macroAlertMessage(alert, hasPos); el.classList.toggle("phase-imminent", alert.phase === "imminent"); el.classList.remove("hidden"); } catch (_) { el.classList.add("hidden"); } } function startMacroBannerPoll() { stopMacroBannerPoll(); if (currentPage() !== "monitor") return; void refreshMacroRiskBanner(lastMonitorRows); macroBannerTimer = setInterval(() => { if (currentPage() === "monitor") void refreshMacroRiskBanner(lastMonitorRows); }, 30000); } function stopMacroBannerPoll() { if (macroBannerTimer) { clearInterval(macroBannerTimer); macroBannerTimer = null; } } function startMonitorPoll() { const hadCache = restoreMonitorBoardFromCache(); void fetchMonitorBoardSnapshot({ showLoading: !hadCache }); connectMonitorBoardStream(); startHostStatusPoll(); startMacroBannerPoll(); } async function loadSettings() { const r = await apiFetch("/api/settings"); settingsCache = await r.json(); syncNavVisibility(settingsCache); return settingsCache; } function enabledAccounts() { return (settingsCache?.exchanges || []).filter((x) => x.enabled); } /** AI 教练手机布局:窄屏或手机 PWA(桌面安装的 App 仍走桌面布局) */ function isMobileAiLayout() { if (isMobileLayout()) return true; if ( window.matchMedia("(display-mode: standalone)").matches && window.matchMedia("(max-width: 960px)").matches ) { return true; } if (window.navigator && window.navigator.standalone === true) return true; return false; } 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 tradingBal = Number(row.trading_usdt); const balance = Number.isFinite(tradingBal) && tradingBal > 0 ? tradingBal : 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}`; } /** 监控卡片列数:桌面 2×2(统计+三所);期权分栏时由 CSS 控制 */ function syncMonitorGridColumns(gridEl, itemCount, opts) { if (!gridEl) return; const options = opts || {}; if (options.optionsSplit) { gridEl.style.gridTemplateColumns = ""; return; } if (isMobileLayout()) { // 手机一律单列:统计卡与三所卡不再并排挤字 gridEl.style.gridTemplateColumns = "1fr"; return; } if (options.statsFirst) { gridEl.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))"; return; } let cols = 3; if (itemCount <= 1) cols = 1; else if (itemCount === 2) cols = 2; else if (itemCount === 3) cols = 3; else if (itemCount === 4) cols = 2; else cols = 3; gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; } const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab"; const AI_MOBILE_CHAT_TABS = new Set(["trading", "general", "supervisor"]); let aiSupervisorSessionCache = null; let supervisorEventSource = null; let localSupervisorVersion = 0; let supervisorReconnectTimer = null; function isSupervisorMode() { return aiSelectedBotMode === "supervisor"; } function normalizeAiBotMode(mode) { const m = (mode || "").trim().toLowerCase(); if (m === "general") return "general"; if (m === "supervisor") return "supervisor"; return "trading"; } function normalizeAiMobileTab(tab) { const raw = (tab || "").trim().toLowerCase(); if (raw === "chat") return "trading"; if (AI_MOBILE_CHAT_TABS.has(raw) || raw === "history") return raw; return "trading"; } function applyAiMobileTab(tab) { const layout = document.querySelector(".ai-layout"); const tabs = document.querySelectorAll(".ai-mobile-tab"); if (!layout) return; const mobile = isMobileAiLayout(); if (!mobile) { delete layout.dataset.aiMobileTab; tabs.forEach((btn) => { btn.classList.remove("is-active"); btn.setAttribute("aria-selected", "false"); }); return; } const active = normalizeAiMobileTab( tab || localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading" ); layout.dataset.aiMobileTab = active; tabs.forEach((btn) => { const t = btn.dataset.aiTab || ""; const on = t === active; btn.classList.toggle("is-active", on); btn.setAttribute("aria-selected", on ? "true" : "false"); }); if (AI_MOBILE_CHAT_TABS.has(active)) { updateAiBotTabs(active); if (active === "supervisor") { void loadAiSupervisorSession().then(() => connectSupervisorStream()); } else { closeSupervisorStream(); } scrollAiChatToEnd(); } if (active === "history") { const hist = document.getElementById("ai-chat-history-list"); if (hist) hist.scrollTop = 0; } } function initAiMobileTabs() { const tabs = document.querySelectorAll(".ai-mobile-tab"); if (!tabs.length) return; tabs.forEach((btn) => { btn.addEventListener("click", () => { const tab = btn.dataset.aiTab || "trading"; if (tab === "new") { const prev = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); const botMode = prev === "general" ? "general" : prev === "supervisor" ? "supervisor" : "trading"; if (botMode === "supervisor") { void switchToSupervisorMode(); } else { void newAiChat(botMode); } return; } if (tab === "supervisor") { void switchToSupervisorMode(); return; } localStorage.setItem(AI_MOBILE_TAB_KEY, tab); applyAiMobileTab(tab); if (AI_MOBILE_CHAT_TABS.has(tab)) { const input = document.getElementById("ai-chat-input"); if (input && isMobileAiLayout()) input.focus(); } }); }); window.addEventListener("resize", () => applyAiMobileTab()); applyAiMobileTab(); } let syncHubAiMobileViewport = () => {}; function initHubAiMobileViewport() { const shell = document.querySelector(".app-shell"); const chatInput = document.getElementById("ai-chat-input"); if (!shell || !window.visualViewport) { syncHubAiMobileViewport = () => {}; return; } let baselineInnerH = Math.max(window.innerHeight, window.visualViewport.height || 0); const scrollChatToEnd = () => { const box = document.getElementById("ai-chat-messages"); if (box) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; }); }; syncHubAiMobileViewport = () => { const onAi = document.body.classList.contains("hub-page-ai"); if (!onAi || !isMobileAiLayout()) { shell.style.removeProperty("height"); shell.style.removeProperty("max-height"); shell.style.removeProperty("width"); shell.style.removeProperty("transform"); document.documentElement.style.removeProperty("--hub-vvh"); document.body.classList.remove("hub-ai-keyboard-open"); return; } const vv = window.visualViewport; const h = Math.max(240, Math.round(vv.height)); const top = Math.round(vv.offsetTop || 0); const left = Math.round(vv.offsetLeft || 0); const inputFocused = !!(chatInput && document.activeElement === chatInput); if (!inputFocused) { baselineInnerH = Math.max(baselineInnerH, window.innerHeight, h); } document.documentElement.style.setProperty("--hub-vvh", `${h}px`); shell.style.height = `${h}px`; shell.style.maxHeight = `${h}px`; shell.style.width = `${Math.round(vv.width)}px`; shell.style.transform = top > 0 || left > 0 ? `translate(${left}px, ${top}px)` : ""; const viewportShrunk = h < baselineInnerH * 0.72; const keyboardLikely = inputFocused && (viewportShrunk || top > 48); document.body.classList.toggle("hub-ai-keyboard-open", keyboardLikely); }; window.visualViewport.addEventListener("resize", syncHubAiMobileViewport); window.visualViewport.addEventListener("scroll", syncHubAiMobileViewport); window.addEventListener("resize", syncHubAiMobileViewport); window.addEventListener("orientationchange", () => { setTimeout(syncHubAiMobileViewport, 80); }); if (chatInput) { chatInput.addEventListener("focus", () => { syncHubAiMobileViewport(); scrollChatToEnd(); setTimeout(syncHubAiMobileViewport, 50); setTimeout(syncHubAiMobileViewport, 280); }); chatInput.addEventListener("blur", () => { setTimeout(syncHubAiMobileViewport, 80); setTimeout(syncHubAiMobileViewport, 320); }); } syncHubAiMobileViewport(); } function syncHubPhoneFolds() { const phone = isMobileLayout(); ["monitor-ops-fold", "market-toolbar-fold"].forEach((id) => { const el = document.getElementById(id); if (!el) return; if (!phone) { el.open = true; return; } // 手机默认折叠;用户手动点开后本会话不再强关 if (el.dataset.userToggled === "1") return; el.open = false; }); } function bindHubPhoneFolds() { ["monitor-ops-fold", "market-toolbar-fold"].forEach((id) => { const el = document.getElementById(id); if (!el || el.dataset.boundFold === "1") return; el.dataset.boundFold = "1"; el.addEventListener("toggle", () => { if (!isMobileLayout()) return; el.dataset.userToggled = "1"; }); }); const sym = document.getElementById("market-symbol"); const tf = document.getElementById("market-timeframe"); const syncMarketFoldMeta = () => { const meta = document.getElementById("market-fold-summary"); if (!meta) return; const s = sym ? String(sym.value || "").trim() : ""; const t = tf ? String(tf.value || "").trim() : ""; meta.textContent = [s || "—", t || "—"].join(" · "); }; if (sym) sym.addEventListener("change", syncMarketFoldMeta); if (sym) sym.addEventListener("input", syncMarketFoldMeta); if (tf) tf.addEventListener("change", syncMarketFoldMeta); syncMarketFoldMeta(); } function initHubMobileChrome() { const moreBtn = document.getElementById("m-tab-more"); const backdrop = document.getElementById("hub-mobile-more-backdrop"); const closeBtn = document.getElementById("hub-mobile-more-close"); if (moreBtn) { moreBtn.addEventListener("click", (ev) => { ev.preventDefault(); toggleHubMobileMore(); }); } if (backdrop) backdrop.addEventListener("click", closeHubMobileMore); if (closeBtn) closeBtn.addEventListener("click", closeHubMobileMore); document.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && document.body.classList.contains("hub-mobile-more-open")) { closeHubMobileMore(); } }); bindHubPhoneFolds(); syncHubPhoneShellClass(); syncHubPhoneFolds(); } function bindHubSpaNavLinks(selector) { document.querySelectorAll(selector).forEach((a) => { a.addEventListener("click", (ev) => { const href = a.getAttribute("href"); if (!href || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return; ev.preventDefault(); closeHubMobileMore(); const path = href.split("?")[0]; if (path === window.location.pathname) { setActiveNav(); return; } history.pushState({}, "", href); setActiveNav(); }); }); } function initMobileLayout() { initAiMobileTabs(); initHubAiMobileViewport(); initHubMobileChrome(); let resizeTimer = null; let wasMobile = isMobileLayout(); window.addEventListener("resize", () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const nowMobile = isMobileLayout(); syncHubPhoneShellClass(); syncHubPhoneFolds(); if (!nowMobile) closeHubMobileMore(); if (lastMonitorRows.length && nowMobile !== wasMobile) { wasMobile = nowMobile; renderMonitorGrid(lastMonitorRows); updateMonitorAlertSummary(filterVisibleMonitorRows(lastMonitorRows)); syncHubMobileTabActive(currentPage()); return; } wasMobile = nowMobile; const box = document.getElementById("monitor-grid"); if (box && lastMonitorRows.length) { const visible = filterVisibleMonitorRows(lastMonitorRows); const split = monitorOptionsSplitActive(visible); syncMonitorGridColumns(box, visible.length + (lastMonitorTotals ? 1 : 0), { statsFirst: !!lastMonitorTotals && !split, optionsSplit: split, }); updateMonitorAlertSummary(visible); } syncHubMobileTabActive(currentPage()); }, 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 condOrderRole(o) { const lb = (o && o.label) || ""; if (/止盈止损/.test(lb)) return null; if (/止损/.test(lb)) return "sl"; if (/止盈/.test(lb)) return "tp"; return null; } function dedupeCondOrdersByRole(orders) { const list = Array.isArray(orders) ? orders : []; const byRole = {}; const others = []; for (const o of list) { const role = condOrderRole(o); if (role) byRole[role] = o; else others.push(o); } const out = others.slice(); if (byRole.tp) out.push(byRole.tp); if (byRole.sl) out.push(byRole.sl); return out; } function dedupeCondOrdersByTrigger(orders) { const list = Array.isArray(orders) ? orders : []; const seen = new Set(); const out = []; for (const o of list) { const px = orderTriggerOrPrice(o); const key = px != null ? "t:" + String(px) : o && o.id ? "id:" + String(o.id) : null; if (key && seen.has(key)) continue; if (key) seen.add(key); out.push(o); } return out; } function upsertExTpslCondOrder(cond, role, slot) { if (!slot || slot.trigger_price == null || slot.trigger_price === "") return; const label = role === "sl" ? "止损" : "止盈"; const item = { label: label, trigger_price: Number(slot.trigger_price), amount: slot.amount != null ? slot.amount : null, id: slot.order_id || "", channel: "algo", }; const idx = cond.findIndex(function (o) { const lb = o.label || ""; return role === "sl" ? /^止损\b/.test(lb) || lb.includes("止损") : /^止盈\b/.test(lb) || lb.includes("止盈"); }); if (idx >= 0) cond[idx] = Object.assign({}, cond[idx], item); else cond.push(item); } function condOrdersFromPosition(pos) { let cond = dedupeCondOrdersByRole( Array.isArray(pos.conditional_orders) ? pos.conditional_orders : [] ); cond = dedupeCondOrdersByTrigger(cond); const et = pos.exchange_tpsl; if (!et) return cond; upsertExTpslCondOrder(cond, "sl", et.sl); upsertExTpslCondOrder(cond, "tp", et.tp); return cond; } 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 resolveTrendPlanRr(trendPlan, side, entry, sl, tp) { const t = trendPlan || {}; if (t.money_rr != null && t.money_rr !== "") { const n = Number(t.money_rr); if (Number.isFinite(n) && n > 0) return n; } if (t.planned_rr != null && t.planned_rr !== "") { const n = Number(t.planned_rr); if (Number.isFinite(n) && n > 0) return n; } const e = t.avg_entry_price != null && t.avg_entry_price !== "" ? t.avg_entry_price : entry; const s = t.stop_loss != null && t.stop_loss !== "" ? t.stop_loss : sl; const p = t.take_profit != null && t.take_profit !== "" ? t.take_profit : tp; return calcRrRatio(side, e, s, p); } function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan) { if (tpMonitored && isTrendContext(mo, trendPlan)) { const rr = resolveTrendPlanRr(trendPlan, side, entry, sl, tp); if (rr != null) return rr; } 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 formatTpCellValue(tp, tpMonitored, symbol, tickMap) { if (tpMonitored) { if (tp != null && tp !== "") { return `程序监控 · ${fmtSymbolPrice(tp, symbol, tickMap)}`; } return "程序监控"; } if (tp != null && tp !== "") return fmtSymbolPrice(tp, symbol, tickMap); return "—"; } function isBreakevenSecured(side, entry, monitorOrder, cond, pos) { const mo = monitorOrder || {}; const p = pos || {}; const { sl } = pickExTpslOrders(cond); const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN; const liveEntry = p.entry_price != null && p.entry_price !== "" ? Number(p.entry_price) : mo.avg_entry_price != null && mo.avg_entry_price !== "" ? Number(mo.avg_entry_price) : Number(entry); if (Number.isFinite(trig) && Number.isFinite(liveEntry)) { if ((side || "long").toLowerCase() === "short") return trig <= liveEntry; return trig >= liveEntry; } 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; return false; } 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; lastMonitorTotals = data.totals || null; saveMonitorBoardCache(lastMonitorRows, ts, ver, lastMonitorTotals); 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 pnlSigned(v, decimals) { const n = Number(v); const d = decimals == null ? 2 : decimals; if (!Number.isFinite(n)) return "—"; if (Math.abs(n) < 1e-12) return fmt(0, d); const abs = fmt(Math.abs(n), d); return (n > 0 ? "+" : "-") + abs; } const MONITOR_STATS_FOLD_KEY = "hub_monitor_stats_collapsed"; function isMonitorStatsCollapsed() { try { const v = localStorage.getItem(MONITOR_STATS_FOLD_KEY); if (v === null || v === "") return true; return v === "1"; } catch (_e) { return true; } } function setMonitorStatsCollapsed(collapsed) { try { localStorage.setItem(MONITOR_STATS_FOLD_KEY, collapsed ? "1" : "0"); } catch (_e) { /* ignore */ } } function renderMonitorStatsCard(totals) { const t = totals || {}; const day = t.trading_day || "—"; const resetH = t.reset_hour != null ? t.reset_hour : 8; const winN = Number(t.win_count) || 0; const lossN = Number(t.loss_count) || 0; const floatVal = Number(t.float_pnl_u); const collapsed = isMonitorStatsCollapsed(); function cell(label, main, sub, valCls) { return `
${esc(label)}
${main}
${sub ? `
${sub}
` : ""}
`; } const winSub = winN > 0 && Number.isFinite(Number(t.win_pnl_u)) ? `${esc(pnlSigned(t.win_pnl_u, 2))}U` : "—"; const lossSub = lossN > 0 && Number.isFinite(Number(t.loss_pnl_u)) ? `${esc(pnlSigned(t.loss_pnl_u, 2))}U` : "—"; const showFloat = showAccountPnlPref(); const floatMain = esc(pnlSigned(floatVal, 2)) + "U"; const floatCls = Math.abs(floatVal) > 1e-9 ? pnlCls(floatVal) : ""; const foldLabel = collapsed ? "展开明细" : "收起"; const floatSummary = showFloat ? `
总浮盈亏
${floatMain}
` : ""; const floatCell = showFloat ? cell("总浮盈亏", floatMain, "", floatCls) : ""; return `
今日统计
交易日 ${esc(day)} · 北京时间 ${esc(String(resetH))}:00 切日
${floatSummary}
${cell("今日开仓", String(Number(t.open_count) || 0), "含未平", "")} ${cell("今日平仓", String(Number(t.closed_count) || 0), "", "")} ${cell("持有仓位", String(Number(t.open_position_count) || 0), "", "")} ${cell("盈利", String(winN), winSub, winN > 0 ? "pnl-pos" : "")} ${cell("亏损", String(lossN), lossSub, lossN > 0 ? "pnl-neg" : "")} ${floatCell}
`; } 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; const visibleSource = filterVisibleMonitorRows(rows); if (expandedExchangeId && !visibleSource.some((r) => String(r.id) === String(expandedExchangeId))) { closeExchangeFullscreen(); } const mobileTiles = isMobileLayout() && !expandedExchangeId; const displayRows = mobileTiles ? sortRowsForMobileDashboard(visibleSource) : visibleSource; const optionsSplit = monitorOptionsSplitActive(displayRows); monitorGridOptionsSplit = optionsSplit; const showStatsCard = !expandedExchangeId; box.classList.toggle("grid-monitor-tiles", mobileTiles); box.classList.toggle("grid-monitor-2x2", showStatsCard && !mobileTiles && !optionsSplit); box.classList.toggle("grid-monitor-with-stats", showStatsCard && mobileTiles); box.classList.toggle("grid-monitor-options-split", optionsSplit && showStatsCard); try { const statsHtml = showStatsCard ? renderMonitorStatsCard(lastMonitorTotals) : ""; let cardsHtml = ""; if (optionsSplit) { const okxRow = displayRows.find((r) => rowHasOptionsLayout(r)); const otherByKey = {}; displayRows .filter((r) => !rowHasOptionsLayout(r)) .forEach((r) => { otherByKey[String(r.key || "").toLowerCase()] = r; }); /* 平铺顺序尽量保持:永续|币安 / 期权|Gate;隐藏项不占位 */ const cells = []; if (okxRow && showMonitorOkxPerpPref()) { cells.push(renderMonitorCard(okxRow, { okxPart: "perp", splitSide: true })); } if (otherByKey.binance && showMonitorBinancePref()) { cells.push(renderMonitorCard(otherByKey.binance, { splitSide: true })); } if (okxRow && showMonitorOkxOptionsPref()) { cells.push(renderMonitorCard(okxRow, { okxPart: "options", splitSide: true })); } if (otherByKey.gate && showMonitorGatePref()) { cells.push(renderMonitorCard(otherByKey.gate, { splitSide: true })); } Object.keys(otherByKey).forEach((k) => { if (k === "binance" || k === "gate") return; cells.push(renderMonitorCard(otherByKey[k], { splitSide: true })); }); cardsHtml = cells.length ? `
${cells.join("")}
` : ""; } else { cardsHtml = displayRows .map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r))) .join("") || (showStatsCard ? "" : '
无已启用账户
'); } box.innerHTML = statsHtml + cardsHtml; if (showStatsCard && !cardsHtml && !statsHtml) { box.innerHTML = '
无已启用账户
'; } } catch (err) { console.error("renderMonitorGrid", err); box.innerHTML = `
监控区渲染失败:${esc(String(err && err.message ? err.message : err))}
`; } syncMonitorGridColumns(box, displayRows.length + (showStatsCard ? 1 : 0), { statsFirst: showStatsCard && !optionsSplit, optionsSplit: optionsSplit && showStatsCard, }); bindMonitorInteractions(box); if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { TimeCloseUI.tickLocalCountdowns(); } ensureHubHoldDurationTimer(); if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { OptionsExpiryCountdown.ensureTimer(); } if (expandedExchangeId && fs && fsInner) { const row = visibleSource.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); if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { TimeCloseUI.tickLocalCountdowns(); } ensureHubHoldDurationTimer(); if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { OptionsExpiryCountdown.ensureTimer(); } fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => { btn.onclick = (ev) => { ev.stopPropagation(); closeExchangeFullscreen(); renderMonitorGrid(lastMonitorRows); }; }); fsInner.querySelectorAll(".btn-expand-dashboard").forEach((btn) => { btn.onclick = (ev) => { ev.stopPropagation(); closeExchangeFullscreen(); if (window.hubNavigateTo) window.hubNavigateTo("/dashboard"); else { history.pushState({}, "", "/dashboard"); setActiveNav(); } }; }); } 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 orderTriggerOrPrice(o) { if (!o) return null; if (o.trigger_price != null && o.trigger_price !== "") { const t = Number(o.trigger_price); if (Number.isFinite(t) && t > 0) return t; } if (o.price != null && o.price !== "") { const p = Number(o.price); if (Number.isFinite(p) && p > 0) return p; } return null; } function inferTpslFromCondOrders(side, cond, entry) { const picked = pickExTpslOrders(cond); let sl = picked.sl ? orderTriggerOrPrice(picked.sl) : ""; let tp = picked.tp ? orderTriggerOrPrice(picked.tp) : ""; if (sl !== "" && sl != null) sl = Number(sl); if (tp !== "" && tp != null) tp = Number(tp); if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) { return { sl, tp }; } const triggers = (cond || []) .map(function (o) { const px = orderTriggerOrPrice(o); return px == null ? null : { price: px, label: o.label || "" }; }) .filter(function (o) { return o != null; }); 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 = isTrendContext(mo, trendPlan); const handoff = isTrendHandoffOrder(mo); 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 (handoff) { tpMonitored = false; } else if (isTrend) { tpMonitored = true; if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") { sl = trendPlan.stop_loss; } if (trendPlan && trendPlan.take_profit != null && trendPlan.take_profit !== "") { takeProfit = trendPlan.take_profit; } else { takeProfit = ""; } } const inferred = inferTpslFromCondOrders(pos.side, cond, entryN); if (inferred.sl !== "" && inferred.sl != null) { sl = inferred.sl; } else if (sl === "" || sl == null) { sl = inferred.sl; } if (!tpMonitored) { if (inferred.tp !== "" && inferred.tp != null) { takeProfit = inferred.tp; } else if (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, is_handoff: handoff, }; } function buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId) { const mo = monitorOrder || {}; 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), }); }); const entryPx = num(pos.entry_price != null ? pos.entry_price : tpsl.entry); const markPx = num(pos.mark_price); const contractSize = num(pos.contract_size); const upnl = resolvePositionUpnlUsdt(pos, trendPlan, markPx); const planMargin = trendPlan && trendPlan.plan_margin_capital != null ? num(trendPlan.plan_margin_capital) : mo.margin_capital != null ? num(mo.margin_capital) : null; const leverage = trendPlan && trendPlan.leverage != null ? num(trendPlan.leverage) : mo.leverage != null ? num(mo.leverage) : null; return { exchange_id: exchangeId || null, symbol: (pos.symbol || "").trim(), side: (pos.side || "long").toLowerCase(), entry: entryPx, mark_price: markPx, stop_loss: num(tpsl.sl), take_profit: num(tpsl.tp), tp_monitored: !!tpsl.tp_monitored, is_trend: !!tpsl.is_trend, contracts: num(pos.contracts), contract_size: contractSize != null ? contractSize : 1, unrealized_pnl: upnl != null ? Number(upnl) : null, notional_usdt: num(pos.notional_usdt), plan_margin: planMargin, leverage: leverage, 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, exchangeId)) ).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-monitor-stats-toggle").forEach((btn) => { btn.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); const next = !isMonitorStatsCollapsed(); setMonitorStatsCollapsed(next); if (lastMonitorRows) renderMonitorGrid(lastMonitorRows); }; }); 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(); const msg = (btn.dataset.confirm || "").trim(); if (msg && !confirm(msg)) return; openInstance(btn.dataset.exId, btn.dataset.next || "/", { newTab: btn.dataset.newTab === "1" || ev.ctrlKey || ev.metaKey, }); }; }); box.querySelectorAll(".btn-hub-trend-stop").forEach((btn) => { btn.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); hubTrendPlanStop(btn.dataset.exId, btn.dataset.planId); }; }); box.querySelectorAll(".btn-hub-trend-be").forEach((btn) => { btn.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); const card = btn.closest(".hub-trend-plan-card"); const inp = card ? card.querySelector(".hub-plan-be-input") : null; hubTrendPlanBreakeven(btn.dataset.exId, btn.dataset.planId, inp); }; }); 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 ``; }) .join(""); return `
合约方向开仓价标记价张数盈利金额操作
${esc(o.label || o.type || "委托")} ${fmt(o.amount, 4)} ${trig}
${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 syntheticExTpslOrder(role, price, amount) { if (price == null || price === "" || !Number.isFinite(Number(price))) return null; return { label: role === "sl" ? "止损" : "止盈", trigger_price: Number(price), price: Number(price), amount: amount != null ? amount : null, id: "", channel: "plan", }; } 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, resolvedTpsl, contracts, intradayDiscipline) { const symAttr = esc(symbol || "").replace(/"/g, """); const intraday = !!intradayDiscipline; let { sl, tp } = pickExTpslOrders(cond); const plan = resolvedTpsl || {}; if (!sl && plan.sl != null && plan.sl !== "") { sl = syntheticExTpslOrder("sl", plan.sl, contracts); } if (!tp && plan.tp != null && plan.tp !== "") { tp = syntheticExTpslOrder("tp", plan.tp, contracts); } function row(label, o) { if (!o) { return `
${label}:—
`; } const oid = esc(o.id || "").replace(/"/g, """); const ch = esc(o.channel || "regular").replace(/"/g, """); const px = orderTriggerOrPrice(o); const trig = px != null ? fmtSymbolPrice(px, symbol, tickMap) : "—"; const cancelBtn = oid && o.channel !== "plan" ? `` : ""; const planHint = o.channel === "plan" ? '(下单监控)' : ""; return `
${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}${planHint} ${cancelBtn}
`; } 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 timeCloseSymbolBadgeHtml(item) { if (!item || !item.time_close_enabled) return ""; const tcLabel = item.time_close_label || `时间平仓 ${item.time_close_hours || ""}h`; const tcCd = item.time_close_countdown || "--:--:--"; const tcAt = item.time_close_at_ms != null ? String(item.time_close_at_ms) : ""; return ( `` + `${esc(tcLabel)} · ${esc(tcCd)}` ); } function forceCloseSymbolBadgeHtml(item) { if (!item || !item.force_close_enabled) return ""; const fcLabel = item.force_close_label || "强制清仓"; const fcCd = item.force_close_countdown || "--:--:--"; const fcAt = item.force_close_at_ms != null ? String(item.force_close_at_ms) : ""; const fcActive = item.force_close_active ? "1" : "0"; return ( `` + `${esc(fcLabel)} · ${esc(fcCd)}` ); } function isIntradayDisciplineRow(row) { const m = row && row.meta; if (!m || typeof m !== "object") return false; if (m.intraday_discipline === true) return true; return m.order_entry_profile === "intraday"; } function forceCloseHeadBadgeHtml(state) { if (!state || !state.enabled) return ""; return forceCloseSymbolBadgeHtml({ force_close_enabled: true, force_close_label: state.label || "强制清仓", force_close_countdown: state.countdown || "--:--:--", force_close_at_ms: state.next_at_ms, force_close_active: state.active, }); } function renderTrendDcaTable(t, tickMap) { const levels = resolveTrendDcaLevels(t); if (!levels.length) return ""; const sym = t.exchange_symbol || t.symbol || ""; const rows = levels .map((lv) => { const price = lv.price != null && lv.price !== "" ? fmtSymbolPrice(lv.price, sym, tickMap) : "—"; const amt = lv.contracts != null && lv.contracts !== "" ? esc(String(lv.contracts)) : "—"; const avg = lv.avg_entry != null && lv.avg_entry !== "" ? fmtSymbolPrice(lv.avg_entry, sym, tickMap) : "—"; const profitU = lv.profit_u != null && lv.profit_u !== "" ? fmt(lv.profit_u, 2) : "—"; const riskU = lv.risk_u != null && lv.risk_u !== "" ? fmt(lv.risk_u, 2) : "—"; const rr = lv.rr != null && lv.rr !== "" ? `${fmt(lv.rr, 2)}:1` : "—"; const stCls = lv.status === "done" ? "st-done" : "st-pending"; const label = lv.status_label || (lv.status === "done" ? "已补仓" : "待补仓"); return ` ${esc(lv.label || lv.leg_key || "—")} ${esc(price)} ${amt} ${esc(avg)} ${esc(profitU)} ${esc(riskU)} ${esc(rr)} ${esc(label)} `; }) .join(""); return `
补仓计划明细
${rows}
档位触发价张数加仓后均价止盈盈利(U)止损(U)盈亏比状态
`; } function renderTrendPlanCard(t, tickMap, pos, exchangeRow) { const sym = t.exchange_symbol || t.symbol || ""; const side = (t.direction || "long").toLowerCase(); 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); const addZone = t.add_upper_display || fmtSymbolPrice(t.add_upper, sym, tickMap) || "—"; const rr = resolveTrendPlanRr(t, side, t.avg_entry_price, t.stop_loss, t.take_profit); const rrTxt = rr != null ? `${fmt(rr, 2)}:1` : "—"; const mark = resolveTrendMarkPrice(pos, t, sym, tickMap); const legsDone = t.add_count != null ? t.add_count : t.legs_done; const legsTotal = t.add_count_total != null ? t.add_count_total : t.dca_legs; const legsTxt = legsDone != null && legsTotal != null ? `${esc(legsDone)}/${esc(legsTotal)}` : legsDone != null ? esc(legsDone) : "—"; const upnlTrend = resolveTrendFloatingPnl(pos, t); const pnlFmt = formatTrendPlanFloatingPnl(upnlTrend, t.plan_margin_capital); const pnlVal = pnlFmt.text === "—" ? "—" : `${esc(pnlFmt.text)}`; const riskTxt = t.risk_percent != null && t.risk_percent !== "" ? `${esc(t.risk_percent)}%` : "—"; const snapTxt = t.snapshot_available_usdt != null && t.snapshot_available_usdt !== "" ? `${fmt(t.snapshot_available_usdt, 2)}U` : "—"; const marginTxt = t.plan_margin_capital != null && t.plan_margin_capital !== "" ? `≈${fmt(t.plan_margin_capital, 2)}U` : "—"; const levTxt = t.leverage != null && t.leverage !== "" ? `${esc(t.leverage)}x` : "—"; const bePctDefault = t.breakeven_default_offset_pct != null && t.breakeven_default_offset_pct !== "" ? t.breakeven_default_offset_pct : t.breakeven_offset_pct != null && t.breakeven_offset_pct !== "" ? t.breakeven_offset_pct : "0.3"; const exId = exchangeRow && exchangeRow.id != null ? esc(exchangeRow.id) : ""; const planId = esc(t.id); const caps = (exchangeRow && exchangeRow.capabilities) || []; const flaskOk = exchangeRow && exchangeRow.flask_ok !== false && (exchangeRow.hub_monitor || {}).ok !== false; const canHubTrend = !!(flaskOk && caps.includes("trend") && exId && planId); const beAppliedFlag = !!t.breakeven_applied; const endBtn = canHubTrend ? `` : ""; const beBtn = canHubTrend && !beAppliedFlag ? `` : beAppliedFlag ? "" : `保本移交下单监控`; const beApplied = t.breakeven_applied ? `已保本 ${esc(String(t.breakeven_applied_at || "").slice(0, 16))}` : ""; const dcaHtml = renderTrendDcaTable(t, tickMap); const dcaCol = dcaHtml ? `
${dcaHtml}
` : `
补仓计划明细
暂无补仓档位
`; return `
#${esc(t.id)} ${esc(sym)} ${renderDirectionBadge(t.direction)}
${endBtn}
来源: 趋势回调计划 | 风险: ${riskTxt} | ${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)} | 已补仓 ${legsTxt}
均价${esc(avg)}
止损${esc(sl)}
止盈${esc(tp)}
盈亏比${esc(rrTxt)}
标记价${esc(mark)}
浮盈亏${pnlVal}
${dcaCol}
${beBtn} ${beApplied}
`; } function renderTrendSection(trends, tickMap, positions, exchangeRow) { if (!trends || !trends.length) return ""; const posList = Array.isArray(positions) ? positions : []; const cards = trends .map((t) => { const sym = t.exchange_symbol || t.symbol || ""; const side = (t.direction || "long").toLowerCase(); let matched = null; for (const p of posList) { if (!symbolsMatchHub(p.symbol, sym)) continue; const ps = (p.side || "").toLowerCase(); if (!ps || ps === side) { matched = p; break; } } return renderTrendPlanCard(t, tickMap, matched, exchangeRow); }) .join(""); return `
运行中的计划
${cards}
`; } function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap, intradayDiscipline) { 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 isTrend = isTrendContext(mo, trendPlan); const intraday = !!intradayDiscipline; const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan); const beSecured = isBreakevenSecured(side, entry, mo, cond, pos); const upnl = resolveTrendFloatingPnl(pos, trendPlan); const pnlFmt = formatFloatingPnlText(upnl, pos.notional_usdt); const pnlText = pnlFmt.text; const sizingFoot = resolveTrendSizingFooter(mo, trendPlan, isTrend, pos); const openMeta = resolvePositionOpenMeta(mo, trendPlan, isTrend); const marginText = sizingFoot.margin != null && sizingFoot.margin !== "" && Number.isFinite(Number(sizingFoot.margin)) ? fmt(Number(sizingFoot.margin), 2) + "U" : "—"; const holdMsAttr = openMeta.openedAtMs != null && Number.isFinite(openMeta.openedAtMs) ? String(openMeta.openedAtMs) : ""; const markDisplay = isTrend ? resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) : fmtMarkPrice(pos, tickMap); const meta = []; if (isTrend) { meta.push(monitorOrderSourceHtml(mo, trendPlan)); const riskLine = formatMonitorRiskMeta(mo, trendPlan); if (riskLine) meta.push(riskLine); const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl); if (latestRiskLine) meta.push(latestRiskLine); if (trendPlan && trendPlan.id) { const zone = trendPlan.add_upper_display || fmtSymbolPrice(trendPlan.add_upper, symbol, tickMap) || "—"; meta.push( `${esc(trendAddZoneLabel(trendPlan.direction))} ${esc(zone)}` ); const addSum = trendAddSummaryHtml(trendPlan, tickMap); if (addSum) meta.push(addSum.replace(/^ · /, "")); } meta.push(`移动保本:关`); } else if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) { meta.push(monitorOrderSourceHtml(mo, trendPlan)); meta.push(monitorEntryStyleHtml(mo, intraday)); const riskLine = formatMonitorRiskMeta(mo, trendPlan); if (riskLine) meta.push(riskLine); const latestRiskLine = formatLatestRiskMeta(mo, trendPlan, pos, tpsl); if (latestRiskLine) meta.push(latestRiskLine); if (!intraday) { const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true; meta.push( `移动保本:${beOn ? "开" : "关"}` ); } } else { meta.push("来源: 交易所持仓"); meta.push("风格: —"); if (!intraday) meta.push(`移动保本:关`); } const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : ""; const tcSymBadge = !isTrend && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; const fcSymBadge = !isTrend && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : ""; const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan); const headActions = `
`; return `
${tcSymBadge}${fcSymBadge}${symBeBadge} ${sideCn}
${headActions}
${meta.map((m) => `${m}`).join("")}
开仓价${fmtEntryPrice(pos, tickMap)}
标记价${markDisplay}
止损${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}
止盈${formatTpCellValue(tp, tpMonitored, symbol, tickMap)}
盈亏比${rr != null ? fmt(rr, 2) + ":1" : "—"}
张数${fmt(pos.contracts, 2)}
盈利金额${formatTpProfitCell(mo, pos)}
${ showAccountPnlPref() ? `
浮盈亏${pnlText}
` : "" }
交易所止盈止损
${renderExTpslRows(exchangeId, symbol, cond, tickMap, tpsl, pos.contracts, intraday)}
${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)}
` : ""; const keyTc = k.time_close_enabled && k.time_close_at_ms ? timeCloseSymbolBadgeHtml(k) : k.time_close_enabled && k.time_close_hours ? `时间平仓 ${esc(k.time_close_hours)}h` : ""; return `
${esc(k.symbol)} ${keyTc} · ${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 || ""; const tcBadge = o.time_close_enabled ? timeCloseSymbolBadgeHtml(o) : ""; const fcBadge = o.force_close_enabled ? forceCloseSymbolBadgeHtml(o) : ""; return `
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge}${fcBadge} · ${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.entry_model_label || o.trade_style || o.monitor_type || "下单监控")}
`; }) .join(""); } function renderRollSection(rolls, tickMap) { if (!rolls || !rolls.length) return ""; return rolls .map((g) => { const sym = g.symbol || g.exchange_symbol || ""; const avg = g.avg_entry_display || fmtSymbolPrice(g.avg_entry, sym, tickMap) || "—"; const tpProfit = g.reward_at_tp_usdt != null && g.reward_at_tp_usdt !== "" ? `${fmt(g.reward_at_tp_usdt, 2)}U` : "—"; const legs = Array.isArray(g.recent_legs) ? g.recent_legs : []; const legRows = legs .map((leg) => { const legAvg = leg.avg_entry_display || fmtSymbolPrice(leg.avg_entry_after, sym, tickMap) || "—"; const legProfit = leg.reward_at_tp_usdt != null && leg.reward_at_tp_usdt !== "" ? `${fmt(leg.reward_at_tp_usdt, 2)}U` : "—"; return `
腿 #${esc(leg.leg_index)} ${esc(leg.add_mode || "")} · 张 ${esc(leg.amount != null ? leg.amount : "—")} · 均价 ${legAvg} · 止盈 ${legProfit}
`; }) .join(""); return `
组 #${esc(g.id)} · ${esc(g.symbol || "")} ${renderDirectionHtml(g.direction)} · 监控 #${esc(g.order_monitor_id || "—")}
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · SL ${fmtSymbolPrice(g.current_stop_loss, sym, tickMap)} · 首仓TP ${fmtSymbolPrice(g.initial_take_profit, sym, tickMap)}
当前均价 ${avg} · 止盈盈利 ${tpProfit}
${legRows}
`; }) .join(""); } function renderPositionTableRow( exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts ) { const options = opts || {}; 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 mo = monitorOrder || {}; const tcBadge = !isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : ""; const fcBadge = !isTrendContext(mo, trendPlan) && mo.force_close_enabled ? forceCloseSymbolBadgeHtml(mo) : ""; const intraday = !!options.intradayDiscipline; const actionCell = `
`; const pnlTd = showAccountPnlPref() ? `${fmt(x.unrealized_pnl, 2)}` : ""; const tpProfitTd = `${formatTpProfitCell(monitorOrder, x)}`; return ` ${tcBadge}${fcBadge}${symBeBadge} ${renderDirectionHtml(x.side)} ${fmtEntryPrice(x, tickMap)} ${fmtMarkPrice(x, tickMap)} ${fmt(x.contracts, 2)} ${tpProfitTd} ${pnlTd} ${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 `
${positionTableHeadHtml(false)} ${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, opts) { const options = opts || {}; const caps = row.capabilities || []; const chips = []; if (flaskOk && hm && typeof hm === "object") { if (caps.includes("key") && !options.hideKeyChips) { 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}` }); } // 永续独立卡不再显示「期权 N仓」(期权只看期权卡);手机磁贴仍可显示 if (!options.hideOptions && (caps.includes("options") || rowHasOptionsLayout(row))) { const opt = row.options || {}; if (opt.enabled === false) { chips.push({ kind: "options", label: "期权 · 未启用" }); } else if (opt.ok === false) { chips.push({ kind: "options", label: "期权 · 异常" }); } else { const n = Number( opt.position_count != null ? opt.position_count : (opt.positions || []).length ); const nSafe = Number.isFinite(n) ? n : 0; chips.push({ kind: "options", label: nSafe > 0 ? `期权 ${nSafe}仓` : "期权" }); } } if (!chips.length) return ""; return `
${chips .map( (c) => `${esc(c.label)}` ) .join("")}
`; } function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap, intradayDiscipline) { const intraday = !!intradayDiscipline; const rows = positions .map((p) => renderPositionTableRow( exchangeId, exchangeKey, p, findMonitorOrder(orders, p.symbol, p.side), findTrendPlan(trends, p.symbol, p.side), tickMap, { compact: true, intradayDiscipline: intraday } ) ) .join(""); return `
${positionTableHeadHtml(true)} ${rows}
`; } function rowHasOptionsLayout(row) { if (!(row.capabilities || []).includes("options")) return false; const opt = row.options || {}; return opt.enabled !== false; } function sumUsdtEquiv(a, b) { const vals = [a, b] .map((v) => (v == null || v === "" ? null : Number(v))) .filter((v) => v != null && Number.isFinite(v)); if (!vals.length) return null; return vals.reduce((s, v) => s + v, 0); } function optionsRowDisplayPnl(p) { if (!p || typeof p !== "object") return null; const preview = p.close_preview || {}; // bid 无效时 total_received 常为 0,不能用 0−权利金冒充净亏 if (preview.bid_invalid) { const upl = p.upl != null ? Number(p.upl) : NaN; return Number.isFinite(upl) ? upl : null; } let net = preview.estimated_pnl; const covered = Number(preview.covered_sheets); if ( net == null && preview.total_received != null && Number.isFinite(covered) && covered > 0 && p.premium_paid != null ) { net = Number(preview.total_received) - Number(p.premium_paid); } if (net != null && Number.isFinite(Number(net))) return Number(net); const upl = p.upl != null ? Number(p.upl) : NaN; return Number.isFinite(upl) ? upl : null; } function optionsBalanceFields(opt) { if (!opt || typeof opt !== "object") { return { funding: null, trading: null, upl: null }; } const bal = opt.balances && typeof opt.balances === "object" ? opt.balances : {}; const pick = (a, b) => (a != null && a !== "" ? a : b); let upl = opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc)) ? Number(opt.upl_total_usdc) : null; if (upl == null) { const pos = Array.isArray(opt.positions) ? opt.positions : []; let sum = 0; let found = false; pos.forEach((p) => { const n = optionsRowDisplayPnl(p); if (n != null) { sum += n; found = true; } }); if (found) upl = sum; } return { funding: sumUsdtEquiv( pick(bal.funding_usdt, opt.funding_usdt), pick(bal.funding_usdc, opt.funding_usdc) ), trading: sumUsdtEquiv( pick(bal.trading_usdt, opt.trading_usdt), pick(bal.trading_usdc, opt.trading_usdc) ), upl, }; } function renderStatRow(funding, trading, upnl, kind, optMeta) { if (!showAccountPnlPref()) return ""; const isOpt = kind === "options"; const fundLabel = isOpt ? "期权资金账户" : "资金账户"; const tradeLabel = isOpt ? "期权交易账户" : "交易账户"; const pnlLabel = isOpt ? "期权浮盈" : "浮盈合计"; const rowCls = isOpt ? "stat-row stat-row-options" : "stat-row"; const coinMode = isOpt && optMeta && (optMeta.options_margin_mode === "coin" || optMeta.margin_mode === "coin"); if (coinMode) { const bal = (optMeta && optMeta.balances) || {}; const fundUsdt = bal.funding_usdt != null ? bal.funding_usdt : optMeta.funding_usdt; const usdt = bal.trading_usdt != null ? bal.trading_usdt : (optMeta.trading_usdt != null ? optMeta.trading_usdt : trading); const eth = bal.trading_eth; const btc = bal.trading_btc; const fundTxt = fundUsdt != null && fundUsdt !== "" ? `${fmt(fundUsdt, 2)} U` : "—"; const tradeTxt = formatCoinTradingLabel(usdt, eth, btc); const pnlCcy = optionsPanelCcy(optMeta); const spotPx = spotPxFromOptMeta(optMeta); const pnlTxt = upnl == null || Number.isNaN(Number(upnl)) ? "—" : `${fmtOptPnlText(upnl, pnlCcy, spotPx)}`; return `
资金账户
${fundTxt}
交易账户
${tradeTxt}
${pnlLabel}
${pnlTxt}
`; } let fundTxt = `${fmt(funding, 2)} U`; let tradeTxt = `${fmt(trading, 2)} U`; return `
${fundLabel}
${fundTxt}
${tradeLabel}
${tradeTxt}
${pnlLabel}
${fmt(upnl, 2)}
`; } function formatCoinTradingLabel(usdt, eth, btc) { const parts = []; if (usdt != null && usdt !== "") { const n = Number(usdt); if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`); } const pushCoin = (v, ccy) => { if (v == null || v === "") return; const n = Number(v); const minAmt = ccy === "BTC" ? 1e-7 : 1e-6; if (Number.isNaN(n) || !(n >= minAmt)) return; const txt = String(n.toFixed(6)).replace(/\.?0+$/, ""); parts.push(`${txt || "0"} ${ccy}`); }; pushCoin(eth, "ETH"); pushCoin(btc, "BTC"); return parts.length ? parts.join(" / ") : "—"; } function formatCoinOptFunds(opt, side, underly) { const bal = (opt && opt.balances) || {}; const usdt = side === "funding" ? (bal.funding_usdt != null ? bal.funding_usdt : opt.funding_usdt) : (bal.trading_usdt != null ? bal.trading_usdt : opt.trading_usdt); let coin = side === "funding" ? (bal.funding_eth != null ? bal.funding_eth : bal.funding_btc) : (bal.trading_eth != null ? bal.trading_eth : bal.trading_btc); if (underly === "BTC" && side === "funding" && bal.funding_btc != null) coin = bal.funding_btc; if (underly === "BTC" && side === "trading" && bal.trading_btc != null) coin = bal.trading_btc; const parts = []; if (usdt != null && usdt !== "") { const n = Number(usdt); if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`); } if (coin != null && coin !== "") { const n = Number(coin); if (!Number.isNaN(n)) { const txt = String(n.toFixed(6)).replace(/\.?0+$/, ""); parts.push(`${txt || "0"} ${underly}`); } } return parts.length ? parts.join(" / ") : "—"; } function renderAccountStatRow(row, ag) { return renderStatRow(row.funding_usdt, row.trading_usdt, ag.total_unrealized_pnl); } function renderOptionsAccountStatRow(opt) { const bal = optionsBalanceFields(opt); return renderStatRow(bal.funding, bal.trading, bal.upl, "options", opt || {}); } function shortOptionsInst(instId) { const s = String(instId || ""); if (s.length <= 22) return s; return s.slice(0, 10) + "…" + s.slice(-8); } function optionsExpiryCdHtml(expMs) { const ms = expMs != null && expMs !== "" ? String(expMs) : ""; if (!ms) return "—"; return ``; } function findOptionsTargetForInst(targets, instId) { const want = String(instId || "").trim(); if (!want) return null; const list = Array.isArray(targets) ? targets : []; return ( list.find((t) => String((t && t.inst_id) || "").trim() === want) || list.find((t) => String((t && t.instId) || "").trim() === want) || null ); } function formatProfitExitMultLabel(mult) { const n = Number(mult); if (!Number.isFinite(n) || n <= 0) return "1倍"; if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)) + "倍"; return fmt(n, 2) + "倍"; } function renderOptionsTargetCell(target, pos) { if (target && target.managed_by === "hedge_plan") { const rr = target.profit_rr != null ? Number(target.profit_rr) : null; if (rr != null && rr > 0) { return `对冲#${esc(target.plan_id)} 盈亏比 ${esc(fmt(rr, 2))}`; } const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥"; const px = target.target_index != null ? fmt(target.target_index, 1) : "—"; return `对冲#${esc(target.plan_id)} ${esc(side)} ${esc(px)}`; } const parts = []; const hasIndex = target && target.exit_mode !== "profit_exit" && target.target_index != null && Number(target.target_index) > 0; if (hasIndex) { const side = String(target.opt_type || (pos && pos.opt_type) || "").toUpperCase() === "P" ? "Put≤" : "Call≥"; parts.push(side + " " + fmt(target.target_index, 1)); } const peOn = !!(pos && pos.profit_exit_enabled) || !!(target && (target.exit_mode === "profit_exit" || target.profit_exit_enabled)); if (peOn) { const mult = pos && pos.profit_exit_mult != null ? pos.profit_exit_mult : target && target.profit_exit_mult != null ? target.profit_exit_mult : 1; parts.push(formatProfitExitMultLabel(mult)); } if (!parts.length) return "—"; return `${esc(parts.join(" · "))}`; } function renderOptionsPositionsTable(pos, targets) { if (!pos.length) return '
暂无期权持仓
'; const showPnl = showAccountPnlPref(); let html = '
'; html += ""; if (showPnl) html += ""; html += ""; pos.forEach((p) => { const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : p.opt_type || "—"; const preview = p.close_preview || {}; let net = optionsRowDisplayPnl(p); let roi = preview.estimated_pnl_ratio_pct; if (roi == null && net != null && Number(p.premium_paid) > 0) { roi = (Number(net) / Number(p.premium_paid)) * 100; } const target = findOptionsTargetForInst(targets, p.inst_id); html += ` ${renderOptionsTargetCell(target, p)}`; if (showPnl) { const premCcy = optPremiumCcyOf(p); html += ``; } html += ""; }); html += "
合约类型张数到期倒计时目标监控净盈亏收益率
${esc(shortOptionsInst(p.inst_id))}${ p.margin_mode_label || p.margin_mode === "coin" ? ` ${esc(p.margin_mode_label || "币本位")}` : "" } ${esc(optType)} ${esc(p.pos)} ${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}${net == null ? "—" : fmtOptPnlText(net, premCcy, spotPxFromOptMeta(null, p))} ${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}
"; return html; } function monitorOptionsSplitActive(rows) { if (isMobileLayout() || expandedExchangeId) return false; if (!(rows || []).some((r) => rowHasOptionsLayout(r))) return false; return showMonitorOkxPerpPref() || showMonitorOkxOptionsPref(); } function renderPerpetualInnerCard(row, ag, pos, orders, trends, tickMap, intraday) { let html = '
'; html += '
永续账户
'; html += '
'; html += renderAccountStatRow(row, ag); html += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday); html += "
"; return html; } function renderOptionsPositionsCards(pos) { if (!pos.length) return '
暂无期权持仓
'; if (!globalThis.OptionsPositionCards || !OptionsPositionCards.renderCard) { return renderOptionsPositionsTable(pos); } const cls = hubPosListCountClass(pos.length); let html = `
`; const hidePnl = !showAccountPnlPref(); pos.forEach((p) => { html += OptionsPositionCards.renderCard(p, { readOnly: true, hub: true, hidePnl }); }); html += "
"; return html; } function renderOptionsSectionBody(row, opts) { const options = opts || {}; const layout = options.layout || "table"; const opt = row.options || {}; let html = ""; if (opt.enabled === false) { html += renderOptionsAccountStatRow(opt); html += '
期权持仓
'; html += '
期权未启用(OKX_OPTIONS_ENABLED)
'; } else if (opt.ok === false) { html += renderOptionsAccountStatRow(opt); html += '
期权持仓
'; html += `
${esc(opt.msg || "期权数据不可用")}
`; } else { const pos = Array.isArray(opt.positions) ? opt.positions : []; const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : []; html += renderOptionsAccountStatRow(opt); const modeLab = esc(opt.options_margin_mode_label || (opt.options_margin_mode === "coin" ? "币本位" : "USDC")); const bridgeHint = opt.bridge_status === "pending_sell_spot" ? " · 待卖回USDT" : opt.bridge_status ? ` · 桥:${esc(opt.bridge_status)}` : ""; html += `
期权持仓 · ${pos.length} 仓 · ${modeLab}${bridgeHint}
`; html += layout === "cards" ? renderOptionsPositionsCards(pos) : renderOptionsPositionsTable(pos, targets); } return html; } function renderOptionsInnerCard(row) { let html = '
'; html += '
期权账户
'; html += '
'; html += renderOptionsSectionBody(row); html += "
"; return html; } function renderOptionsMonitorSection(row, opts) { if (!rowHasOptionsLayout(row)) return ""; let html = '
'; html += '
期权账户
'; html += renderOptionsSectionBody(row, opts); html += "
"; return html; } function renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday) { const n = Array.isArray(pos) ? pos.length : 0; let html = ""; html += `
交易所持仓 · ${n} 仓
`; html += `
`; if (n) { html += renderGridPositionsTable( row.id, row.key || row.id, pos, orders, trends, tickMap, intraday ); } else { html += '
无持仓
'; } html += "
"; return html; } const HUB_EXPAND_HINT = "点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓"; function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap, layoutOpts) { const layout = layoutOpts || {}; const tickMap = buildPriceTickMap(row); const intraday = isIntradayDisciplineRow(row); const expandHint = `
${esc(HUB_EXPAND_HINT)}
`; let inner = ""; if (layout.okxPart === "perp" || layout.splitSide) { /* 桌面分栏:仓位预留 + 原关键位/趋势/顺势芯片 + 底栏提示;不显示期权 N仓 */ if (layout.okxPart === "options") { inner += renderOptionsSectionBody(row); inner += expandHint; return inner; } if (layout.okxPart === "perp" || !rowHasOptionsLayout(row)) { inner += renderAccountStatRow(row, ag); inner += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday); inner += renderCardStrategyStats(row, hm, flaskOk, { hideOptions: true }); if (intraday) { inner += `
日内纪律:禁手动平仓/改委托 · 整点强制清仓${row.force_close && row.force_close.enabled ? " · " + esc(row.force_close.label || "强制清仓") : ""}
`; } else { inner += expandHint; } return inner; } } if (layout.okxPart === "options") { inner += renderOptionsSectionBody(row); inner += expandHint; return inner; } if (rowHasOptionsLayout(row)) { if (monitorGridOptionsSplit) { inner += '
'; inner += renderPerpetualInnerCard(row, ag, pos, orders, trends, tickMap, intraday); inner += renderOptionsInnerCard(row); inner += "
"; } else { inner += '
'; inner += '
永续账户
'; inner += renderAccountStatRow(row, ag); inner += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday); inner += "
"; inner += renderOptionsMonitorSection(row); } } else { inner += renderAccountStatRow(row, ag); inner += renderPerpetualPositionsSection(row, ag, pos, orders, trends, tickMap, intraday); } inner += renderCardStrategyStats(row, hm, flaskOk); inner += intraday ? `
日内纪律:禁手动平仓/改委托 · 整点强制清仓${row.force_close && row.force_close.enabled ? " · " + esc(row.force_close.label || "强制清仓") : ""}
` : expandHint; 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; const intraday = isIntradayDisciplineRow(row); const fcHeadBadge = intraday ? forceCloseHeadBadgeHtml(row.force_close) : ""; let html = `

${esc(row.name)}${fcHeadBadge ? " " + fcHeadBadge : ""}

${esc(flaskOpen || "")}
${flaskOpen ? `打开实例` : ""} ${flaskOpen ? `下单` : ""} ${flaskOpen ? `监控位` : ""} ${flaskOpen ? `复盘` : ""} ${flaskOpen && (row.capabilities || []).includes("options") ? `期权` : ""}
`; if (!row.http_ok || ag.ok === false) { html += `
${esc(row.error || ag.error || "子代理不可用")}
`; return html; } const posCount = pos.length; const posListCls = hubPosListCountClass(posCount); if (rowHasOptionsLayout(row)) { html += '
'; html += '
永续账户
'; } html += renderAccountStatRow(row, ag); 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, intraday ); }); } else { html += '
暂无持仓
'; } html += "
"; if (rowHasOptionsLayout(row)) { html += "
"; } html += renderOptionsMonitorSection(row, { layout: "cards" }); 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, pos, row), "暂无运行中的趋势回调计划" ); } html += renderHubSectionCard("顺势加仓", renderRollSection(rolls, tickMap), "暂无运行中的顺势加仓组"); html += "
"; 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 (frame && frame.dataset.hubNavBound !== "1") { frame.dataset.hubNavBound = "1"; frame.addEventListener("load", () => setInstanceFrameNavLoading(false)); } if (!window.__hubInstanceFrameMsgBound) { window.__hubInstanceFrameMsgBound = true; window.addEventListener("message", (ev) => { const d = ev.data; if (!d || typeof d !== "object") return; if (d.type === "instance-frame-navigating") { if (d.embedShellTab) return; setInstanceFrameNavLoading(true); } else if (d.type === "instance-frame-ready") { setInstanceFrameNavLoading(false); } }); } 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 opt = row.options || {}; const hasOptCap = (row.capabilities || []).includes("options") || rowHasOptionsLayout(row); let optLine = ""; let pnlShow = upnl; let pnlSuffix = ""; let pnlUnit = "U"; let pnlSpotPx = null; if (hasOptCap) { if (opt.enabled === false) { optLine = "期权未启用"; } else if (opt.ok === false) { optLine = "期权异常"; } else { const optCount = Number( opt.position_count != null ? opt.position_count : (opt.positions || []).length ); const n = Number.isFinite(optCount) ? optCount : 0; const bal = optionsBalanceFields(opt); const optUpl = bal.upl != null ? bal.upl : null; const optCcy = optionsPanelCcy(opt); const parts = [n > 0 ? `期权 ${n}仓` : "期权 空仓"]; if (showAccountPnlPref()) { if (bal.funding != null) parts.push(`资金 ${fmt(bal.funding, 2)}U`); if (opt.options_margin_mode === "coin") { const coinTrade = formatCoinTradingLabel( bal.trading != null ? bal.trading : opt.trading_usdt, (opt.balances || {}).trading_eth, (opt.balances || {}).trading_btc ); if (coinTrade && coinTrade !== "—") parts.push(`交易 ${coinTrade}`); } else if (bal.trading != null) { parts.push(`交易 ${fmt(bal.trading, 2)}U`); } if (optUpl != null && Number.isFinite(Number(optUpl))) { parts.push(`浮盈 ${fmtOptPnlText(optUpl, optCcy, spotPxFromOptMeta(opt))}`); } if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) { // 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U pnlShow = optUpl; pnlSuffix = "期权"; pnlUnit = optCcy; pnlSpotPx = spotPxFromOptMeta(opt); } } optLine = parts.join(" · "); } } const hm = row.hub_monitor || {}; const flaskOk = row.flask_ok !== false && hm.ok !== false; const strategyStats = renderCardStrategyStats(row, hm, flaskOk); const tilePnlHtml = pnlUnit === "ETH" || pnlUnit === "BTC" ? `${fmtOptPnlText(pnlShow, pnlUnit, pnlSpotPx)} ${pnlSuffix ? esc(pnlSuffix) : ""}` : `${fmt(pnlShow, 2)} U${pnlSuffix ? " · " + esc(pnlSuffix) : ""}`; return `
${esc(row.name)} ${formatRiskStatusBadge(hm.risk_status)}
${ showAccountPnlPref() ? `
${tilePnlHtml}
` : "" }
${esc(posLine)}
${optLine ? `
${esc(optLine)}
` : ""} ${strategyStats}
UPD ${esc(tsShort)}
`; } function renderMonitorCard(row, layoutOpts) { const opts = layoutOpts || {}; 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, opts); } 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 okxPart = opts.okxPart || ""; const isOkxOptionsCard = okxPart === "options"; const isOkxPerpCard = okxPart === "perp"; const titleSuffix = isOkxPerpCard ? " · 永续" : isOkxOptionsCard ? " · 期权" : ""; const openFlask = flaskOpen ? `打开实例` : ""; const openTrade = flaskOpen && !isOkxOptionsCard ? `下单` : ""; const openKey = flaskOpen && !isOkxOptionsCard ? `监控位` : ""; const openReview = flaskOpen ? `复盘` : ""; const openOptions = flaskOpen && ((row.capabilities || []).includes("options") || isOkxOptionsCard) ? `期权` : ""; const intraday = isIntradayDisciplineRow(row); const fcHeadBadge = intraday && !isOkxOptionsCard ? forceCloseHeadBadgeHtml(row.force_close) : ""; const showCloseAll = !isOkxOptionsCard; const layoutCls = opts.okxSplit ? " card-monitor-okx-split" : opts.splitSide || okxPart ? " card-monitor-split-side" : ""; const partAttr = okxPart ? ` data-okx-part="${esc(okxPart)}"` : ""; return `
${esc(row.name)}${esc(titleSuffix)}${fcHeadBadge}${formatRiskStatusBadge(hm.risk_status)}
${esc(flaskOpen || "")}
${openFlask} ${openTrade} ${openKey} ${openOptions} ${openReview} ${showCloseAll ? `` : ""}
${inner}
`; } async function hubTrendPlanStop(exchangeId, planId) { if (!exchangeId || !planId) { showToast("缺少交易所或计划 ID", true); return; } if (!confirm("结束计划:市价平仓并撤掉该合约全部挂单,确定?")) return; try { const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ plan_id: Number(planId) }), }); const j = await r.json(); showToast(j.message || (j.ok ? "已结束趋势回调计划" : "结束失败"), !j.ok); if (j.ok) refreshMonitorBoardNow(); } catch (e) { showToast(String(e), true); } } async function hubTrendPlanBreakeven(exchangeId, planId, inputEl) { if (!exchangeId || !planId) { showToast("缺少交易所或计划 ID", true); return; } const raw = inputEl ? String(inputEl.value || "").trim() : ""; let pct = null; if (raw !== "") { pct = Number(raw); if (!Number.isFinite(pct) || pct < 0) { showToast("保本偏移% 须为非负数", true); return; } } if ( !confirm( "确认保本?将结束本趋势计划,持仓移交「下单监控」,并在交易所挂保本止损与计划止盈;后续平仓写入交易记录." ) ) { return; } try { const body = { plan_id: Number(planId) }; if (pct != null) body.breakeven_offset_pct = pct; const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/breakeven", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const j = await r.json(); showToast(j.message || (j.ok ? "保本移交成功" : "保本移交失败"), !j.ok); if (j.ok) refreshMonitorBoardNow(); } catch (e) { showToast(String(e), true); } } 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)"); const userEl = document.getElementById("hub-pwd-current-user"); if (userEl && m.default_username) userEl.textContent = m.default_username; 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); }; }); bindSettingsCardFolds(list); list.querySelectorAll(".settings-card-save").forEach((btn) => { btn.addEventListener("click", () => { void saveSettingsSection("exchange", { label: btn.dataset.label || "账户" }); }); }); } const SETTINGS_FOLD_KEY = "hub_settings_section_fold"; function settingsFoldStorageKey(section, cardKey) { return cardKey ? `${SETTINGS_FOLD_KEY}_${section}_${cardKey}` : `${SETTINGS_FOLD_KEY}_${section}`; } function getSettingsFoldState(section, cardKey) { try { return localStorage.getItem(settingsFoldStorageKey(section, cardKey)) === "1"; } catch (_) { return false; } } function setSettingsFoldState(section, collapsed, cardKey) { try { localStorage.setItem(settingsFoldStorageKey(section, cardKey), collapsed ? "1" : "0"); } catch (_) {} } function applySettingsSectionFold(el) { const section = el.dataset.settingsSection; if (!section) return; const collapsed = getSettingsFoldState(section); el.classList.toggle("is-collapsed", collapsed); const btn = el.querySelector(":scope > .settings-section-head > .settings-section-fold"); if (btn) btn.setAttribute("aria-expanded", collapsed ? "false" : "true"); } function applySettingsCardFold(card) { const key = card.dataset.key || card.dataset.idx || ""; const collapsed = getSettingsFoldState("exchange", String(key)); card.classList.toggle("is-collapsed", collapsed); const btn = card.querySelector(".settings-card-fold"); if (btn) btn.setAttribute("aria-expanded", collapsed ? "false" : "true"); } function bindSettingsCardFolds(root) { (root || document).querySelectorAll(".settings-card").forEach((card) => { if (card.dataset.foldBound === "1") return; card.dataset.foldBound = "1"; applySettingsCardFold(card); const foldBtn = card.querySelector(".settings-card-fold"); if (!foldBtn) return; foldBtn.addEventListener("click", () => { const key = card.dataset.key || card.dataset.idx || ""; const collapsed = !card.classList.contains("is-collapsed"); card.classList.toggle("is-collapsed", collapsed); foldBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); setSettingsFoldState("exchange", collapsed, String(key)); }); }); } function initSettingsSectionFolds() { document.querySelectorAll(".settings-section-save").forEach((btn) => { if (btn.dataset.saveBound === "1") return; btn.dataset.saveBound = "1"; btn.addEventListener("click", () => { const section = btn.dataset.settingsSection || ""; if (section === "macro") { const form = document.getElementById("macro-event-form"); if (form) form.requestSubmit(); return; } const label = section === "display" ? "显示与导航" : section === "supervisor" ? "交易监管" : section === "exchanges" ? "交易所账户" : section === "backup" ? "备份设置" : "设置"; if (section === "backup") return; void saveSettingsSection(section, { label }); }); }); } async function waitHubHealth() { const deadline = Date.now() + 90000; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2000)); try { const r = await fetch("/api/admin/health", { credentials: "same-origin" }); if (r.ok) return; } catch (_) {} } throw new Error("重启后中控未在预期时间内恢复"); } async function saveHubPassword() { const status = document.getElementById("hub-pwd-save-status"); const setStatus = (msg, err) => { if (!status) return; status.textContent = msg || ""; status.classList.toggle("is-err", !!err); }; setStatus("保存中…"); try { const r = await apiFetch("/api/settings/password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ old_password: (document.getElementById("hub-pwd-old") || {}).value || "", new_username: (document.getElementById("hub-pwd-new-username") || {}).value || "", new_password: (document.getElementById("hub-pwd-new") || {}).value || "", confirm_password: (document.getElementById("hub-pwd-confirm") || {}).value || "", }), }); const j = await r.json(); if (!r.ok) throw new Error(j.detail || j.msg || "保存失败"); if (!j.restart_required) { setStatus("未修改(与当前配置相同)"); showToast("未修改"); return; } setStatus("密码已保存,正在重启中控…"); await apiFetch("/api/admin/restart", { method: "POST" }); await waitHubHealth(); setStatus("密码已更新,请用新密码重新登录"); showToast("密码已更新,请重新登录"); setTimeout(() => { location.href = "/login"; }, 1200); } catch (e) { setStatus(String(e.message || e), true); showToast(String(e.message || e), true); } } function initHubPasswordSettings() { const btn = document.getElementById("hub-pwd-save-btn"); if (!btn || btn.dataset.bound === "1") return; btn.dataset.bound = "1"; btn.addEventListener("click", () => { void saveHubPassword(); }); } function renderHubAiSyncStatus(sync) { const el = document.getElementById("hub-ai-sync-status"); if (!el) return; if (!sync) { el.textContent = ""; return; } if (sync.all_synced) { el.textContent = "三所实例 AI 配置已与中控同步"; el.classList.remove("is-err"); return; } const parts = []; const inst = sync.instances || {}; Object.keys(inst).forEach((ex) => { const row = inst[ex]; if (row && !row.ok) parts.push(`${ex} 不一致`); }); el.textContent = parts.length ? `未完全同步:${parts.join(",")}` : "同步状态未知"; el.classList.add("is-err"); } function renderHubAiEnvFields(fields) { const box = document.getElementById("hub-ai-env-fields"); if (!box) return; box.innerHTML = (fields || []) .map((f) => { const wide = f.key === "OPENAI_API_BASE" || f.key === "OLLAMA_API" ? " field-wide" : ""; const inputType = f.sensitive ? "password" : f.type === "int" ? "number" : "text"; const placeholder = f.sensitive && f.has_value ? f.masked || "****" : f.note || ""; const value = f.sensitive ? "" : esc(f.current || f.default || ""); const note = f.note ? `${esc(f.note)}` : ""; return `
`; }) .join(""); } async function loadHubAiEnvSettings() { const box = document.getElementById("hub-ai-env-fields"); if (!box) return; try { const r = await apiFetch("/api/settings/ai-env"); const j = await r.json(); if (!r.ok) throw new Error(j.detail || "加载失败"); renderHubAiEnvFields(j.fields || []); renderHubAiSyncStatus(j.sync_status); } catch (e) { box.innerHTML = `${esc(String(e.message || e))}`; } } function collectHubAiEnvValues() { const values = {}; document.querySelectorAll(".hub-ai-field[data-ai-key]").forEach((el) => { values[el.dataset.aiKey] = el.value; }); return values; } async function parseApiJson(r) { const ct = (r.headers.get("content-type") || "").toLowerCase(); if (!ct.includes("application/json")) { const text = await r.text(); const snippet = (text || "").replace(/\s+/g, " ").trim().slice(0, 120); throw new Error(snippet ? `服务返回非 JSON:${snippet}` : `HTTP ${r.status}`); } return r.json(); } async function saveHubAiEnv() { const status = document.getElementById("hub-ai-env-save-status"); const setStatus = (msg, err) => { if (!status) return; status.textContent = msg || ""; status.classList.toggle("is-err", !!err); }; setStatus("保存并同步中…"); try { const r = await apiFetch("/api/settings/ai-env", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: collectHubAiEnvValues(), restart: true }), }); const j = await parseApiJson(r); if (!r.ok) throw new Error(j.detail || j.msg || "保存失败"); if (!j.changed || !Object.keys(j.changed).length) { setStatus("未修改(与当前配置相同)"); showToast("未修改"); return; } if (j.restart_required) { setStatus("已同步三所,服务重启中…"); await waitHubHealth(); } setStatus("AI 配置已保存并同步至三所实例"); showToast("AI 配置已保存并同步"); await loadHubAiEnvSettings(); } catch (e) { setStatus(String(e.message || e), true); showToast(String(e.message || e), true); } } function initHubAiEnvSettings() { const btn = document.getElementById("hub-ai-env-save-btn"); if (btn && btn.dataset.bound !== "1") { btn.dataset.bound = "1"; btn.addEventListener("click", () => { void saveHubAiEnv(); }); } void loadHubAiEnvSettings(); } function macroDatetimeLocalToApi(v) { if (!v) return ""; return String(v).trim().replace("T", " ").slice(0, 16); } function macroApiToDatetimeLocal(s) { if (!s) return ""; return String(s).trim().replace(" ", "T").slice(0, 16); } function resetMacroEventForm() { macroCalendarEditId = null; const form = document.getElementById("macro-event-form"); const cancel = document.getElementById("macro-event-cancel"); const submit = document.getElementById("macro-event-submit"); if (form) form.reset(); if (cancel) cancel.classList.add("hidden"); if (submit) submit.textContent = "添加"; } function renderMacroEventList(events) { const box = document.getElementById("macro-event-list"); if (!box) return; const rows = events || []; if (!rows.length) { box.innerHTML = '
暂无已录入的关键数据.请在上方添加 FOMC / CPI / 就业发布时间.
'; return; } const now = Date.now(); box.innerHTML = rows .map((ev) => { const start = Number(ev.event_at_ms) - 3600000; const end = Number(ev.event_at_ms) + 3600000; const active = now >= start && now <= end; const note = ev.note ? `
${esc(ev.note)}
` : ""; return `
${esc(ev.event_type_label || ev.event_type)}
${note}
${esc(ev.event_at || "")}
${active ? "窗口内" : "待触发"} · ±1h
`; }) .join(""); box.querySelectorAll(".macro-event-edit").forEach((btn) => { btn.addEventListener("click", () => { const id = Number(btn.getAttribute("data-id")); const row = rows.find((x) => Number(x.id) === id); if (!row) return; macroCalendarEditId = id; const typeEl = document.getElementById("macro-event-type"); const atEl = document.getElementById("macro-event-at"); const noteEl = document.getElementById("macro-event-note"); const cancel = document.getElementById("macro-event-cancel"); const submit = document.getElementById("macro-event-submit"); if (typeEl) typeEl.value = row.event_type || "fomc"; if (atEl) atEl.value = macroApiToDatetimeLocal(row.event_at || ""); if (noteEl) noteEl.value = row.note || ""; if (cancel) cancel.classList.remove("hidden"); if (submit) submit.textContent = "保存"; }); }); box.querySelectorAll(".macro-event-del").forEach((btn) => { btn.addEventListener("click", async () => { const id = btn.getAttribute("data-id"); if (!id || !confirm("确定删除这条宏观关键数据?")) return; try { const r = await apiFetch(`/api/macro-calendar/events/${id}`, { method: "DELETE" }); const j = await r.json(); if (!j.ok) throw new Error(j.detail || "删除失败"); showToast("已删除"); resetMacroEventForm(); await loadMacroCalendarUI(); void refreshMacroRiskBanner(lastMonitorRows); } catch (e) { showToast(String(e), true); } }); }); } async function loadMacroCalendarUI() { const box = document.getElementById("macro-event-list"); if (!box) return; try { const r = await apiFetch("/api/macro-calendar/events"); const j = await r.json(); renderMacroEventList((j.ok && j.events) || []); } catch (e) { box.innerHTML = `
${esc(String(e))}
`; } } function initMacroCalendarSettings() { const form = document.getElementById("macro-event-form"); const cancel = document.getElementById("macro-event-cancel"); if (cancel) { cancel.addEventListener("click", () => resetMacroEventForm()); } if (!form || form.dataset.bound === "1") return; form.dataset.bound = "1"; form.addEventListener("submit", async (ev) => { ev.preventDefault(); const typeEl = document.getElementById("macro-event-type"); const atEl = document.getElementById("macro-event-at"); const noteEl = document.getElementById("macro-event-note"); const payload = { event_type: typeEl ? typeEl.value : "", event_at: macroDatetimeLocalToApi(atEl ? atEl.value : ""), note: noteEl ? noteEl.value : "", }; try { const editing = macroCalendarEditId != null; const r = await apiFetch( editing ? `/api/macro-calendar/events/${macroCalendarEditId}` : "/api/macro-calendar/events", { method: editing ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), } ); const j = await r.json(); if (!r.ok || !j.ok) throw new Error(j.detail || "保存失败"); showToast(editing ? "已更新" : "已添加"); resetMacroEventForm(); await loadMacroCalendarUI(); void refreshMacroRiskBanner(lastMonitorRows); } catch (e) { showToast(String(e), true); } }); } function loadSettingsUI() { loadSettingsMetaLine(); initMacroCalendarSettings(); initHubPasswordSettings(); initHubAiEnvSettings(); loadMacroCalendarUI(); loadSettings().then((data) => { syncDisplayPrefsUI(data); syncSupervisorSettingsUI(data); renderSettingsList(data); initSettingsSectionFolds(); if (typeof initBackupSettingsUI === "function") void initBackupSettingsUI(); }); } function renderSettingsCard(ex, idx) { const caps = ex.capabilities || []; const envOff = ex.env_disabled ? '环境变量强制关' : ""; const cardKey = esc(ex.key || ex.id || String(idx)); const cardTitle = esc(ex.name || ex.key || `账户 ${idx + 1}`); return `
${cardTitle}
${envOff}
`; } function collectSettingsFromUI() { const rows = [...document.querySelectorAll("#settings-list .settings-card")]; const chk = (id) => { const el = document.getElementById(id); return el ? !!el.checked : true; }; const supEnabled = document.getElementById("supervisor-enabled"); const supProg = document.getElementById("supervisor-wechat-program"); const supWebhook = document.getElementById("supervisor-wechat-webhook"); const supLink = document.getElementById("supervisor-wechat-link"); const supPrefix = document.getElementById("supervisor-wechat-prefix"); const supDaily = document.getElementById("supervisor-daily-warn"); const supInterval = document.getElementById("supervisor-interval-warn"); const supFreq30 = document.getElementById("supervisor-freq-30m"); const supReopen = document.getElementById("supervisor-reopen-min"); return { version: 1, display: { show_account_pnl: chk("pref-show-account-pnl"), show_nav_funds: chk("pref-show-nav-funds"), show_nav_dashboard: chk("pref-show-nav-dashboard"), show_nav_plan: chk("pref-show-nav-plan"), show_nav_archive: chk("pref-show-nav-archive"), show_nav_quotes: chk("pref-show-nav-quotes"), show_nav_ai: chk("pref-show-nav-ai"), show_nav_calculator: chk("pref-show-nav-calculator"), show_nav_compare: chk("pref-show-nav-compare"), show_nav_strategy: chk("pref-show-nav-strategy"), show_nav_amp_stats: chk("pref-show-nav-amp-stats"), show_nav_help: chk("pref-show-nav-help"), show_nav_logs: chk("pref-show-nav-logs"), show_monitor_binance: chk("pref-show-monitor-binance"), show_monitor_okx_perp: chk("pref-show-monitor-okx-perp"), show_monitor_okx_options: chk("pref-show-monitor-okx-options"), show_monitor_gate: chk("pref-show-monitor-gate"), show_strategy_playbook_v2: chk("pref-show-strategy-playbook-v2"), show_strategy_playbook: chk("pref-show-strategy-playbook"), show_strategy_behavior: chk("pref-show-strategy-behavior"), show_strategy_binance: chk("pref-show-strategy-binance"), show_strategy_okx: chk("pref-show-strategy-okx"), show_strategy_gate: chk("pref-show-strategy-gate"), }, supervisor: { enabled: supEnabled ? !!supEnabled.checked : true, wechat_webhook: supWebhook ? supWebhook.value.trim() : "", wechat_link_base: supLink ? supLink.value.trim() : "", wechat_prefix: supPrefix ? supPrefix.value.trim() : "【交易监管】", wechat_on_program_tp_sl: supProg ? !!supProg.checked : true, manual_close_daily_warn: supDaily ? Number(supDaily.value) || 2 : 2, interval_warn_minutes: supInterval ? Number(supInterval.value) || 15 : 15, freq_30m_count: supFreq30 ? Number(supFreq30.value) || 2 : 2, reopen_after_close_minutes: supReopen ? Number(supReopen.value) || 30 : 30, }, exchanges: rows.map((card) => { const caps = []; if (card.querySelector(".cap-key").checked) caps.push("key"); if (card.querySelector(".cap-trend").checked) caps.push("trend"); if (card.querySelector(".cap-options") && card.querySelector(".cap-options").checked) caps.push("options"); 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 saveSettingsSection(section, opts) { const options = opts || {}; 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("保存失败", true); return; } const label = options.label || "设置"; showToast(`${label}已保存`); if (j.settings) { settingsCache = j.settings; syncDisplayPrefsUI(j.settings); syncSupervisorSettingsUI(j.settings); renderSettingsList(j.settings); loadSettingsMetaLine(); } if (lastMonitorRows.length) renderMonitorGrid(lastMonitorRows); if (window.hubDashboardPage && window.hubDashboardPage.refresh) { window.hubDashboardPage.refresh(); } if (window.hubStrategyPage && typeof window.hubStrategyPage.reloadMeta === "function") { window.hubStrategyPage.reloadMeta(); } if (!pageNavAllowed(currentPage())) { history.replaceState({}, "", "/monitor"); setActiveNav(); } } 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-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 后点「保存设置」"); }; let aiChatLoading = false; let aiChatSessionCache = null; let aiChatSessionsCache = []; let aiSelectedBotMode = "trading"; const AI_CHAT_MAX_ATTACHMENTS = 3; let aiChatPendingFiles = []; const aiChatMdCache = new Map(); const AI_CHAT_MD_CACHE_MAX = 120; function aiChatFileKind(file) { return file && file.type && file.type.startsWith("image/") ? "image" : "text"; } function isValidAiChatFile(file) { if (!file) return false; if (file.type && file.type.startsWith("image/")) return true; const mime = (file.type || "").toLowerCase(); if (["text/plain", "text/markdown", "application/json"].includes(mime)) return true; const name = (file.name || "").toLowerCase(); return ( name.endsWith(".txt") || name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".json") ); } function syncAiChatFileInput() { const fileInput = document.getElementById("ai-chat-files"); if (!fileInput || typeof DataTransfer === "undefined") return; const dt = new DataTransfer(); aiChatPendingFiles.forEach((f) => dt.items.add(f)); fileInput.files = dt.files; } function renderAiChatPendingAttachments() { const box = document.getElementById("ai-chat-pending"); if (!box) return; if (!aiChatPendingFiles.length) { box.innerHTML = ""; box.hidden = true; return; } box.hidden = false; box.innerHTML = aiChatPendingFiles .map((f, idx) => { const kind = aiChatFileKind(f); const icon = kind === "image" ? "图" : "文"; return ( `` + `${icon}` + `${esc(f.name || "附件")}` + `` + `` ); }) .join(""); } function addAiChatPendingFiles(files) { const incoming = Array.isArray(files) ? files : []; if (!incoming.length) return; let added = 0; for (const file of incoming) { if (aiChatPendingFiles.length >= AI_CHAT_MAX_ATTACHMENTS) { showToast(`最多 ${AI_CHAT_MAX_ATTACHMENTS} 个附件`, true); break; } if (!isValidAiChatFile(file)) { showToast(`${file.name || "文件"}: 不支持的类型(仅图片或 txt/md/json)`, true); continue; } aiChatPendingFiles.push(file); added += 1; } if (!added) return; syncAiChatFileInput(); renderAiChatPendingAttachments(); } function removeAiChatPendingFile(index) { if (index < 0 || index >= aiChatPendingFiles.length) return; aiChatPendingFiles.splice(index, 1); syncAiChatFileInput(); renderAiChatPendingAttachments(); } function clearAiChatPendingFiles() { aiChatPendingFiles = []; syncAiChatFileInput(); renderAiChatPendingAttachments(); } function handleAiChatPaste(ev) { if (aiChatLoading) return; const clipboard = ev.clipboardData; if (!clipboard || !clipboard.items) return; const imageFiles = []; for (const item of clipboard.items) { if (!item.type || !item.type.startsWith("image/")) continue; const blob = item.getAsFile(); if (!blob) continue; const sub = (item.type.split("/")[1] || "png").toLowerCase(); const ext = sub === "jpeg" ? "jpg" : sub; const name = `screenshot-${Date.now()}.${ext}`; imageFiles.push(new File([blob], name, { type: item.type })); } if (!imageFiles.length) return; ev.preventDefault(); addAiChatPendingFiles(imageFiles); } function renderHubMarkdown(text, cacheKey) { const raw = String(text || ""); if (cacheKey && aiChatMdCache.has(cacheKey)) { return aiChatMdCache.get(cacheKey); } let html; if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) { html = window.AiReviewRender.renderMarkdown(raw); } else { html = esc(raw) .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\n/g, "
"); } if (cacheKey) { if (aiChatMdCache.size >= AI_CHAT_MD_CACHE_MAX) { const firstKey = aiChatMdCache.keys().next().value; if (firstKey != null) aiChatMdCache.delete(firstKey); } aiChatMdCache.set(cacheKey, html); } return html; } function scrollAiChatToEnd() { const box = document.getElementById("ai-chat-messages"); if (!box) return; const run = () => { box.scrollTop = box.scrollHeight; const rows = box.querySelectorAll(".ai-msg-row"); const last = rows[rows.length - 1]; if (last && last.scrollIntoView) { try { last.scrollIntoView({ block: "end", behavior: "auto" }); } catch (_) { /* ignore */ } } }; requestAnimationFrame(() => requestAnimationFrame(run)); } function updateAiBotTabs(mode) { const m = normalizeAiBotMode(mode); aiSelectedBotMode = m; document.querySelectorAll(".ai-bot-tab").forEach((btn) => { const on = normalizeAiBotMode(btn.dataset.bot || "trading") === m; btn.classList.toggle("is-active", on); btn.setAttribute("aria-selected", on ? "true" : "false"); }); const newBtn = document.getElementById("btn-ai-chat-new"); if (newBtn) newBtn.classList.toggle("hidden", m === "supervisor"); const histPanel = document.querySelector(".ai-chat-history-panel"); if (histPanel) histPanel.classList.toggle("hidden", m === "supervisor"); const input = document.getElementById("ai-chat-input"); if (input) { if (m === "general") { input.placeholder = "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图"; } else if (m === "supervisor") { input.placeholder = "回应监管提醒,说说为什么又开了一单…"; } else { input.placeholder = "聊聊行情,心态,纪律,执行…;可直接 Ctrl+V 粘贴截图"; } } } function renderAiChatHistory(sessions) { const list = document.getElementById("ai-chat-history-list"); if (!list) return; const items = Array.isArray(sessions) ? sessions : []; if (!items.length) { list.innerHTML = '

暂无历史,发送消息后会出现在这里.

'; return; } list.innerHTML = items .map((s) => { const mode = s.bot_mode === "general" ? "general" : "trading"; const badge = mode === "general" ? "普通" : "交易"; const badgeCls = mode === "general" ? "" : " trading"; const active = s.is_active ? " is-active" : ""; const time = esc((s.updated_at || s.created_at || "").slice(0, 16)); const title = esc(s.title || "新对话"); const preview = esc(s.preview || "(空会话)"); const sid = esc(s.id || ""); return ( `
` + `
` + `${title}` + `${preview}` + `` + `${time}` + `${badge}` + `${Number(s.message_count) || 0} 条` + `` + `
` + `` + `
` ); }) .join(""); } function renderAiChatRow(role, content, extraClass, attachments, rowOpts) { const opts = rowOpts || {}; const botMode = normalizeAiBotMode(opts.botMode || aiSelectedBotMode); const isUser = role === "user"; const isSystem = role === "system"; let label = "主人"; if (isSystem) label = "监管"; else if (!isUser) label = botMode === "general" ? "助手" : botMode === "supervisor" ? "监管AI" : "交易教练"; const rowCls = isUser ? "ai-msg-row-user" : isSystem ? "ai-msg-row-system" : "ai-msg-row-coach"; const bubbleCls = isUser ? "ai-bubble-user" : isSystem ? "ai-bubble-system" : "ai-bubble-assistant"; const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking"); const isError = !isUser && !isSystem && !isThinking && /^(AI 调用失败|AI 生成失败)/.test(String(content || "").trim()); const mdKey = !isUser && !isSystem && !isThinking && opts.cacheKey ? String(opts.cacheKey) : ""; const bubbleInner = isUser || isThinking || isSystem ? esc(content || "") : renderHubMarkdown(content || "", mdKey); const mdCls = !isUser && !isSystem && !isThinking ? " ai-result-md" : ""; const attList = Array.isArray(attachments) ? attachments : []; const attHtml = attList.length ? `
${attList .map((a) => `${esc(a.name || "附件")}`) .join("")}
` : ""; const canCopy = !isThinking && String(content || "").trim(); const copyHtml = canCopy ? `
` : ""; return ( `
` + `${label}` + `${attHtml}` + `
${bubbleInner}
` + `${copyHtml}` + `
` ); } function renderAiChatMessages(session, opts) { const options = opts || {}; const box = document.getElementById("ai-chat-messages"); const title = document.getElementById("ai-chat-title"); if (!box) return; const activeSession = isSupervisorMode() ? aiSupervisorSessionCache || session : session; const msgs = (activeSession && activeSession.messages) || []; const botMode = normalizeAiBotMode((activeSession && activeSession.bot_mode) || aiSelectedBotMode); if (title) { const modeLabel = botMode === "general" ? "普通聊天" : botMode === "supervisor" ? "交易监管" : "交易教练"; const sessionTitle = activeSession && activeSession.title ? String(activeSession.title) : ""; if (isMobileAiLayout()) { title.textContent = botMode === "supervisor" ? sessionTitle || "今日监管" : sessionTitle && sessionTitle !== "新对话" ? sessionTitle : modeLabel; } else { title.textContent = sessionTitle ? `${modeLabel} · ${sessionTitle}` : modeLabel; } } const showPlaceholder = !msgs.length && !options.pendingUser && !options.thinking; if (showPlaceholder) { const hint = botMode === "general" ? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」.可粘贴截图或上传附件." : botMode === "supervisor" ? "今日监管为长会话:手动/中控开平仓与新开仓会自动推送;程序止盈止损会鼓励性提醒.可直接回复继续聊." : "交易教练会结合三户监控数据陪聊;发消息后可点气泡下方「复制」.可粘贴截图或点「附件」上传图片/文档."; box.innerHTML = `

${hint}

`; return; } const sessionId = activeSession && activeSession.id ? String(activeSession.id) : "local"; let html = msgs .map((m, idx) => { const role = m.role === "user" ? "user" : m.role === "system" ? "system" : "assistant"; return renderAiChatRow( role, m.content || "", m.level === "warn" ? "ai-bubble-warn" : null, m.attachments, { botMode, msgIdx: idx, cacheKey: sessionId + ":" + idx } ); }) .join(""); if (options.pendingUser) { html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments); } if (options.thinking) { html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking"); } box.innerHTML = html; scrollAiChatToEnd(); } function setAiChatBusy(busy) { aiChatLoading = !!busy; const btn = document.getElementById("btn-ai-chat-send"); const input = document.getElementById("ai-chat-input"); if (btn) btn.disabled = busy; if (input) input.disabled = busy; document.querySelectorAll(".ai-chat-pending-del").forEach((el) => { el.disabled = busy; }); } async function loadAiSupervisorSession() { const r = await apiFetch("/api/ai/supervisor/session"); const j = await r.json(); aiSupervisorSessionCache = j.session || null; if (isSupervisorMode()) { renderAiChatMessages(aiSupervisorSessionCache); } updateAiBotTabs("supervisor"); return j; } async function switchToSupervisorMode() { updateAiBotTabs("supervisor"); if (isMobileAiLayout()) { localStorage.setItem(AI_MOBILE_TAB_KEY, "supervisor"); applyAiMobileTab("supervisor"); } try { await loadAiSupervisorSession(); connectSupervisorStream(); scrollAiChatToEnd(); } catch (e) { showToast(String(e), true); } } function closeSupervisorStream() { if (supervisorEventSource) { supervisorEventSource.close(); supervisorEventSource = null; } if (supervisorReconnectTimer) { clearTimeout(supervisorReconnectTimer); supervisorReconnectTimer = null; } } function connectSupervisorStream() { closeSupervisorStream(); if (currentPage() !== "ai" || !isSupervisorMode()) return; supervisorEventSource = new EventSource("/api/ai/supervisor/stream"); supervisorEventSource.addEventListener("supervisor", (ev) => { try { const st = JSON.parse(ev.data || "{}"); const ver = Number(st.supervisor_version) || 0; if (ver !== localSupervisorVersion) { localSupervisorVersion = ver; void loadAiSupervisorSession(); } } catch (_) {} }); supervisorEventSource.onerror = () => { closeSupervisorStream(); if (supervisorReconnectTimer) clearTimeout(supervisorReconnectTimer); supervisorReconnectTimer = setTimeout(() => { if (currentPage() === "ai" && isSupervisorMode()) connectSupervisorStream(); }, 8000); }; } async function loadAiChatSession() { const r = await apiFetch("/api/ai/chat/session"); const j = await r.json(); aiChatSessionCache = j.session || null; aiChatSessionsCache = j.sessions || []; renderAiChatMessages(aiChatSessionCache); renderAiChatHistory(aiChatSessionsCache); updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode); } async function switchAiChatSession(sessionId) { if (!sessionId || aiChatLoading) return; try { const r = await apiFetch("/api/ai/chat/switch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId }), }); const j = await r.json(); if (!r.ok) throw new Error(j.detail || j.msg || "切换失败"); aiChatSessionCache = j.session || null; aiChatSessionsCache = j.sessions || []; renderAiChatMessages(aiChatSessionCache); renderAiChatHistory(aiChatSessionsCache); const mode = (aiChatSessionCache && aiChatSessionCache.bot_mode) === "general" ? "general" : "trading"; updateAiBotTabs(mode); if (isMobileAiLayout()) { localStorage.setItem(AI_MOBILE_TAB_KEY, mode); applyAiMobileTab(mode); } scrollAiChatToEnd(); } catch (e) { showToast(String(e), true); } } async function deleteAiChatSession(sessionId) { if (!sessionId) return; if (!confirm("确定删除这条聊天历史?")) return; try { const r = await apiFetch(`/api/ai/chat/session/${encodeURIComponent(sessionId)}`, { method: "DELETE", }); const j = await r.json(); if (!r.ok) throw new Error(j.detail || j.msg || "删除失败"); aiChatSessionCache = j.session || null; aiChatSessionsCache = j.sessions || []; renderAiChatMessages(aiChatSessionCache); renderAiChatHistory(aiChatSessionsCache); updateAiBotTabs( (aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode || "trading" ); showToast("已删除"); } catch (e) { showToast(String(e), true); } } const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; let archiveQuoteAiPending = false; async function consumeArchiveQuoteAiPending() { if (archiveQuoteAiPending || aiChatLoading) return; let raw = ""; try { raw = sessionStorage.getItem(ARCHIVE_QUOTE_AI_KEY) || ""; } catch (_) { return; } if (!raw) return; sessionStorage.removeItem(ARCHIVE_QUOTE_AI_KEY); let payload; try { payload = JSON.parse(raw); } catch (_) { return; } const content = String((payload && payload.content) || "").trim(); const quoteDate = String((payload && payload.quote_date) || "").trim(); if (!content) return; const input = document.getElementById("ai-chat-input"); if (input) input.value = content; updateAiBotTabs("trading"); if (isMobileAiLayout()) { localStorage.setItem(AI_MOBILE_TAB_KEY, "trading"); applyAiMobileTab("trading"); } archiveQuoteAiPending = true; setAiChatBusy(true); renderAiChatMessages(aiChatSessionCache, { pendingUser: content, thinking: true, }); try { const r = await apiFetch("/api/ai/chat/archive-quote", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quote_date: quoteDate, content }), }); const j = await r.json(); if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); aiChatSessionCache = j.session || null; aiChatSessionsCache = j.sessions || aiChatSessionsCache; renderAiChatMessages(aiChatSessionCache); renderAiChatHistory(aiChatSessionsCache); if (input) input.value = ""; showToast("复盘语录已发送给交易教练"); } catch (e) { showToast(String(e), true); if (input) input.value = content; try { await loadAiChatSession(); } catch (_) { renderAiChatMessages(aiChatSessionCache); } } finally { archiveQuoteAiPending = false; setAiChatBusy(false); } } async function loadAiPage() { applyAiMobileTab(); const params = new URLSearchParams(window.location.search || ""); const modeParam = (params.get("mode") || "").trim().toLowerCase(); if (modeParam === "supervisor") { await switchToSupervisorMode(); } else { closeSupervisorStream(); await loadAiChatSession(); await consumeArchiveQuoteAiPending(); } const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); if (isMobileAiLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) { const input = document.getElementById("ai-chat-input"); if (input && !aiChatLoading) { setTimeout(() => input.focus(), 80); } } } async function newAiChat(botMode) { const mode = normalizeAiBotMode(botMode); if (mode !== "supervisor") closeSupervisorStream(); try { const r = await apiFetch("/api/ai/chat/new", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bot_mode: mode }), }); const j = await r.json(); aiChatSessionCache = j.session || null; aiChatSessionsCache = j.sessions || []; renderAiChatMessages(aiChatSessionCache); renderAiChatHistory(aiChatSessionsCache); updateAiBotTabs(mode); if (isMobileAiLayout()) { localStorage.setItem(AI_MOBILE_TAB_KEY, mode); applyAiMobileTab(mode); } showToast( mode === "general" ? "已开始普通聊天" : mode === "supervisor" ? "已打开今日监管" : "已开始交易教练对话" ); } catch (e) { showToast(String(e), true); } } async function sendAiChat(ev) { if (ev) ev.preventDefault(); if (aiChatLoading) return; const input = document.getElementById("ai-chat-input"); const text = (input && input.value || "").trim(); if (isSupervisorMode()) { if (!text) return; const savedText = text; if (input) input.value = ""; setAiChatBusy(true); renderAiChatMessages(aiSupervisorSessionCache, { pendingUser: text, thinking: true, }); try { const r = await apiFetch("/api/ai/supervisor/chat/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }), }); const j = await r.json(); if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); aiSupervisorSessionCache = j.session || null; renderAiChatMessages(aiSupervisorSessionCache); } catch (e) { showToast(String(e), true); if (input && savedText) input.value = savedText; try { await loadAiSupervisorSession(); } catch (_) { renderAiChatMessages(aiSupervisorSessionCache); } } finally { setAiChatBusy(false); } return; } const files = aiChatPendingFiles.slice(); if (!text && !files.length) return; const pendingAttachments = files.map((f) => ({ name: f.name, kind: aiChatFileKind(f), })); const savedText = text; if (input) input.value = ""; setAiChatBusy(true); renderAiChatMessages(aiChatSessionCache, { pendingUser: text || (files.length ? `(上传 ${files.length} 个附件)` : ""), pendingAttachments, thinking: true, }); try { const fd = new FormData(); fd.append("message", text); files.forEach((f) => fd.append("files", f, f.name)); const r = await apiFetch("/api/ai/chat/send", { method: "POST", body: fd }); const j = await r.json(); if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); aiChatSessionCache = j.session || null; aiChatSessionsCache = j.sessions || aiChatSessionsCache; renderAiChatMessages(aiChatSessionCache); renderAiChatHistory(aiChatSessionsCache); clearAiChatPendingFiles(); if (j.attachment_warnings && j.attachment_warnings.length) { showToast(j.attachment_warnings.join(";"), true); } } catch (e) { showToast(String(e), true); if (input && savedText) input.value = savedText; try { await loadAiChatSession(); } catch (_) { renderAiChatMessages(aiChatSessionCache); } } finally { setAiChatBusy(false); } } const aiChatFiles = document.getElementById("ai-chat-files"); if (aiChatFiles) { aiChatFiles.addEventListener("change", () => { const picked = aiChatFiles.files ? Array.from(aiChatFiles.files) : []; addAiChatPendingFiles(picked); aiChatFiles.value = ""; }); } const aiChatInput = document.getElementById("ai-chat-input"); if (aiChatInput) { aiChatInput.addEventListener("paste", handleAiChatPaste); } const aiChatPending = document.getElementById("ai-chat-pending"); if (aiChatPending) { aiChatPending.addEventListener("click", (ev) => { const btn = ev.target.closest("[data-pending-del]"); if (!btn || aiChatLoading) return; ev.preventDefault(); const idx = Number(btn.getAttribute("data-pending-del")); if (!Number.isNaN(idx)) removeAiChatPendingFile(idx); }); } const aiChatNewBtn = document.getElementById("btn-ai-chat-new"); if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode); const aiChatForm = document.getElementById("ai-chat-form"); if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat); function initAiChatInteractions() { const hist = document.getElementById("ai-chat-history-list"); if (hist && !hist._aiBound) { hist._aiBound = true; hist.addEventListener("click", (ev) => { const delBtn = ev.target.closest(".ai-chat-history-del"); if (delBtn) { ev.stopPropagation(); const sid = delBtn.getAttribute("data-delete-session"); if (sid) deleteAiChatSession(sid); return; } const item = ev.target.closest(".ai-chat-history-item"); if (!item) return; const sid = item.getAttribute("data-session-id"); if (sid) switchAiChatSession(sid); }); } const box = document.getElementById("ai-chat-messages"); if (box && !box._aiCopyBound) { box._aiCopyBound = true; box.addEventListener("click", async (ev) => { const btn = ev.target.closest(".ai-msg-copy-btn"); if (!btn) return; const idx = Number(btn.getAttribute("data-msg-idx")); const msgs = (aiChatSessionCache && aiChatSessionCache.messages) || []; const text = msgs[idx] && msgs[idx].content ? String(msgs[idx].content) : ""; if (!text) return; try { await navigator.clipboard.writeText(text); showToast("已复制"); } catch (_) { showToast("复制失败", true); } }); } document.querySelectorAll(".ai-bot-tab").forEach((btn) => { if (btn._aiBotBound) return; btn._aiBotBound = true; btn.addEventListener("click", () => { const mode = normalizeAiBotMode(btn.getAttribute("data-bot") || "trading"); if (mode === "supervisor") { void switchToSupervisorMode(); return; } closeSupervisorStream(); newAiChat(mode); }); }); } initAiChatInteractions(); initTpslModal(); initInstanceFrame(); initFullscreen(); initMobileLayout(); if (globalThis.HubTheme && typeof HubTheme.initToggleUI === "function") { HubTheme.initToggleUI(); } function initShellNav() { bindHubSpaNavLinks(".top-nav a[href^='/']"); bindHubSpaNavLinks(".hub-mobile-tabbar a.hub-m-tab[href^='/']"); bindHubSpaNavLinks(".hub-mobile-more-nav a[href^='/']"); window.addEventListener("popstate", setActiveNav); } window.hubNavigateTo = function hubNavigateTo(path) { const href = String(path || "/").split("?")[0] || "/"; if (href === window.location.pathname) { setActiveNav(); return; } history.pushState({}, "", href); setActiveNav(); }; window.hubOpenMonitorExpand = function hubOpenMonitorExpand(exId) { const id = String(exId || "").trim(); if (!id) return; expandedExchangeId = id; sessionStorage.setItem("hub_expanded_ex", id); if (currentPage() !== "monitor") { history.pushState({}, "", "/monitor"); setActiveNav(); } if (lastMonitorRows.length) { openExchangeFullscreen(id); } else { void fetchMonitorBoardSnapshot({ showLoading: true }); } }; initAuth().then((ok) => { if (!ok) return; initShellNav(); loadSettings() .then((data) => { syncDisplayPrefsUI(data); }) .catch(() => {}) .finally(() => { setActiveNav(); }); }); if (window.AccountRiskBadge) AccountRiskBadge.startTicker(); })();