948 lines
37 KiB
JavaScript
948 lines
37 KiB
JavaScript
(function () {
|
||
const toast = document.getElementById("toast");
|
||
let settingsCache = null;
|
||
let monitorTimer = null;
|
||
let authState = { required: false, logged_in: true };
|
||
let tpslPending = null;
|
||
let lastMonitorRows = [];
|
||
let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || "";
|
||
|
||
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, ">")
|
||
.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))`;
|
||
}
|
||
|
||
function normSym(s) {
|
||
return String(s || "")
|
||
.toUpperCase()
|
||
.replace(/:USDT$/i, "")
|
||
.replace(/\/USDT:USDT$/i, "")
|
||
.replace(/\/USDT$/i, "");
|
||
}
|
||
|
||
function symbolsMatchHub(a, b) {
|
||
const x = normSym(a);
|
||
const y = normSym(b);
|
||
if (!x || !y) return false;
|
||
return x === y;
|
||
}
|
||
|
||
function ordersCollapseKey(exchangeId, symbol) {
|
||
const sym = normSym(symbol) || "unknown";
|
||
return `hub_orders_${exchangeId}_${sym}`;
|
||
}
|
||
|
||
function isOrdersCollapseOpen(exchangeId, symbol) {
|
||
return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1";
|
||
}
|
||
|
||
function findMonitorOrder(orders, symbol, side) {
|
||
const want = (side || "").toLowerCase();
|
||
for (const o of orders || []) {
|
||
const sym = o.exchange_symbol || o.symbol || "";
|
||
if (!symbolsMatchHub(sym, symbol)) continue;
|
||
const d = (o.direction || "").toLowerCase();
|
||
if (!d || d === want) return o;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function calcRrRatio(side, entry, sl, tp) {
|
||
const e = Number(entry);
|
||
const s = Number(sl);
|
||
const t = Number(tp);
|
||
if (![e, s, t].every((n) => Number.isFinite(n) && n > 0)) return null;
|
||
if ((side || "long").toLowerCase() === "short") {
|
||
const risk = s - e;
|
||
const reward = e - t;
|
||
if (risk <= 0 || reward <= 0) return null;
|
||
return reward / risk;
|
||
}
|
||
const risk = e - s;
|
||
const reward = t - e;
|
||
if (risk <= 0 || reward <= 0) return null;
|
||
return reward / risk;
|
||
}
|
||
|
||
async function loadMonitorBoard() {
|
||
const box = document.getElementById("monitor-grid");
|
||
try {
|
||
const r = await apiFetch("/api/monitor/board");
|
||
const data = await r.json();
|
||
lastMonitorRows = data.rows || [];
|
||
const online = lastMonitorRows.filter(
|
||
(x) => x.http_ok && (x.agent || {}).ok !== false
|
||
).length;
|
||
const pill = document.getElementById("sys-status");
|
||
if (pill) {
|
||
pill.textContent = lastMonitorRows.length
|
||
? `LINK ${online}/${lastMonitorRows.length}`
|
||
: "NO DATA";
|
||
pill.classList.toggle("warn", lastMonitorRows.length && online < lastMonitorRows.length);
|
||
}
|
||
document.getElementById("monitor-updated").textContent =
|
||
"UPD " + (data.updated_at || "").replace("T", " ");
|
||
renderMonitorGrid(lastMonitorRows);
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="err">${esc(e)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderMonitorGrid(rows) {
|
||
const box = document.getElementById("monitor-grid");
|
||
if (!box) return;
|
||
if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) {
|
||
expandedExchangeId = "";
|
||
sessionStorage.removeItem("hub_expanded_ex");
|
||
}
|
||
const visible = expandedExchangeId
|
||
? rows.filter((r) => String(r.id) === String(expandedExchangeId))
|
||
: rows;
|
||
box.classList.toggle("grid-monitor-expanded", !!expandedExchangeId);
|
||
const parts = visible.map((r) => renderMonitorCard(r, !!expandedExchangeId));
|
||
box.innerHTML = parts.join("") || '<div class="err">无已启用账户</div>';
|
||
if (!expandedExchangeId) syncMonitorGridColumns(box, rows.length);
|
||
bindMonitorInteractions(box);
|
||
}
|
||
|
||
function bindMonitorInteractions(box) {
|
||
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
|
||
btn.onclick = () => closeOne(btn.dataset.id);
|
||
});
|
||
box.querySelectorAll(".btn-close-pos").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.stopPropagation();
|
||
closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side);
|
||
};
|
||
});
|
||
box.querySelectorAll(".btn-cancel-order").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.stopPropagation();
|
||
cancelOneOrder(
|
||
btn.dataset.exId,
|
||
btn.dataset.symbol,
|
||
btn.dataset.orderId,
|
||
btn.dataset.channel
|
||
);
|
||
};
|
||
});
|
||
box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional");
|
||
};
|
||
});
|
||
box.querySelectorAll(".btn-place-tpsl").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.stopPropagation();
|
||
openTpslModal(
|
||
btn.dataset.exId,
|
||
btn.dataset.symbol,
|
||
btn.dataset.side,
|
||
btn.dataset.contracts,
|
||
btn.dataset.sl || "",
|
||
btn.dataset.tp || ""
|
||
);
|
||
};
|
||
});
|
||
box.querySelectorAll(".btn-expand-back").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.stopPropagation();
|
||
expandedExchangeId = "";
|
||
sessionStorage.removeItem("hub_expanded_ex");
|
||
renderMonitorGrid(lastMonitorRows);
|
||
};
|
||
});
|
||
box.querySelectorAll(".card-expand-hit").forEach((hit) => {
|
||
hit.onclick = (ev) => {
|
||
if (ev.target.closest("a, button, input, summary, .pos-orders-collapse")) return;
|
||
const id = hit.closest(".card")?.dataset.exId;
|
||
if (!id || expandedExchangeId) return;
|
||
expandedExchangeId = id;
|
||
sessionStorage.setItem("hub_expanded_ex", id);
|
||
renderMonitorGrid(lastMonitorRows);
|
||
};
|
||
});
|
||
box.querySelectorAll("details.pos-orders-collapse[data-collapse-key]").forEach((el) => {
|
||
el.addEventListener("toggle", () => {
|
||
const k = el.dataset.collapseKey;
|
||
if (k) localStorage.setItem(k, el.open ? "1" : "0");
|
||
});
|
||
});
|
||
}
|
||
|
||
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, """);
|
||
const rows = orders
|
||
.map((o) => {
|
||
const oidAttr = esc(o.id || "").replace(/"/g, """);
|
||
const chAttr = esc(o.channel || "regular").replace(/"/g, """);
|
||
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 guessTpslFromCondOrders(side, cond) {
|
||
const triggers = (cond || [])
|
||
.map((o) => o.trigger_price)
|
||
.filter((v) => v != null && !Number.isNaN(Number(v)))
|
||
.map(Number);
|
||
if (!triggers.length) return { sl: "", tp: "" };
|
||
triggers.sort((a, b) => a - b);
|
||
const s = (side || "long").toLowerCase();
|
||
if (s === "short") {
|
||
return { sl: triggers[triggers.length - 1], tp: triggers[0] };
|
||
}
|
||
return { sl: triggers[0], tp: triggers[triggers.length - 1] };
|
||
}
|
||
|
||
function renderOrdersCollapse(exchangeId, symbol, cond, reg) {
|
||
const symAttr = esc(symbol || "").replace(/"/g, """);
|
||
const orderTotal = cond.length + reg.length;
|
||
const collapseKey = ordersCollapseKey(exchangeId, symbol);
|
||
const openAttr = isOrdersCollapseOpen(exchangeId, symbol) ? " open" : "";
|
||
const condAllBtn =
|
||
cond.length > 0
|
||
? `<button type="button" class="btn-cancel-cond-all btn-sm ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销条件单</button>`
|
||
: "";
|
||
const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional");
|
||
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit");
|
||
return `<details class="pos-orders-collapse"${openAttr} data-collapse-key="${esc(collapseKey)}">
|
||
<summary class="pos-orders-collapse-summary">
|
||
<span class="pos-orders-collapse-label">委托单 <em>${orderTotal}</em></span>
|
||
<span class="pos-orders-collapse-meta">条件 ${cond.length} · 普通 ${reg.length}</span>
|
||
${condAllBtn}
|
||
</summary>
|
||
<div class="pos-orders-collapse-body">
|
||
<div class="orders-section">
|
||
<div class="orders-section-head">条件单</div>
|
||
${condBody}
|
||
</div>
|
||
<div class="orders-section">
|
||
<div class="orders-section-head">普通委托</div>
|
||
${regBody}
|
||
</div>
|
||
</div>
|
||
</details>`;
|
||
}
|
||
|
||
function renderExTpslRows(exchangeId, symbol, cond) {
|
||
const symAttr = esc(symbol || "").replace(/"/g, """);
|
||
const sl = cond.find((o) => (o.label || "").includes("止损"));
|
||
const tp = cond.find((o) => (o.label || "").includes("止盈"));
|
||
function row(label, o) {
|
||
if (!o) {
|
||
return `<div class="pos-ex-order-row"><span class="pos-ex-order-main">${label}:—</span></div>`;
|
||
}
|
||
const oid = esc(o.id || "").replace(/"/g, """);
|
||
const ch = esc(o.channel || "regular").replace(/"/g, """);
|
||
const trig = o.trigger_price != null ? fmt(o.trigger_price, 4) : "—";
|
||
return `<div class="pos-ex-order-row">
|
||
<span class="pos-ex-order-main">${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}</span>
|
||
<button type="button" class="pos-ex-cancel-btn btn-cancel-order" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oid}" data-channel="${ch}">撤单</button>
|
||
</div>`;
|
||
}
|
||
return row("止损", sl) + row("止盈", tp);
|
||
}
|
||
|
||
function renderLivePositionCard(exchangeId, pos, monitorOrder) {
|
||
const symbol = pos.symbol || "";
|
||
const side = (pos.side || "long").toLowerCase();
|
||
const sideCn = side === "long" ? "做多" : "做空";
|
||
const sideCls = side === "long" ? "pos-side-long" : "pos-side-short";
|
||
const mo = monitorOrder || {};
|
||
const cond = Array.isArray(pos.conditional_orders) ? pos.conditional_orders : [];
|
||
const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : [];
|
||
const guess = guessTpslFromCondOrders(side, cond);
|
||
const symAttr = esc(symbol).replace(/"/g, """);
|
||
const sideAttr = esc(side).replace(/"/g, """);
|
||
const contractsAttr = esc(String(pos.contracts != null ? pos.contracts : "")).replace(/"/g, """);
|
||
const slAttr = esc(String(mo.stop_loss != null ? mo.stop_loss : guess.sl)).replace(/"/g, """);
|
||
const tpAttr = esc(String(mo.take_profit != null ? mo.take_profit : guess.tp)).replace(/"/g, """);
|
||
const entry = pos.entry_price != null ? pos.entry_price : mo.trigger_price;
|
||
const sl = mo.stop_loss != null ? mo.stop_loss : guess.sl;
|
||
const tp = mo.take_profit != null ? mo.take_profit : guess.tp;
|
||
const rr = calcRrRatio(side, entry, sl, tp);
|
||
const upnl = pos.unrealized_pnl;
|
||
let pnlText = fmt(upnl, 2) + "U";
|
||
if (pos.notional_usdt && upnl != null && Math.abs(Number(pos.notional_usdt)) > 1e-8) {
|
||
const pct = (Number(upnl) / Math.abs(Number(pos.notional_usdt))) * 100;
|
||
pnlText += ` (${pct >= 0 ? "" : ""}${pct.toFixed(2)}%)`;
|
||
}
|
||
const meta = [];
|
||
if (mo.monitor_type || mo.key_signal_type) {
|
||
meta.push(
|
||
`来源: ${esc(mo.monitor_type || "下单监控")}${mo.key_signal_type ? " · " + esc(mo.key_signal_type) : ""}`
|
||
);
|
||
} else {
|
||
meta.push("来源: 交易所持仓");
|
||
}
|
||
if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`);
|
||
else meta.push("风格: —");
|
||
if (mo.risk_percent != null) {
|
||
meta.push(`风险: ${esc(mo.risk_percent)}%`);
|
||
}
|
||
const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true;
|
||
meta.push(
|
||
`<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>`
|
||
);
|
||
return `<div class="pos-card hub-pos-card">
|
||
<div class="pos-card-head">
|
||
<div class="pos-card-symbol">
|
||
<strong>${esc(symbol)}</strong>
|
||
<span class="pos-side-badge ${sideCls}">${sideCn}</span>
|
||
</div>
|
||
<div class="pos-head-actions">
|
||
<button type="button" class="pos-entrust-btn btn-place-tpsl" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}" data-contracts="${contractsAttr}" data-sl="${slAttr}" data-tp="${tpAttr}">委托</button>
|
||
<button type="button" class="pos-close-btn btn-close-pos" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
|
||
</div>
|
||
</div>
|
||
<div class="pos-meta">${meta.map((m) => `<span class="pos-meta-item">${m}</span>`).join("")}</div>
|
||
<div class="pos-grid">
|
||
<div class="pos-cell"><span class="pos-label">成交价</span><span class="pos-value">${entry != null ? fmt(entry, 4) : "—"}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${sl != null && sl !== "" ? fmt(sl, 4) : "—"}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value">${tp != null && tp !== "" ? fmt(tp, 4) : "—"}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${rr != null ? fmt(rr, 2) + ":1" : "-:1"}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">张数</span><span class="pos-value">${fmt(pos.contracts, 4)}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ${pnlCls(upnl)}">${pnlText}</span></div>
|
||
</div>
|
||
<div class="pos-footer">
|
||
<span>杠杆: ${mo.leverage != null ? esc(mo.leverage) + "x" : "—"}</span>
|
||
<span>计划基数: ${mo.margin_capital != null ? fmt(mo.margin_capital, 2) + "U" : "—"}</span>
|
||
<span>仓位占比: ${mo.position_ratio != null ? esc(mo.position_ratio) + "%" : "—"}</span>
|
||
</div>
|
||
<div class="pos-ex-orders">
|
||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||
${renderExTpslRows(exchangeId, symbol, cond)}
|
||
</div>
|
||
${renderOrdersCollapse(exchangeId, symbol, cond, reg)}
|
||
</div>`;
|
||
}
|
||
|
||
function renderHubSectionCard(title, bodyHtml, emptyHint) {
|
||
const inner = bodyHtml || `<div class="pos-empty">${esc(emptyHint || "暂无")}</div>`;
|
||
return `<div class="hub-section-card">
|
||
<div class="hub-section-head">${esc(title)}</div>
|
||
<div class="hub-section-body">${inner}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderKeySection(keys, kmap) {
|
||
if (!keys.length) return "";
|
||
return keys
|
||
.map((k) => {
|
||
const kp = kmap[k.id] || kmap[String(k.id)] || {};
|
||
const mt = k.monitor_type || k.type || "";
|
||
return `<div class="hub-mini-card">
|
||
<div class="hub-mini-title">${esc(k.symbol)} · ${esc(mt)}</div>
|
||
<div class="hub-mini-line">上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}</div>
|
||
<div class="hub-mini-line">${esc(kp.gate_summary || kp.price_display || kp.price || "—")}</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function renderStrategySection(orders, trends) {
|
||
const parts = [];
|
||
(orders || []).forEach((o) => {
|
||
parts.push(`<div class="hub-mini-card">
|
||
<div class="hub-mini-title">下单监控 #${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)}</div>
|
||
<div class="hub-mini-line">${esc(o.direction)} · 触发 ${fmt(o.trigger_price, 4)} · SL ${fmt(o.stop_loss, 4)} · TP ${fmt(o.take_profit, 4)}</div>
|
||
</div>`);
|
||
});
|
||
(trends || []).forEach((t) => {
|
||
parts.push(`<div class="hub-mini-card">
|
||
<div class="hub-mini-title">趋势计划 #${esc(t.id)} · ${esc(t.symbol)}</div>
|
||
<div class="hub-mini-line">${esc(t.direction)} · SL ${fmt(t.stop_loss, 4)} · TP ${fmt(t.take_profit, 4)}</div>
|
||
</div>`;
|
||
});
|
||
return parts.join("");
|
||
}
|
||
|
||
function renderCompactBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap) {
|
||
let 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">持仓 · ${pos.length}</div>`;
|
||
if (pos.length) {
|
||
inner += '<div class="compact-pos-list">';
|
||
pos.forEach((p) => {
|
||
inner += `<div class="compact-pos-line"><span>${esc(p.symbol)} · ${esc(p.side)}</span><span class="${pnlCls(p.unrealized_pnl)}">${fmt(p.unrealized_pnl, 4)}</span></div>`;
|
||
});
|
||
inner += "</div>";
|
||
} else {
|
||
inner += '<div class="empty-hint">无持仓</div>';
|
||
}
|
||
const keyN = (row.capabilities || []).includes("key") ? keys.length : 0;
|
||
const stratN = orders.length + ((row.capabilities || []).includes("trend") ? trends.length : 0);
|
||
inner += `<div class="card-expand-hint">点击卡片展开 · 持仓详情 / 关键位 ${keyN} / 策略 ${stratN}</div>`;
|
||
return inner;
|
||
}
|
||
|
||
function renderExpandedBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap) {
|
||
let 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="hub-pos-list">';
|
||
if (pos.length) {
|
||
pos.forEach((p) => {
|
||
const mo = findMonitorOrder(orders, p.symbol, p.side);
|
||
inner += renderLivePositionCard(row.id, p, mo);
|
||
});
|
||
} else {
|
||
inner += '<div class="pos-empty">暂无持仓</div>';
|
||
}
|
||
inner += "</div>";
|
||
if ((row.capabilities || []).includes("key")) {
|
||
if (!flaskOk) {
|
||
const fe = row.flask_error || hm.msg || hm.error || "策略 Flask 未连通";
|
||
inner += renderHubSectionCard("关键位", `<div class="err">${esc(fe)}</div>`, "");
|
||
} else {
|
||
inner += renderHubSectionCard("关键位", renderKeySection(keys, kmap), "当前无关键位记录");
|
||
}
|
||
}
|
||
const showStrategy =
|
||
orders.length || ((row.capabilities || []).includes("trend") && trends.length);
|
||
if (showStrategy) {
|
||
inner += renderHubSectionCard(
|
||
"策略",
|
||
renderStrategySection(orders, (row.capabilities || []).includes("trend") ? trends : []),
|
||
"当前无策略记录"
|
||
);
|
||
}
|
||
return inner;
|
||
}
|
||
|
||
function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) {
|
||
tpslPending = {
|
||
exchangeId,
|
||
symbol,
|
||
side: (side || "long").toLowerCase(),
|
||
contracts: parseFloat(contracts),
|
||
};
|
||
const modal = document.getElementById("tpsl-modal");
|
||
const meta = document.getElementById("tpsl-modal-meta");
|
||
const slIn = document.getElementById("tpsl-sl");
|
||
const tpIn = document.getElementById("tpsl-tp");
|
||
if (!modal || !meta || !slIn || !tpIn) return;
|
||
meta.textContent = `${symbol} · ${side} · ${contracts} 张`;
|
||
slIn.value = slHint !== "" && slHint != null ? String(slHint) : "";
|
||
tpIn.value = tpHint !== "" && tpHint != null ? String(tpHint) : "";
|
||
modal.classList.remove("hidden");
|
||
modal.setAttribute("aria-hidden", "false");
|
||
slIn.focus();
|
||
}
|
||
|
||
function closeTpslModal() {
|
||
tpslPending = null;
|
||
const modal = document.getElementById("tpsl-modal");
|
||
if (modal) {
|
||
modal.classList.add("hidden");
|
||
modal.setAttribute("aria-hidden", "true");
|
||
}
|
||
}
|
||
|
||
async function submitTpslModal() {
|
||
if (!tpslPending) return;
|
||
const slIn = document.getElementById("tpsl-sl");
|
||
const tpIn = document.getElementById("tpsl-tp");
|
||
const sl = parseFloat(slIn && slIn.value);
|
||
const tp = parseFloat(tpIn && tpIn.value);
|
||
if (!sl || sl <= 0 || !tp || tp <= 0) {
|
||
showToast("请填写有效的止损价与止盈价", true);
|
||
return;
|
||
}
|
||
const { exchangeId, symbol, side, contracts } = tpslPending;
|
||
if (
|
||
!confirm(
|
||
`确认 ${symbol} ${side}\n先撤销全部条件单,再挂止损 ${sl}、止盈 ${tp}?`
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById("tpsl-submit");
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const r = await apiFetch(
|
||
"/api/orders/" + encodeURIComponent(exchangeId) + "/place-tpsl",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
symbol,
|
||
side,
|
||
stop_loss: sl,
|
||
take_profit: tp,
|
||
contracts: contracts > 0 ? contracts : null,
|
||
}),
|
||
}
|
||
);
|
||
const j = await r.json();
|
||
const pl = j.payload || {};
|
||
const ok = j.ok && pl.ok !== false;
|
||
const n = pl.placed && pl.placed.cancelled_conditional;
|
||
showToast(
|
||
ok
|
||
? `已挂单(已撤 ${n != null ? n : "?"} 笔旧条件单)`
|
||
: pl.error || JSON.stringify(j),
|
||
!ok
|
||
);
|
||
if (ok) {
|
||
closeTpslModal();
|
||
loadMonitorBoard();
|
||
}
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function initTpslModal() {
|
||
const backdrop = document.getElementById("tpsl-modal-backdrop");
|
||
const cancel = document.getElementById("tpsl-cancel");
|
||
const submit = document.getElementById("tpsl-submit");
|
||
if (backdrop) backdrop.onclick = closeTpslModal;
|
||
if (cancel) cancel.onclick = closeTpslModal;
|
||
if (submit) submit.onclick = () => submitTpslModal();
|
||
document.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Escape") closeTpslModal();
|
||
});
|
||
}
|
||
|
||
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, expanded) {
|
||
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 if (expanded) {
|
||
inner = renderExpandedBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap);
|
||
} else {
|
||
inner = renderCompactBody(row, ag, pos, hm, flaskOk, keys, orders, trends, kmap);
|
||
}
|
||
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>`
|
||
: "";
|
||
const backBtn = expanded
|
||
? `<button type="button" class="ghost btn-expand-back">返回</button>`
|
||
: "";
|
||
const expandHit = !expanded && online ? " card-expand-hit" : "";
|
||
return `<div class="card ${cardCls}${expanded ? " card-expanded" : ""}" data-ex-id="${esc(row.id)}">
|
||
<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">
|
||
${backBtn}
|
||
${openFlask}
|
||
${review}
|
||
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body${expandHit}">${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 后点「保存设置」");
|
||
};
|
||
|
||
initTpslModal();
|
||
|
||
initAuth().then((ok) => {
|
||
if (!ok) return;
|
||
loadSettings().catch(() => {});
|
||
setActiveNav();
|
||
window.addEventListener("popstate", setActiveNav);
|
||
});
|
||
})();
|