(function () { const toast = document.getElementById("toast"); let settingsCache = null; let monitorTimer = null; let authState = { required: false, logged_in: true }; 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; } async function initAuth() { try { const r = await fetch("/api/auth/status"); authState = await r.json(); const btn = document.getElementById("btn-logout"); if (btn) btn.style.display = authState.required ? "" : "none"; if (authState.required && !authState.logged_in) { location.href = "/login?next=" + encodeURIComponent(location.pathname + location.search); return false; } return true; } catch (_) { return true; } } function showToast(msg, isErr) { toast.textContent = msg; toast.style.borderColor = isErr ? "var(--red)" : "var(--border)"; toast.classList.add("show"); clearTimeout(showToast._t); showToast._t = setTimeout(() => toast.classList.remove("show"), 7000); } function esc(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmt(n, d) { if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); } 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("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 === "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 apiFetch("/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 apiFetch("/api/monitor/board"); const data = await r.json(); const rows = data.rows || []; 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); } document.getElementById("monitor-updated").textContent = "UPD " + (data.updated_at || "").replace("T", " "); const parts = 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 online = row.http_ok && agOk; const cardCls = online ? "card-online" : "card-offline"; const dotCls = online ? "ok" : "bad"; const review = row.review_url ? `复盘` : ""; const flaskOpen = row.flask_url_browser || row.flask_url; const openFlask = flaskOpen ? `实例` : ""; return `
${esc(row.name)}
${esc(flaskOpen || "")}
${openFlask} ${review}
${inner}
`; } 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); loadMonitorBoard(); } 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); loadMonitorBoard(); } catch (e) { showToast(String(e), true); } } async function loadSettingsMetaLine() { try { const r = await apiFetch("/api/settings/meta"); const m = await r.json(); const el = document.getElementById("settings-meta-line"); if (!el) return; const parts = []; if (m.password_required) parts.push("已启用用户名+密码登录"); else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置 HUB_USERNAME + HUB_PASSWORD)"); if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin); else parts.push("未设 HUB_PUBLIC_ORIGIN(复盘链接仅本机可开)"); if ((m.env_disabled_ids || []).length) parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ")); 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-key").checked) caps.push("key"); if (card.querySelector(".cap-trend").checked) caps.push("trend"); const id = card.querySelector(".ex-id").value.trim(); const stableKey = (card.dataset.key || id).trim(); return { id: id, key: stableKey, name: card.querySelector(".ex-name").value.trim(), flask_url: card.querySelector(".ex-flask").value.trim(), agent_url: card.querySelector(".ex-agent").value.trim(), review_url: card.querySelector(".ex-review").value.trim(), enabled: card.querySelector(".ex-enabled").checked, capabilities: caps, }; }), }; } async function saveSettings() { const body = collectSettingsFromUI(); try { const r = await apiFetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const j = await r.json(); if (j.ok) { showToast("设置已保存(已写入 hub_settings.json)"); await loadSettingsUI(); } else showToast("保存失败", true); } catch (e) { showToast(String(e), true); } } document.getElementById("btn-logout").onclick = async () => { try { await fetch("/api/auth/logout", { method: "POST" }); } catch (_) {} location.href = "/login"; }; document.getElementById("btn-monitor-refresh").onclick = loadMonitorBoard; document.getElementById("auto-monitor").onchange = startMonitorPoll; document.getElementById("btn-close-all").onclick = closeAll; document.getElementById("btn-settings-save").onclick = saveSettings; document.getElementById("btn-settings-reload").onclick = loadSettingsUI; document.getElementById("btn-settings-add").onclick = () => { const data = settingsCache || { exchanges: [] }; const nid = String(Date.now() % 100000); data.exchanges.push({ id: nid, key: "custom_" + nid, name: "新交易所", flask_url: "http://127.0.0.1:5000", agent_url: "http://127.0.0.1:15200", review_url: "", enabled: false, capabilities: ["key"], }); settingsCache = data; loadSettingsUI(); }; initAuth().then((ok) => { if (!ok) return; setActiveNav(); window.addEventListener("popstate", setActiveNav); }); })();