(function () { const toast = document.getElementById("toast"); let settingsCache = null; let tradeMeta = {}; let trendPreviewId = null; let monitorTimer = null; function showToast(msg, isErr) { toast.textContent = msg; toast.style.borderColor = isErr ? "var(--red)" : "var(--border)"; toast.classList.add("show"); clearTimeout(showToast._t); showToast._t = setTimeout(() => toast.classList.remove("show"), 7000); } function esc(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmt(n, d) { if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); } function pnlCls(v) { const n = Number(v); if (!Number.isFinite(n) || n === 0) return ""; return n > 0 ? "pnl-pos" : "pnl-neg"; } function currentPage() { const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; if (p.includes("trade")) return "trade"; if (p.includes("settings")) return "settings"; return "monitor"; } function setActiveNav() { const page = currentPage(); document.querySelectorAll(".top-nav a").forEach((a) => { a.classList.toggle("active", a.getAttribute("href").includes(page)); }); document.querySelectorAll(".page").forEach((el) => { el.classList.toggle("hidden", !el.id.includes(page)); }); if (page === "monitor") startMonitorPoll(); else stopMonitorPoll(); if (page === "trade") initTradePage(); if (page === "settings") loadSettingsUI(); } function stopMonitorPoll() { clearInterval(monitorTimer); monitorTimer = null; } function startMonitorPoll() { stopMonitorPoll(); loadMonitorBoard(); if (document.getElementById("auto-monitor").checked) { monitorTimer = setInterval(loadMonitorBoard, 5000); } } async function loadSettings() { const r = await fetch("/api/settings"); settingsCache = await r.json(); return settingsCache; } function enabledAccounts() { return (settingsCache?.exchanges || []).filter((x) => x.enabled); } async function loadMonitorBoard() { const box = document.getElementById("monitor-grid"); try { const r = await fetch("/api/monitor/board"); const data = await r.json(); document.getElementById("monitor-updated").textContent = "更新于 " + (data.updated_at || "").replace("T", " "); const parts = (data.rows || []).map(renderMonitorCard); box.innerHTML = parts.join("") || '
无已启用账户
'; box.querySelectorAll(".btn-close-ex").forEach((btn) => { btn.onclick = () => closeOne(btn.dataset.id); }); } catch (e) { box.innerHTML = `
${esc(e)}
`; } } function renderMonitorCard(row) { const ag = row.agent || {}; const pos = Array.isArray(ag.positions) ? ag.positions : []; const hm = row.hub_monitor || {}; const flaskOk = row.flask_ok !== false && hm.ok !== false; const keys = flaskOk ? hm.keys || [] : []; const orders = flaskOk ? hm.orders || [] : []; const trends = flaskOk ? hm.trends || [] : []; const 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 = `
余额
${fmt(ag.balance_usdt, 2)} U
浮盈合计
${fmt(ag.total_unrealized_pnl, 4)}
`; inner += `
交易所持仓
`; if (pos.length) { const posRows = pos .map( (x) => `${esc(x.symbol)}${esc(x.side)}${fmt(x.contracts, 4)}${fmt(x.unrealized_pnl, 4)}` ) .join(""); inner += `${posRows}
合约方向张数浮盈
`; } else { inner += `
无持仓
`; } if (orders.length) { inner += `
机器人单 · ${orders.length}
`; orders.forEach((o) => { inner += `
${esc(o.symbol)} · ${esc(o.direction)} · 触发 ${o.trigger_price}
`; }); } if ((row.capabilities || []).includes("key")) { inner += `
关键位
`; if (!flaskOk) { const fe = row.flask_error || hm.msg || hm.error || ""; const short = fe || (hm.status === 404 ? "HTTP 404:请重启各 crypto_* Flask" : "策略 Flask 未连通"); inner += `
${esc(short)}
`; } else if (!keys.length) { inner += `
当前无记录
`; } else { keys.slice(0, 8).forEach((k) => { const kp = kmap[k.id] || kmap[String(k.id)] || {}; const mt = k.monitor_type || k.type || ""; let line = `${esc(k.symbol)} · ${esc(mt)} · ${k.upper} / ${k.lower}`; if (kp.price_display != null || kp.price != null) { line += ` · ${esc(kp.price_display != null ? kp.price_display : kp.price)}`; } line += ` · ${esc(kp.gate_summary || "-")}`; inner += `
${line}
`; }); } } if (trends.length) { inner += `
趋势计划 · ${trends.length}
`; trends.forEach((t) => { inner += `
#${t.id} ${esc(t.symbol)} ${t.direction} · SL ${t.stop_loss} · TP ${t.take_profit}
`; }); } } const review = row.review_url ? `复盘` : ""; return `
${esc(row.name)}
${esc(row.flask_url_browser || row.flask_url || "")}
${review}
${inner}
`; } async function closeOne(id) { if (!confirm("确认对该账户市价全平?")) return; try { const r = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" }); const j = await r.json(); showToast(JSON.stringify(j, null, 2), !r.ok); loadMonitorBoard(); } catch (e) { showToast(String(e), true); } } async function closeAll() { const n = enabledAccounts().length; if (!confirm(`对 ${n} 个已启用账户执行紧急全平?`)) return; try { const r = await fetch("/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); loadMonitorBoard(); } catch (e) { showToast(String(e), true); } } function initTradePage() { loadSettings().then(() => { const sel = document.getElementById("trade-account"); const prev = sel.value; sel.innerHTML = enabledAccounts() .map( (x) => `` ) .join(""); if (prev) sel.value = prev; syncTradeTabs(); loadTradeMeta(); }); } function accountCaps() { const id = document.getElementById("trade-account").value; const ex = (settingsCache?.exchanges || []).find((x) => String(x.id) === String(id)); return ex?.capabilities || []; } function syncTradeTabs() { const caps = accountCaps(); document.querySelectorAll(".tabs button").forEach((btn) => { const tab = btn.dataset.tab; let ok = false; if (tab === "order") ok = caps.includes("order"); if (tab === "key") ok = caps.includes("key"); if (tab === "trend") ok = caps.includes("trend"); btn.disabled = !ok; btn.style.opacity = ok ? "1" : "0.4"; }); let active = document.querySelector(".tabs button.active"); if (active && active.disabled) { const first = [...document.querySelectorAll(".tabs button")].find((b) => !b.disabled); if (first) switchTradeTab(first.dataset.tab); } ["order", "key", "trend"].forEach((t) => { document.getElementById("panel-" + t).classList.toggle( "hidden", !document.querySelector(`.tabs button[data-tab="${t}"]`).classList.contains("active") ); }); } function switchTradeTab(tab) { document.querySelectorAll(".tabs button").forEach((b) => { b.classList.toggle("active", b.dataset.tab === tab); }); ["order", "key", "trend"].forEach((t) => { document.getElementById("panel-" + t).classList.toggle("hidden", t !== tab); }); trendPreviewId = null; document.getElementById("trend-preview-box").style.display = "none"; } async function loadTradeMeta() { const id = document.getElementById("trade-account").value; if (!id) return; try { const r = await fetch("/api/trade/meta/" + encodeURIComponent(id)); const data = await r.json(); tradeMeta = data.meta?.meta || data.meta || {}; const el = document.getElementById("trade-meta"); let txt = ""; if (tradeMeta.key_gate_rule_text) txt = tradeMeta.key_gate_rule_text; else if (tradeMeta.trend_pullback_preview_ttl) { txt = `预览 ${tradeMeta.trend_pullback_preview_ttl}s · 补仓 ${tradeMeta.trend_pullback_dca_legs} 档 · 余额偏差 ≤${tradeMeta.trend_preview_max_drift_pct}%`; } el.textContent = txt; el.style.display = txt ? "block" : "none"; } catch (e) { const el = document.getElementById("trade-meta"); el.textContent = ""; el.style.display = "none"; } } async function submitForm(path, formEl) { const id = document.getElementById("trade-account").value; const fd = new FormData(formEl); try { const r = await fetch(path + encodeURIComponent(id), { method: "POST", body: fd }); const j = await r.json(); const res = j.result || {}; const msgs = (res.messages || []).join("\n") || JSON.stringify(res, null, 2); showToast(msgs, !res.ok); if (res.ok && res.preview) { showTrendPreview(res); } loadTradeMeta(); } catch (e) { showToast(String(e), true); } } function showTrendPreview(res) { trendPreviewId = res.preview_id; const p = res.preview || {}; const box = document.getElementById("trend-preview-box"); const levels = (p.grid_levels || []) .map((r) => `${r.i}${r.price}${r.contracts}`) .join(""); box.innerHTML = `
预览 #${esc(p.id || trendPreviewId)} · ${p.expires_in_sec ?? "?"}s
${esc(p.symbol)} ${esc(p.direction)} · ${p.leverage}x · 快照 ${fmt(p.snapshot_available_usdt, 2)} U
${levels}
#补仓价张数
`; box.style.display = "block"; document.getElementById("btn-trend-exec").onclick = executeTrend; } async function executeTrend() { if (!trendPreviewId) { showToast("请先生成预览", true); return; } if (!confirm("确认按预览参数实盘下单?")) return; const id = document.getElementById("trade-account").value; const fd = new FormData(); fd.set("preview_id", trendPreviewId); try { const r = await fetch("/api/trade/trend/execute/" + encodeURIComponent(id), { method: "POST", body: fd, }); const j = await r.json(); const res = j.result || {}; showToast((res.messages || []).join("\n") || JSON.stringify(res), !res.ok); document.getElementById("trend-preview-box").style.display = "none"; trendPreviewId = null; } catch (e) { showToast(String(e), true); } } async function loadSettingsMetaLine() { try { const r = await fetch("/api/settings/meta"); const m = await r.json(); const el = document.getElementById("settings-meta-line"); if (!el) return; const parts = []; 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(", ")); el.textContent = parts.join(" · "); } catch (_) {} } function loadSettingsUI() { loadSettingsMetaLine(); loadSettings().then((data) => { const list = document.getElementById("settings-list"); document.getElementById("settings-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; loadSettingsUI(); }; }); }); } function renderSettingsCard(ex, idx) { const caps = ex.capabilities || []; const envOff = ex.env_disabled ? '环境变量强制关' : ""; return `
${envOff}
`; } function collectSettingsFromUI() { const rows = [...document.querySelectorAll("#settings-list .settings-card")]; return { version: 1, exchanges: rows.map((card) => { const caps = []; if (card.querySelector(".cap-order").checked) caps.push("order"); if (card.querySelector(".cap-key").checked) caps.push("key"); if (card.querySelector(".cap-trend").checked) caps.push("trend"); const id = card.querySelector(".ex-id").value.trim(); const stableKey = (card.dataset.key || id).trim(); return { id: id, key: stableKey, name: card.querySelector(".ex-name").value.trim(), flask_url: card.querySelector(".ex-flask").value.trim(), agent_url: card.querySelector(".ex-agent").value.trim(), review_url: card.querySelector(".ex-review").value.trim(), enabled: card.querySelector(".ex-enabled").checked, capabilities: caps, }; }), }; } async function saveSettings() { const body = collectSettingsFromUI(); try { const r = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const j = await r.json(); if (j.ok) { showToast("设置已保存(已写入 hub_settings.json)"); await loadSettingsUI(); } else showToast("保存失败", true); } catch (e) { showToast(String(e), true); } } document.getElementById("btn-monitor-refresh").onclick = loadMonitorBoard; document.getElementById("auto-monitor").onchange = startMonitorPoll; document.getElementById("btn-close-all").onclick = closeAll; document.getElementById("trade-account").onchange = () => { syncTradeTabs(); loadTradeMeta(); }; document.querySelectorAll(".tabs button").forEach((btn) => { btn.onclick = () => { if (!btn.disabled) switchTradeTab(btn.dataset.tab); }; }); document.getElementById("form-order").onsubmit = (e) => { e.preventDefault(); submitForm("/api/trade/order/", e.target); }; document.getElementById("form-key").onsubmit = (e) => { e.preventDefault(); submitForm("/api/trade/key/", e.target); }; document.getElementById("form-trend").onsubmit = (e) => { e.preventDefault(); submitForm("/api/trade/trend/preview/", e.target); }; document.getElementById("order-sltp-mode").onchange = function () { const pct = this.value === "pct"; const slField = document.getElementById("order-sl").closest(".field"); const tpField = document.getElementById("order-tp").closest(".field"); if (slField) slField.style.display = pct ? "none" : ""; if (tpField) tpField.style.display = pct ? "none" : ""; document.getElementById("wrap-sl-pct").style.display = pct ? "" : "none"; document.getElementById("wrap-tp-pct").style.display = pct ? "" : "none"; }; document.getElementById("key-sl-tp-mode").onchange = function () { const manual = this.value === "trend_manual"; document.getElementById("wrap-key-manual-tp").style.display = manual ? "" : "none"; }; document.getElementById("trend-direction").onchange = function () { const lbl = document.getElementById("trend-add-label"); if (lbl) lbl.textContent = this.value === "short" ? "补仓下沿价" : "补仓上沿价"; }; document.getElementById("btn-settings-save").onclick = saveSettings; document.getElementById("btn-settings-reload").onclick = loadSettingsUI; document.getElementById("btn-settings-add").onclick = () => { const data = settingsCache || { exchanges: [] }; const nid = String(Date.now() % 100000); data.exchanges.push({ id: nid, key: "custom_" + nid, name: "新交易所", flask_url: "http://127.0.0.1:5000", agent_url: "http://127.0.0.1:15200", review_url: "", enabled: false, capabilities: ["order"], }); settingsCache = data; loadSettingsUI(); }; setActiveNav(); window.addEventListener("popstate", setActiveNav); })();