Files
crypto_monitor/manual_trading_hub/static/app.js
T
2026-05-22 11:38:20 +08:00

348 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const toast = document.getElementById("toast");
let settingsCache = 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 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("") || '<div class="err">无已启用账户</div>';
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id);
});
} catch (e) {
box.innerHTML = `<div class="err">${esc(e)}</div>`;
}
}
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 = `<div class="err">${esc(row.error || "子代理不可用")}</div>`;
} else if (!agOk) {
inner = `<div class="err">${esc(agErr || "子代理返回失败")}</div>`;
inner += `<div class="empty-hint">请检查 PM2 子代理与 <code>${esc(row.agent_url || "")}/status</code></div>`;
} else {
inner = `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 4)}</div></div>
</div>`;
inner += `<div class="section-title">交易所持仓</div>`;
if (pos.length) {
const posRows = pos
.map(
(x) =>
`<tr><td>${esc(x.symbol)}</td><td>${esc(x.side)}</td><td>${fmt(x.contracts, 4)}</td><td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 4)}</td></tr>`
)
.join("");
inner += `<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th></tr></thead><tbody>${posRows}</tbody></table>`;
} else {
inner += `<div class="empty-hint">无持仓</div>`;
}
if (orders.length) {
inner += `<div class="section-title">机器人单 · ${orders.length}</div>`;
orders.forEach((o) => {
inner += `<div class="list-line">${esc(o.symbol)} · ${esc(o.direction)} · 触发 ${o.trigger_price}</div>`;
});
}
if ((row.capabilities || []).includes("key")) {
inner += `<div class="section-title">关键位</div>`;
if (!flaskOk) {
const fe = row.flask_error || hm.msg || hm.error || "";
const short =
fe ||
(hm.status === 404
? "HTTP 404:请重启各 crypto_* Flask"
: "策略 Flask 未连通");
inner += `<div class="err">${esc(short)}</div>`;
} else if (!keys.length) {
inner += `<div class="empty-hint">当前无记录</div>`;
} 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 += `<div class="list-line">${line}</div>`;
});
}
}
if (trends.length) {
inner += `<div class="section-title">趋势计划 · ${trends.length}</div>`;
trends.forEach((t) => {
inner += `<div class="list-line">#${t.id} ${esc(t.symbol)} ${t.direction} · SL ${t.stop_loss} · TP ${t.take_profit}</div>`;
});
}
}
const review = row.review_url
? `<a class="btn-link" href="${esc(row.review_url)}" target="_blank" rel="noopener">复盘</a>`
: "";
const flaskOpen = row.flask_url_browser || row.flask_url;
const openFlask = flaskOpen
? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">实例</a>`
: "";
return `<div class="card">
<div class="card-head">
<div>
<div class="card-title">${esc(row.name)}</div>
<div class="card-sub">${esc(flaskOpen || "")}</div>
</div>
<div class="card-actions">
${openFlask}
${review}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div>
</div>
<div class="card-body">${inner}</div>
</div>`;
}
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);
}
}
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
? '<span class="badge">环境变量强制关</span>'
: "";
return `<div class="settings-card" data-idx="${idx}" data-key="${esc(ex.key || ex.id || "")}">
<div class="settings-card-head">
<label class="chk-label"><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/> 启用</label>
${envOff}
<input class="ex-name" value="${esc(ex.name || "")}" placeholder="显示名称" />
</div>
<div class="settings-grid">
<div class="field"><label>Flask URL</label><input class="ex-flask" value="${esc(ex.flask_url || "")}" /></div>
<div class="field"><label>Agent URL</label><input class="ex-agent" value="${esc(ex.agent_url || "")}" /></div>
<div class="field field-wide"><label>复盘链接(可空)</label><input class="ex-review" value="${esc(ex.review_url || "")}" placeholder="留空则自动生成 /records" /></div>
</div>
<div class="cap-chips">
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
<label><input type="checkbox" class="cap-trend" ${caps.includes("trend") ? "checked" : ""}/> 监控趋势计划</label>
</div>
<div class="settings-card-foot">
<div class="field"><label>id</label><input class="ex-id" value="${esc(ex.id || "")}" /></div>
<button type="button" class="danger btn-del-ex" data-idx="${idx}">删除账户</button>
</div>
</div>`;
}
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 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("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();
};
setActiveNav();
window.addEventListener("popstate", setActiveNav);
})();