(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;
}
let instanceFrameUrl = "";
/** @type {{ exchangeId: string, nextPath: string, title: string } | null} */
let instanceFrameCtx = null;
function isHubEmbedded() {
try {
return window.self !== window.top;
} catch (_) {
return true;
}
}
/** 在 LocalNav 等父页 iframe 内:直接替换本 iframe 地址,避免 postMessage / 三层嵌套 */
function openInstanceInParentFrame(url) {
try {
window.location.assign(url);
return true;
} catch (_) {
return false;
}
}
async function fetchInstanceOpenUrl(exchangeId, nextPath, opts) {
const options = opts || {};
const next = nextPath || "/";
const q = new URLSearchParams({ exchange_id: String(exchangeId), next });
if (options.embed) q.set("embed", "1");
const r = await apiFetch("/api/instance/open-url?" + q.toString());
const j = await r.json();
if (!j.ok || !j.url) {
throw new Error(j.detail || "无法生成打开链接");
}
return j.url;
}
async function openInstance(exchangeId, nextPath, opts) {
const options = opts || {};
const newTab = !!options.newTab;
const next = nextPath || "/";
try {
const embedded = isHubEmbedded();
const url = await fetchInstanceOpenUrl(exchangeId, next, { embed: embedded });
if (newTab) {
window.open(url, "_blank", "noopener");
return;
}
const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId));
const title = row ? row.name : exchangeId;
instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title };
if (embedded) {
try {
window.parent.postMessage(
{
type: "hub:open-instance-nav",
exchangeId: String(exchangeId),
nextPath: next,
title,
},
"*"
);
} catch (_) {}
if (openInstanceInParentFrame(url)) return;
}
openInstanceFrame(url, title);
} catch (e) {
showToast(String(e), true);
}
}
async function refreshInstanceFrame() {
if (!instanceFrameCtx) {
if (instanceFrameUrl) {
const frame = document.getElementById("instance-frame");
if (frame) frame.src = instanceFrameUrl;
}
return;
}
try {
const url = await fetchInstanceOpenUrl(
instanceFrameCtx.exchangeId,
instanceFrameCtx.nextPath,
{ embed: isHubEmbedded() }
);
instanceFrameUrl = url;
const frame = document.getElementById("instance-frame");
if (frame) frame.src = url;
} catch (e) {
showToast(String(e), true);
}
}
function openInstanceFrame(url, title) {
const shell = document.getElementById("instance-frame-shell");
const frame = document.getElementById("instance-frame");
const titleEl = document.getElementById("instance-frame-title");
if (!shell || !frame) {
window.open(url, "_blank", "noopener");
return;
}
closeExchangeFullscreen();
instanceFrameUrl = url;
if (titleEl) titleEl.textContent = title || "实例";
frame.src = url;
shell.classList.remove("hidden");
shell.setAttribute("aria-hidden", "false");
document.body.classList.add("hub-instance-frame-open");
}
function closeInstanceFrame() {
const shell = document.getElementById("instance-frame-shell");
const frame = document.getElementById("instance-frame");
instanceFrameUrl = "";
instanceFrameCtx = null;
if (frame) frame.src = "about:blank";
if (shell) {
shell.classList.add("hidden");
shell.setAttribute("aria-hidden", "true");
}
document.body.classList.remove("hub-instance-frame-open");
}
/** @deprecated use openInstance */
async function openInstanceInBrowser(exchangeId, nextPath) {
return openInstance(exchangeId, nextPath, { newTab: false });
}
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 normSide(side) {
const s = (side || "").toLowerCase();
if (s === "buy") return "long";
if (s === "sell") return "short";
return s;
}
function sideDirCls(side) {
const s = normSide(side);
if (s === "long") return "side-long";
if (s === "short") return "side-short";
return "";
}
function sideDirLabel(side) {
const s = normSide(side);
if (s === "long") return "做多";
if (s === "short") return "做空";
return side || "—";
}
function renderDirectionHtml(side) {
const cls = sideDirCls(side);
const label = sideDirLabel(side);
if (!cls) return esc(String(label));
return `${esc(label)}`;
}
function keyHasPendingOrder(keyRow, keyPrice) {
const kp = keyPrice || {};
const oid = keyRow.fib_limit_order_id;
if (oid != null && String(oid).trim() !== "") return true;
const gm = String(kp.gate_metrics || "");
if (gm.includes("限价单") || gm.includes("挂单")) return true;
const gs = String(kp.gate_summary || "");
if (/挂|限价|等待成交/.test(gs)) return true;
return false;
}
function fmtKeyOrderAmount(keyRow) {
const raw = keyRow.fib_order_amount;
if (raw == null || raw === "") return "";
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return "";
return `${fmt(n, 4)} 张`;
}
/** 全屏持仓区:按仓位数量附加布局 class(1~6 固定列数,7+ 自动填充) */
function hubPosListCountClass(n) {
const c = Math.max(0, parseInt(n, 10) || 0);
if (c <= 0) return "count-0";
if (c <= 6) return `count-${c}`;
return "count-many";
}
function currentPage() {
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("settings")) return "settings";
if (p.includes("market")) return "market";
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();
if (page === "market" && window.hubMarketChart) {
window.hubMarketChart.init();
} else if (window.hubMarketChart) {
if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh();
if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer();
}
}
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);
}
function isMobileLayout() {
return window.matchMedia("(max-width: 720px)").matches;
}
/** 监控卡片列数:桌面 3/2 列;手机端固定单列 */
function syncMonitorGridColumns(gridEl, count) {
if (!gridEl) return;
if (isMobileLayout()) {
gridEl.style.gridTemplateColumns = "minmax(0, 1fr)";
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 initMobileLayout() {
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const box = document.getElementById("monitor-grid");
if (box && lastMonitorRows.length) {
syncMonitorGridColumns(box, lastMonitorRows.length);
}
}, 120);
});
}
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 condOrdersFromPosition(pos) {
const cond = Array.isArray(pos.conditional_orders) ? pos.conditional_orders : [];
if (cond.length) return cond;
const et = pos.exchange_tpsl;
if (!et) return [];
const out = [];
if (et.sl && et.sl.trigger_price != null) {
out.push({
label: "止损",
trigger_price: Number(et.sl.trigger_price),
amount: null,
id: et.sl.order_id,
channel: "algo",
});
}
if (et.tp && et.tp.trigger_price != null) {
out.push({
label: "止盈",
trigger_price: Number(et.tp.trigger_price),
amount: null,
id: et.tp.order_id,
channel: "algo",
});
}
return out;
}
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;
}
function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored) {
if (tpMonitored) return null;
const snap = mo && mo.rr_ratio;
if (snap != null && snap !== "") {
const n = Number(snap);
if (Number.isFinite(n)) return n;
}
const initSl = mo && (mo.initial_stop_loss != null ? mo.initial_stop_loss : mo.stop_loss);
return calcRrRatio(side, entry, initSl || sl, tp);
}
function isBreakevenSecured(side, entry, monitorOrder, cond, pos) {
const mo = monitorOrder || {};
const p = pos || {};
if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true;
if (p.sl_breakeven_secured === true || p.sl_breakeven_secured === 1) return true;
const { sl } = pickExTpslOrders(cond);
const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN;
const e = Number(entry);
if (!Number.isFinite(trig) || !Number.isFinite(e)) return false;
if ((side || "long").toLowerCase() === "short") return trig <= e;
return trig >= e;
}
function breakevenBadgeHtml() {
return `已保本`;
}
async function loadMonitorBoard() {
const box = document.getElementById("monitor-grid");
const showLoading = !lastMonitorRows.length;
if (showLoading && box) {
box.innerHTML =
'
正在聚合四所数据…
';
}
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 = `${esc(e)}
`;
}
}
function closeExchangeFullscreen() {
expandedExchangeId = "";
sessionStorage.removeItem("hub_expanded_ex");
const fs = document.getElementById("exchange-fullscreen");
if (fs) {
fs.classList.add("hidden");
fs.setAttribute("aria-hidden", "true");
}
document.body.classList.remove("hub-fullscreen-open");
}
function openExchangeFullscreen(exId) {
expandedExchangeId = String(exId);
sessionStorage.setItem("hub_expanded_ex", expandedExchangeId);
renderMonitorGrid(lastMonitorRows);
}
function renderMonitorGrid(rows) {
const box = document.getElementById("monitor-grid");
const fs = document.getElementById("exchange-fullscreen");
const fsInner = document.getElementById("exchange-fullscreen-inner");
if (!box) return;
if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) {
closeExchangeFullscreen();
}
box.innerHTML =
rows.map((r) => renderMonitorCard(r)).join("") || '无已启用账户
';
syncMonitorGridColumns(box, rows.length);
bindMonitorInteractions(box);
if (expandedExchangeId && fs && fsInner) {
const row = rows.find((r) => String(r.id) === String(expandedExchangeId));
if (row) {
try {
fsInner.innerHTML = renderFullscreenExchange(row);
fs.classList.remove("hidden");
fs.setAttribute("aria-hidden", "false");
document.body.classList.add("hub-fullscreen-open");
bindMonitorInteractions(fsInner);
fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
};
});
} catch (err) {
console.error("renderFullscreenExchange", err);
closeExchangeFullscreen();
showToast("全屏渲染失败: " + err, true);
}
} else {
closeExchangeFullscreen();
}
} else {
closeExchangeFullscreen();
}
}
function normalizeMarketSymbol(raw) {
let s = (raw || "").trim().toUpperCase();
if (!s) return "";
if (s.includes(":")) {
const base = s.split(":")[0];
if (base.includes("/")) return base;
}
return s;
}
function resolveExchangeKey(exchangeId) {
const row = (lastMonitorRows || []).find((r) => String(r.id) === String(exchangeId));
return (row && (row.key || row.id)) || exchangeId;
}
function findTrendPlan(trends, symbol, side) {
const want = (side || "").toLowerCase();
for (const t of trends || []) {
const sym = t.symbol || t.exchange_symbol || "";
if (!symbolsMatchHub(sym, symbol)) continue;
const d = (t.direction || "").toLowerCase();
if (!d || d === want) return t;
}
return null;
}
function inferTpslFromCondOrders(side, cond, entry) {
const picked = pickExTpslOrders(cond);
let sl = picked.sl && picked.sl.trigger_price != null ? picked.sl.trigger_price : "";
let tp = picked.tp && picked.tp.trigger_price != null ? picked.tp.trigger_price : "";
if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) {
return { sl, tp };
}
const triggers = (cond || [])
.map(function (o) {
return { price: Number(o.trigger_price), label: o.label || "" };
})
.filter(function (o) {
return o.price != null && !Number.isNaN(o.price) && o.price > 0;
});
if (!triggers.length) return { sl: sl || "", tp: tp || "" };
const s = (side || "long").toLowerCase();
const e = entry != null && Number.isFinite(Number(entry)) ? Number(entry) : null;
if (e != null) {
const below = triggers.filter(function (t) {
return t.price < e;
});
const above = triggers.filter(function (t) {
return t.price > e;
});
if (s === "long") {
if (sl === "" && below.length) {
sl = Math.max.apply(
null,
below.map(function (t) {
return t.price;
})
);
}
if (tp === "" && above.length) {
tp = Math.min.apply(
null,
above.map(function (t) {
return t.price;
})
);
}
} else {
if (sl === "" && above.length) {
sl = Math.min.apply(
null,
above.map(function (t) {
return t.price;
})
);
}
if (tp === "" && below.length) {
tp = Math.max.apply(
null,
below.map(function (t) {
return t.price;
})
);
}
}
}
if (triggers.length === 1 && sl === "" && tp === "") {
const one = triggers[0];
const p = one.price;
const lbl = one.label;
if (e != null) {
if (s === "long") {
if (p < e) sl = p;
else if (p > e) tp = p;
} else if (p > e) sl = p;
else if (p < e) tp = p;
} else if (/止损/.test(lbl)) sl = p;
else if (/止盈/.test(lbl) && !/止盈止损/.test(lbl)) tp = p;
}
if (sl !== "" && tp !== "" && Number(sl) === Number(tp)) tp = "";
return { sl: sl || "", tp: tp || "" };
}
function resolvePositionTpsl(pos, monitorOrder, trendPlan) {
const mo = monitorOrder || {};
const tp = trendPlan || {};
const cond = condOrdersFromPosition(pos);
const entryRaw =
pos.entry_price != null
? pos.entry_price
: mo.trigger_price != null
? mo.trigger_price
: tp.avg_entry_price;
const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null;
const isTrend =
!!(trendPlan && trendPlan.id) || String(mo.monitor_type || "").trim() === "趋势回调";
let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : "";
let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : "";
let tpMonitored = false;
if (isTrend) {
tpMonitored = true;
takeProfit = "";
if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") {
sl = trendPlan.stop_loss;
}
}
const inferred = inferTpslFromCondOrders(pos.side, cond, entryN);
if (sl === "" || sl == null) sl = inferred.sl;
if (!tpMonitored && (takeProfit === "" || takeProfit == null)) takeProfit = inferred.tp;
if (sl !== "" && takeProfit !== "" && Number(sl) === Number(takeProfit)) {
takeProfit = "";
}
return {
entry: entryRaw,
sl,
tp: takeProfit,
tp_monitored: tpMonitored,
is_trend: isTrend,
};
}
function buildPositionMarketContext(pos, monitorOrder, trendPlan) {
const tpsl = resolvePositionTpsl(pos, monitorOrder, trendPlan);
const cond = condOrdersFromPosition(pos);
const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : [];
const num = function (v) {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const orders = [];
cond.forEach(function (o) {
orders.push({
kind: "条件",
label: o.label || "条件单",
price: num(o.trigger_price),
amount: num(o.amount),
});
});
reg.forEach(function (o) {
orders.push({
kind: "普通",
label: o.label || o.type || "委托",
price: num(o.price != null ? o.price : o.trigger_price),
amount: num(o.amount),
});
});
return {
side: (pos.side || "long").toLowerCase(),
entry: num(tpsl.entry),
stop_loss: num(tpsl.sl),
take_profit: num(tpsl.tp),
tp_monitored: !!tpsl.tp_monitored,
is_trend: !!tpsl.is_trend,
contracts: num(pos.contracts),
orders: orders,
};
}
const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext";
function encodePosCtx(ctx) {
try {
return btoa(unescape(encodeURIComponent(JSON.stringify(ctx))));
} catch (e) {
return "";
}
}
function decodePosCtx(raw) {
if (!raw) return null;
try {
return JSON.parse(decodeURIComponent(escape(atob(raw))));
} catch (e) {
return null;
}
}
function marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan) {
const symAttr = esc(symbol || "").replace(/"/g, """);
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """);
const ctxEnc = esc(encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan))).replace(
/"/g,
"""
);
return (
'data-ex-id="' +
esc(exchangeId) +
'" data-ex-key="' +
exKeyAttr +
'" data-symbol="' +
symAttr +
'" data-pos-ctx="' +
ctxEnc +
'"'
);
}
function openMarketForPosition(exchangeId, symbol, exchangeKey, posCtxRaw) {
const exKey = exchangeKey || resolveExchangeKey(exchangeId);
const sym = normalizeMarketSymbol(symbol);
if (!exKey || !sym) {
showToast("无法打开行情:缺少交易所或合约", true);
return;
}
const ctx = decodePosCtx(posCtxRaw);
if (ctx) {
ctx.symbol = sym;
ctx.exchange_key = exKey;
sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(ctx));
} else {
sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY);
}
if (expandedExchangeId) {
closeExchangeFullscreen();
}
const qs = new URLSearchParams({ exchange_key: exKey, symbol: sym });
history.pushState({}, "", "/market?" + qs.toString());
setActiveNav();
if (window.hubMarketChart && window.hubMarketChart.openWith) {
window.hubMarketChart.openWith(exKey, sym);
}
}
function bindMonitorInteractions(box) {
box.querySelectorAll(".btn-open-market").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
openMarketForPosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.exKey, btn.dataset.posCtx);
};
});
box.querySelectorAll(".btn-open-instance").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
openInstance(btn.dataset.exId, btn.dataset.next || "/", {
newTab: ev.ctrlKey || ev.metaKey,
});
};
});
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(".card-expand-zone").forEach((zone) => {
zone.onclick = (ev) => {
if (ev.target.closest("a, button, input, summary, details, .card-actions")) return;
const id = zone.closest(".card")?.dataset.exId;
if (id) openExchangeFullscreen(id);
};
});
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 `${hint}
`;
}
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 `
| ${esc(o.label || o.type || "委托")} |
${fmt(o.amount, 4)} |
${trig} |
|
`;
})
.join("");
return ``;
}
function guessTpslFromCondOrders(side, cond, entry) {
return inferTpslFromCondOrders(side, cond, entry);
}
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
? ``
: "";
const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional");
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit");
return `
委托单 ${orderTotal}
条件 ${cond.length} · 普通 ${reg.length}
${condAllBtn}
`;
}
function pickExTpslOrders(cond) {
let sl = cond.find((o) => /^止损\b/.test(o.label || ""));
let tp = cond.find((o) => /^止盈\b/.test(o.label || "") && !(o.label || "").includes("止盈止损"));
if (!sl || !tp) {
const combo = cond.find((o) => (o.label || "").includes("止盈止损"));
if (combo) {
const m = (combo.label || "").match(/SL=([\d.eE+-]+).*TP=([\d.eE+-]+)/i);
if (m) {
if (!sl) sl = { ...combo, label: "止损", trigger_price: Number(m[1]) };
if (!tp) tp = { ...combo, label: "止盈", trigger_price: Number(m[2]) };
}
}
}
if (!sl) sl = cond.find((o) => (o.label || "").includes("止损"));
if (!tp) tp = cond.find((o) => (o.label || "").includes("止盈") && o !== sl);
return { sl, tp };
}
function renderExTpslRows(exchangeId, symbol, cond) {
const symAttr = esc(symbol || "").replace(/"/g, """);
const { sl, tp } = pickExTpslOrders(cond);
function row(label, o) {
if (!o) {
return `${label}:—
`;
}
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 `
${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}
`;
}
return row("止损", sl) + row("止盈", tp);
}
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan) {
const symbol = pos.symbol || "";
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """);
const side = (pos.side || "long").toLowerCase();
const sideCn = sideDirLabel(side);
const sideCls = sideDirCls(side) || "side-long";
const mo = monitorOrder || {};
const cond = condOrdersFromPosition(pos);
const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : [];
const tpsl = resolvePositionTpsl(pos, mo, trendPlan);
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(tpsl.sl)).replace(/"/g, """);
const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """);
const entry = tpsl.entry;
const sl = tpsl.sl;
const tp = tpsl.tp;
const tpMonitored = tpsl.tp_monitored;
const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored);
const beSecured = isBreakevenSecured(side, entry, mo, cond, pos);
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(
`移动保本:${beOn ? "开" : "关"}`
);
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
return `
${symBeBadge}
${sideCn}
${meta.map((m) => `${m}`).join("")}
成交价${entry != null ? fmt(entry, 4) : "—"}
止损${sl != null && sl !== "" ? fmt(sl, 4) : "—"}
止盈${tpMonitored ? "程序监控" : tp != null && tp !== "" ? fmt(tp, 4) : "—"}
盈亏比${tpMonitored ? "—" : rr != null ? fmt(rr, 2) + ":1" : "-:1"}
张数${fmt(pos.contracts, 4)}
浮盈亏${pnlText}
交易所止盈止损
${renderExTpslRows(exchangeId, symbol, cond)}
${renderOrdersCollapse(exchangeId, symbol, cond, reg)}
`;
}
function renderHubSectionCard(title, bodyHtml, emptyHint) {
const inner = bodyHtml || `${esc(emptyHint || "暂无")}
`;
return ``;
}
function renderKeySection(keys, kmap) {
if (!keys.length) return "";
const cards = keys
.map((k) => {
const kp = kmap[k.id] || kmap[String(k.id)] || {};
const mt = k.monitor_type || k.type || "";
const pending = keyHasPendingOrder(k, kp);
const cardCls = pending ? "hub-mini-card hub-key-pending" : "hub-mini-card";
const dir = k.direction ? ` · ${renderDirectionHtml(k.direction)}` : "";
const pendingTag = pending
? `挂单中`
: "";
const amtTxt = fmtKeyOrderAmount(k);
const amtLine = amtTxt
? `挂单数量 ${esc(amtTxt)}
`
: "";
return `
${esc(k.symbol)} · ${esc(mt)}${dir} ${pendingTag}
上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}
${amtLine}
${esc(kp.gate_summary || kp.price_display || kp.price || "—")}${kp.gate_metrics ? ` · ${esc(kp.gate_metrics)}` : ""}
`;
})
.join("");
return `${cards}
`;
}
function renderOrderMonitorSection(orders) {
if (!orders || !orders.length) return "";
return orders
.map(
(o) => `
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)}
触发 ${fmt(o.trigger_price, 4)} · SL ${fmt(o.stop_loss, 4)} · TP ${fmt(o.take_profit, 4)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}
`
)
.join("");
}
function renderTrendSection(trends) {
if (!trends || !trends.length) return "";
return trends
.map(
(t) => `
#${esc(t.id)} · ${esc(t.symbol)} · ${renderDirectionHtml(t.direction)}
SL ${fmt(t.stop_loss, 4)} · TP ${fmt(t.take_profit, 4)} · 状态 ${esc(t.status || "active")}
`
)
.join("");
}
function renderRollSection(rolls) {
if (!rolls || !rolls.length) return "";
return rolls
.map(
(g) => `
组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmt(g.current_stop_loss, 4)} · ${esc(g.status || "active")}
`
)
.join("");
}
function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan) {
const symAttr = esc(x.symbol || "").replace(/"/g, """);
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """);
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """);
const side = sideAttr || "long";
const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace(/"/g, """);
const cond = condOrdersFromPosition(x);
const reg = Array.isArray(x.regular_orders) ? x.regular_orders : [];
const tpsl = resolvePositionTpsl(x, monitorOrder, trendPlan);
const beSecured = isBreakevenSecured(side, tpsl.entry, monitorOrder, cond, x);
const slAttr = esc(String(tpsl.sl)).replace(/"/g, """);
const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """);
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, x.symbol, x, monitorOrder, trendPlan);
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
return `
${renderOrdersCollapse(exchangeId, x.symbol, cond, reg)}
`;
}
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
let inner = `
余额
${fmt(ag.balance_usdt, 2)} U
浮盈合计
${fmt(ag.total_unrealized_pnl, 2)}
`;
inner += `交易所持仓 · ${pos.length} 仓
`;
if (pos.length) {
inner += pos
.map((p) =>
renderPositionBlock(
row.id,
row.key || row.id,
p,
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side)
)
)
.join("");
} else {
inner += '无持仓
';
}
if (orders.length) {
inner += `下单监控 · ${orders.length}
`;
orders.forEach((o) => {
inner += `${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)} · 触发 ${fmt(o.trigger_price, 4)}
`;
});
}
if ((row.capabilities || []).includes("key")) {
inner += `关键位 · ${keys.length}
`;
if (!flaskOk) {
const fe = row.flask_error || hm.msg || hm.error || "策略 Flask 未连通";
inner += `${esc(fe)}
`;
} else if (!keys.length) {
inner += '当前无记录
';
} else {
keys.forEach((k) => {
const kp = kmap[k.id] || kmap[String(k.id)] || {};
const mt = k.monitor_type || k.type || "";
const pending = keyHasPendingOrder(k, kp);
const lineCls = pending ? "list-line hub-key-pending" : "list-line";
let line = `${esc(k.symbol)} · ${esc(mt)}`;
if (k.direction) line += ` · ${renderDirectionHtml(k.direction)}`;
if (pending) line += ` · 挂单`;
const amtTxt = fmtKeyOrderAmount(k);
if (amtTxt) line += ` · 数量 ${esc(amtTxt)}`;
line += ` · ${esc(k.upper)} / ${esc(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)} ${renderDirectionHtml(t.direction)} · SL ${t.stop_loss} · TP ${t.take_profit}
`;
});
}
if (rolls.length) {
inner += `顺势加仓 · ${rolls.length}
`;
rolls.forEach((g) => {
inner += `组 #${g.id} · 监控 #${g.order_monitor_id || "—"} · ${g.leg_count != null ? g.leg_count : "—"} 腿
`;
});
}
inner += `点击标题栏放大全屏 · 查看持仓卡片 / 关键位 / 策略详情
`;
return inner;
}
function renderFullscreenExchange(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 rolls = flaskOk ? hm.rolls || [] : [];
const kmap = {};
(row.key_prices || []).forEach((k) => {
kmap[k.id] = k;
});
const flaskOpen = row.flask_url_browser || row.flask_url;
let html = `
${esc(row.name)}
${esc(flaskOpen || "")}
${flaskOpen ? `
打开实例` : ""}
${flaskOpen ? `
策略交易` : ""}
`;
if (!row.http_ok || ag.ok === false) {
html += `${esc(row.error || ag.error || "子代理不可用")}
`;
return html;
}
html += `
余额
${fmt(ag.balance_usdt, 2)} U
浮盈合计
${fmt(ag.total_unrealized_pnl, 2)}
`;
const posCount = pos.length;
const posListCls = hubPosListCountClass(posCount);
html += `持仓(${posCount} 仓 · 每币种一卡)
`;
html += ``;
if (posCount) {
pos.forEach((p) => {
html += renderLivePositionCard(
row.id,
row.key || row.id,
p,
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side)
);
});
} else {
html += '
暂无持仓
';
}
html += "
";
if ((row.capabilities || []).includes("key")) {
if (!flaskOk) {
html += renderHubSectionCard("关键位", `${esc(row.flask_error || hm.error || "Flask 未连通")}
`, "");
} else {
html += renderHubSectionCard(
`关键位 · ${keys.length}`,
renderKeySection(keys, kmap),
"当前无关键位记录"
);
}
}
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders), "暂无运行中的下单监控");
if ((row.capabilities || []).includes("trend")) {
html += renderHubSectionCard("趋势回调", renderTrendSection(trends), "暂无运行中的趋势回调计划");
}
html += renderHubSectionCard("顺势加仓", renderRollSection(rolls), "暂无运行中的顺势加仓组");
return html;
}
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 initInstanceFrame() {
const back = document.getElementById("instance-frame-back");
const refresh = document.getElementById("instance-frame-refresh");
const newTab = document.getElementById("instance-frame-newtab");
const frame = document.getElementById("instance-frame");
if (back) back.onclick = () => closeInstanceFrame();
if (refresh) refresh.onclick = () => refreshInstanceFrame();
if (newTab) {
newTab.onclick = () => {
if (instanceFrameCtx) {
openInstance(instanceFrameCtx.exchangeId, instanceFrameCtx.nextPath, {
newTab: true,
});
return;
}
if (instanceFrameUrl) window.open(instanceFrameUrl, "_blank", "noopener");
};
}
}
function initFullscreen() {
const backdrop = document.getElementById("exchange-fullscreen-backdrop");
if (backdrop) {
backdrop.onclick = () => {
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
};
}
const fs = document.getElementById("exchange-fullscreen");
if (fs && !expandedExchangeId) {
fs.classList.add("hidden");
fs.setAttribute("aria-hidden", "true");
}
}
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();
const shell = document.getElementById("instance-frame-shell");
if (shell && !shell.classList.contains("hidden")) {
closeInstanceFrame();
return;
}
if (expandedExchangeId) {
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
}
}
});
}
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 rolls = flaskOk ? hm.rolls || [] : [];
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 = renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap);
}
const online = row.http_ok && agOk;
const cardCls = online ? "card-online" : "card-offline";
const dotCls = online ? "ok" : "bad";
const flaskOpen = row.flask_url_browser || row.flask_url;
const openFlask = flaskOpen
? `实例`
: "";
const openReview = flaskOpen
? `复盘`
: "";
return `
${openFlask}
${openReview}
${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 后点「保存设置」");
};
initTpslModal();
initInstanceFrame();
initFullscreen();
initMobileLayout();
initAuth().then((ok) => {
if (!ok) return;
setActiveNav();
if (currentPage() === "settings") {
loadSettings().catch(() => {});
}
window.addEventListener("popstate", setActiveNav);
});
})();