(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);
}
/** 监控卡片列数:3 个一行;4 个 2×2;5/6 个两行(每行最多 3) */
function syncMonitorGridColumns(gridEl, count) {
if (!gridEl) return;
let cols = 3;
if (count <= 1) cols = 1;
else if (count === 2) cols = 2;
else if (count === 3) cols = 3;
else if (count === 4) cols = 2;
else cols = 3;
gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
}
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("") || '
无已启用账户
';
syncMonitorGridColumns(box, rows.length);
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id);
});
box.querySelectorAll(".btn-close-pos").forEach((btn) => {
btn.onclick = () =>
closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side);
});
} 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) => {
const symAttr = esc(x.symbol || "").replace(/"/g, """);
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """);
return `
| ${esc(x.symbol)} |
${esc(x.side)} |
${fmt(x.contracts, 4)} |
${fmt(x.unrealized_pnl, 4)} |
|
`;
})
.join("");
inner += ``;
} 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 ((row.capabilities || []).includes("trend") && 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 `
${openFlask}
${review}
${inner}
`;
}
async function closeOnePosition(exchangeId, symbol, side) {
const label = `${symbol} · ${side}`;
if (!confirm(`确认对该账户市价平仓:${label}?`)) return;
try {
const r = await apiFetch(
"/api/close/" + encodeURIComponent(exchangeId) + "/position",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symbol, side }),
}
);
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
const msg =
(ok && pl.closed
? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}`
: pl.error) || JSON.stringify(j, null, 2);
showToast(msg, !ok);
loadMonitorBoard();
} 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);
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(", ") + "(改 .env 后须重启 hub)");
} else {
parts.push("HUB_DISABLED_IDS 未强制关闭任何账户");
}
el.textContent = parts.join(" · ");
} catch (_) {}
}
function renderSettingsList(data) {
const list = document.getElementById("settings-list");
if (!list) return;
list.innerHTML = (data.exchanges || [])
.map((ex, idx) => renderSettingsCard(ex, idx))
.join("");
list.querySelectorAll(".btn-del-ex").forEach((btn) => {
btn.onclick = () => {
const i = Number(btn.dataset.idx);
data.exchanges.splice(i, 1);
settingsCache = data;
renderSettingsList(data);
};
});
}
function loadSettingsUI() {
loadSettingsMetaLine();
loadSettings().then((data) => {
renderSettingsList(data);
});
}
function renderSettingsCard(ex, idx) {
const caps = ex.capabilities || [];
const envOff = ex.env_disabled
? '环境变量强制关'
: "";
return ``;
}
function collectSettingsFromUI() {
const rows = [...document.querySelectorAll("#settings-list .settings-card")];
return {
version: 1,
exchanges: rows.map((card) => {
const caps = [];
if (card.querySelector(".cap-key").checked) caps.push("key");
if (card.querySelector(".cap-trend").checked) caps.push("trend");
const id = card.querySelector(".ex-id").value.trim();
const stableKey = (card.dataset.key || id).trim();
return {
id: id,
key: stableKey,
name: card.querySelector(".ex-name").value.trim(),
flask_url: card.querySelector(".ex-flask").value.trim(),
agent_url: card.querySelector(".ex-agent").value.trim(),
review_url: card.querySelector(".ex-review").value.trim(),
enabled: card.querySelector(".ex-enabled").checked,
capabilities: caps,
};
}),
};
}
async function saveSettings() {
const body = collectSettingsFromUI();
try {
const r = await apiFetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const j = await r.json();
if (j.ok) {
showToast("设置已保存(已写入 hub_settings.json)");
if (j.settings) {
settingsCache = j.settings;
renderSettingsList(j.settings);
loadSettingsMetaLine();
} else {
await loadSettingsUI();
}
} else showToast("保存失败", true);
} catch (e) {
showToast(String(e), true);
}
}
document.getElementById("btn-logout").onclick = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch (_) {}
location.href = "/login";
};
document.getElementById("btn-monitor-refresh").onclick = 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;
renderSettingsList(data);
showToast("已添加一行,请填写 URL 后点「保存设置」");
};
initAuth().then((ok) => {
if (!ok) return;
loadSettings().catch(() => {});
setActiveNav();
window.addEventListener("popstate", setActiveNav);
});
})();