Files
crypto_monitor/manual_trading_hub/static/app.js
T
2026-05-24 07:35:48 +08:00

567 lines
22 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;
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, "&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 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("") || '<div class="err">无已启用账户</div>';
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);
});
box.querySelectorAll(".btn-cancel-order").forEach((btn) => {
btn.onclick = () =>
cancelOneOrder(
btn.dataset.exId,
btn.dataset.symbol,
btn.dataset.orderId,
btn.dataset.channel
);
});
box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => {
btn.onclick = () =>
cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional");
});
} catch (e) {
box.innerHTML = `<div class="err">${esc(e)}</div>`;
}
}
function renderOrderRows(exchangeId, symbol, orders, kind) {
if (!orders || !orders.length) {
const hint =
kind === "conditional"
? "暂无条件单(止盈/止损等)"
: "暂无普通委托";
return `<div class="order-empty">${hint}</div>`;
}
const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const rows = orders
.map((o) => {
const oidAttr = esc(o.id || "").replace(/"/g, "&quot;");
const chAttr = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig =
o.trigger_price != null ? fmt(o.trigger_price, 4) : o.price != null ? fmt(o.price, 4) : "—";
return `<tr>
<td>${esc(o.label || o.type || "委托")}</td>
<td>${fmt(o.amount, 4)}</td>
<td>${trig}</td>
<td class="td-actions"><button type="button" class="btn-cancel-order ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oidAttr}" data-channel="${chAttr}">撤单</button></td>
</tr>`;
})
.join("");
return `<table class="data-table data-table-sub"><thead><tr><th>类型</th><th>数量</th><th>触发/价格</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function renderPositionBlock(exchangeId, x) {
const symAttr = esc(x.symbol || "").replace(/"/g, "&quot;");
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;");
const cond = Array.isArray(x.conditional_orders) ? x.conditional_orders : [];
const reg = Array.isArray(x.regular_orders) ? x.regular_orders : [];
const condAllBtn =
cond.length > 0
? `<button type="button" class="btn-cancel-cond-all ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销全部条件单</button>`
: "";
return `<div class="pos-block">
<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
<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>
<td class="td-actions"><button type="button" class="btn-close-pos danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button></td>
</tr>
</tbody></table>
<div class="pos-orders">
<div class="pos-orders-head">
<span class="pos-orders-title">条件单 · ${cond.length}</span>
${condAllBtn}
</div>
${renderOrderRows(exchangeId, x.symbol, cond, "conditional")}
<div class="pos-orders-head" style="margin-top:10px">
<span class="pos-orders-title">普通委托 · ${reg.length}</span>
</div>
${renderOrderRows(exchangeId, x.symbol, reg, "limit")}
</div>
</div>`;
}
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);
loadMonitorBoard();
} 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);
loadMonitorBoard();
} catch (e) {
showToast(String(e), true);
}
}
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) {
inner += pos.map((x) => renderPositionBlock(row.id, x)).join("");
} 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 ((row.capabilities || []).includes("trend") && 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 online = row.http_ok && agOk;
const cardCls = online ? "card-online" : "card-offline";
const dotCls = online ? "ok" : "bad";
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 ${cardCls}">
<div class="card-head">
<div>
<div class="card-title-row">
<span class="status-dot ${dotCls}" title="${online ? "在线" : "离线"}"></span>
<div class="card-title">${esc(row.name)}</div>
</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 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
? '<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 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);
});
})();