c1e0e52f8c
Co-authored-by: Cursor <cursoragent@cursor.com>
3991 lines
146 KiB
JavaScript
3991 lines
146 KiB
JavaScript
(function () {
|
||
const toast = document.getElementById("toast");
|
||
let settingsCache = null;
|
||
let authState = { required: false, logged_in: true };
|
||
|
||
function displayPref(key, defaultOn) {
|
||
const d = settingsCache && settingsCache.display;
|
||
if (!d || d[key] === undefined) return defaultOn !== false;
|
||
return !!d[key];
|
||
}
|
||
|
||
function showAccountPnlPref() {
|
||
return displayPref("show_account_pnl", true);
|
||
}
|
||
|
||
function showNavFundsPref() {
|
||
return displayPref("show_nav_funds", true);
|
||
}
|
||
|
||
function showNavDashboardPref() {
|
||
return displayPref("show_nav_dashboard", true);
|
||
}
|
||
|
||
function syncNavVisibility(data) {
|
||
const d = (data && data.display) || {};
|
||
const navFunds = document.getElementById("nav-funds");
|
||
const navDash = document.getElementById("nav-dashboard");
|
||
if (navFunds) navFunds.classList.toggle("nav-hidden", d.show_nav_funds === false);
|
||
if (navDash) navDash.classList.toggle("nav-hidden", d.show_nav_dashboard === false);
|
||
}
|
||
|
||
function pageNavAllowed(page) {
|
||
if (page === "funds") return showNavFundsPref();
|
||
if (page === "dashboard") return showNavDashboardPref();
|
||
return true;
|
||
}
|
||
|
||
function syncDisplayPrefsUI(data) {
|
||
const d = (data && data.display) || {};
|
||
const pnlCb = document.getElementById("pref-show-account-pnl");
|
||
const fundsCb = document.getElementById("pref-show-nav-funds");
|
||
const dashCb = document.getElementById("pref-show-nav-dashboard");
|
||
if (pnlCb) pnlCb.checked = d.show_account_pnl !== false;
|
||
if (fundsCb) fundsCb.checked = d.show_nav_funds !== false;
|
||
if (dashCb) dashCb.checked = d.show_nav_dashboard !== false;
|
||
syncNavVisibility(data);
|
||
}
|
||
|
||
function positionTableHeadHtml(compact) {
|
||
const pnlTh = showAccountPnlPref() ? "<th>浮盈</th>" : "";
|
||
const cls = compact ? " data-table data-table-positions" : "";
|
||
return `<table class="data-table${cls}"><thead><tr><th>合约</th><th>方向</th><th>开仓价</th><th>标记价</th><th>张数</th>${pnlTh}<th>操作</th></tr></thead><tbody>`;
|
||
}
|
||
let tpslPending = null;
|
||
let lastMonitorRows = [];
|
||
let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || "";
|
||
const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1";
|
||
const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||
const MONITOR_BOARD_SNAPSHOT_URL = "/api/monitor/board/snapshot";
|
||
const HUB_MONITOR_SNAPSHOT_TIMEOUT_MS = 15000;
|
||
/** 关注:浮亏超过交易账户余额的比例(10%) */
|
||
const HUB_ALERT_FLOAT_LOSS_RATIO = 0.1;
|
||
let lastMonitorBoardUpdatedAt = "";
|
||
let localBoardVersion = 0;
|
||
let monitorBoardInFlight = false;
|
||
let monitorBoardFetchPending = false;
|
||
let monitorBoardSlowHintTimer = null;
|
||
let boardEventSource = null;
|
||
let sseReconnectTimer = null;
|
||
let hostStatusTimer = null;
|
||
const HOST_STATUS_POLL_MS = 5000;
|
||
const HOST_STATUS_OPEN_KEY = "hub-host-status-open";
|
||
const HOST_RESOURCE_ALERT_THRESHOLD = 85;
|
||
const hostResourceAlertLatch = { cpu: false, mem: false };
|
||
|
||
function loadBoolPref(key, defaultValue) {
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (raw === "1" || raw === "true") return true;
|
||
if (raw === "0" || raw === "false") return false;
|
||
} catch (_) {}
|
||
return !!defaultValue;
|
||
}
|
||
|
||
function saveBoolPref(key, on) {
|
||
try {
|
||
localStorage.setItem(key, on ? "1" : "0");
|
||
} catch (_) {}
|
||
}
|
||
|
||
function fmtHostBytes(n) {
|
||
const v = Number(n);
|
||
if (!Number.isFinite(v)) return "—";
|
||
const abs = Math.abs(v);
|
||
if (abs >= 1e12) return (v / 1e12).toFixed(2) + " TB";
|
||
if (abs >= 1e9) return (v / 1e9).toFixed(2) + " GB";
|
||
if (abs >= 1e6) return (v / 1e6).toFixed(2) + " MB";
|
||
if (abs >= 1e3) return (v / 1e3).toFixed(1) + " KB";
|
||
return v.toFixed(0) + " B";
|
||
}
|
||
|
||
function fmtHostUptime(sec) {
|
||
const s = Math.max(0, Number(sec) || 0);
|
||
const d = Math.floor(s / 86400);
|
||
const h = Math.floor((s % 86400) / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
if (d > 0) return d + "天" + h + "时";
|
||
if (h > 0) return h + "时" + m + "分";
|
||
return m + "分";
|
||
}
|
||
|
||
function hostMetricLevel(percent) {
|
||
const p = Number(percent);
|
||
if (!Number.isFinite(p)) return "ok";
|
||
if (p >= 90) return "bad";
|
||
if (p >= 75) return "warn";
|
||
return "ok";
|
||
}
|
||
|
||
function setHostMetricBar(fillEl, percent) {
|
||
if (!fillEl) return;
|
||
const p = Math.max(0, Math.min(100, Number(percent) || 0));
|
||
const level = hostMetricLevel(p);
|
||
fillEl.style.width = p + "%";
|
||
fillEl.classList.remove("warn", "bad");
|
||
if (level === "warn") fillEl.classList.add("warn");
|
||
if (level === "bad") fillEl.classList.add("bad");
|
||
}
|
||
|
||
function checkHostResourceAlert(cpu, mem) {
|
||
const msgs = [];
|
||
const cpuP = Number(cpu && cpu.percent);
|
||
if (Number.isFinite(cpuP) && cpuP >= HOST_RESOURCE_ALERT_THRESHOLD) {
|
||
if (!hostResourceAlertLatch.cpu) {
|
||
msgs.push("CPU 使用率 " + cpuP + "%");
|
||
hostResourceAlertLatch.cpu = true;
|
||
}
|
||
} else {
|
||
hostResourceAlertLatch.cpu = false;
|
||
}
|
||
const memP = Number(mem && mem.percent);
|
||
if (Number.isFinite(memP) && memP >= HOST_RESOURCE_ALERT_THRESHOLD) {
|
||
if (!hostResourceAlertLatch.mem) {
|
||
msgs.push("内存使用率 " + memP + "%");
|
||
hostResourceAlertLatch.mem = true;
|
||
}
|
||
} else {
|
||
hostResourceAlertLatch.mem = false;
|
||
}
|
||
if (msgs.length) {
|
||
window.alert(
|
||
"服务器资源告警\n\n" + msgs.join("\n") + "\n\n请及时关注中控服务器负载。"
|
||
);
|
||
}
|
||
}
|
||
|
||
function hostStatusSummaryText(data) {
|
||
if (!data || !data.ok) return (data && data.msg) || "状态不可用";
|
||
const cpu = data.cpu || {};
|
||
const mem = data.memory || {};
|
||
const disk = data.disk || {};
|
||
const parts = [];
|
||
const host = String(data.hostname || "").trim();
|
||
if (host) parts.push(host);
|
||
if (cpu.percent != null) parts.push("CPU " + cpu.percent + "%");
|
||
if (mem.percent != null) parts.push("内存 " + mem.percent + "%");
|
||
if (disk.percent != null) parts.push("硬盘 " + disk.percent + "%");
|
||
return parts.join(" · ") || "—";
|
||
}
|
||
|
||
let hostStatusPanelInited = false;
|
||
|
||
function initHostStatusPanel() {
|
||
const panel = document.getElementById("host-status-panel");
|
||
if (!panel) return;
|
||
panel.classList.remove("hidden");
|
||
if (!hostStatusPanelInited) {
|
||
panel.open = loadBoolPref(HOST_STATUS_OPEN_KEY, false);
|
||
panel.addEventListener("toggle", function () {
|
||
saveBoolPref(HOST_STATUS_OPEN_KEY, !!panel.open);
|
||
});
|
||
hostStatusPanelInited = true;
|
||
}
|
||
}
|
||
|
||
function renderHostStatusBar(data) {
|
||
const panel = document.getElementById("host-status-panel");
|
||
const summaryText = document.getElementById("host-status-summary-text");
|
||
const bar = document.getElementById("host-status-bar");
|
||
if (!panel || !bar) return;
|
||
const dot = document.getElementById("host-status-dot");
|
||
const name = document.getElementById("host-status-name");
|
||
const uptime = document.getElementById("host-status-uptime");
|
||
const updated = document.getElementById("host-status-updated");
|
||
const cpuVal = document.getElementById("host-cpu-val");
|
||
const cpuSub = document.getElementById("host-cpu-sub");
|
||
const memVal = document.getElementById("host-mem-val");
|
||
const memSub = document.getElementById("host-mem-sub");
|
||
const diskVal = document.getElementById("host-disk-val");
|
||
const diskSub = document.getElementById("host-disk-sub");
|
||
const netUp = document.getElementById("host-net-up");
|
||
const netDown = document.getElementById("host-net-down");
|
||
panel.classList.remove("hidden");
|
||
if (summaryText) summaryText.textContent = hostStatusSummaryText(data);
|
||
if (!data || !data.ok) {
|
||
if (dot) dot.className = "host-status-dot bad";
|
||
if (name) {
|
||
name.textContent = "服务器";
|
||
name.title = "";
|
||
}
|
||
if (uptime) uptime.textContent = (data && data.msg) || "状态不可用";
|
||
if (updated) updated.textContent = "";
|
||
if (cpuVal) cpuVal.textContent = "—";
|
||
if (cpuSub) cpuSub.textContent = "";
|
||
if (memVal) memVal.textContent = "—";
|
||
if (memSub) memSub.textContent = "";
|
||
if (diskVal) diskVal.textContent = "—";
|
||
if (diskSub) diskSub.textContent = "";
|
||
if (netUp) netUp.textContent = "↑ —";
|
||
if (netDown) netDown.textContent = "↓ —";
|
||
return;
|
||
}
|
||
const cpu = data.cpu || {};
|
||
const mem = data.memory || {};
|
||
const disk = data.disk || {};
|
||
const net = data.network || {};
|
||
checkHostResourceAlert(cpu, mem);
|
||
const levels = [
|
||
hostMetricLevel(cpu.percent),
|
||
hostMetricLevel(mem.percent),
|
||
hostMetricLevel(disk.percent),
|
||
];
|
||
let overall = "ok";
|
||
if (levels.includes("bad")) overall = "bad";
|
||
else if (levels.includes("warn")) overall = "warn";
|
||
if (dot) dot.className = "host-status-dot " + overall;
|
||
const hostname = data.hostname || "服务器";
|
||
if (name) {
|
||
name.textContent = hostname;
|
||
name.title = hostname;
|
||
}
|
||
if (uptime) uptime.textContent = "运行 " + fmtHostUptime(data.uptime_sec);
|
||
if (updated) updated.textContent = data.updated_at ? "更新 " + data.updated_at : "";
|
||
setHostMetricBar(document.getElementById("host-cpu-fill"), cpu.percent);
|
||
setHostMetricBar(document.getElementById("host-mem-fill"), mem.percent);
|
||
setHostMetricBar(document.getElementById("host-disk-fill"), disk.percent);
|
||
if (cpuVal) cpuVal.textContent = cpu.percent != null ? cpu.percent + "%" : "—";
|
||
if (cpuSub) cpuSub.textContent = cpu.count ? cpu.count + " 核" : "";
|
||
if (memVal) memVal.textContent = mem.percent != null ? mem.percent + "%" : "—";
|
||
if (memSub) {
|
||
memSub.textContent =
|
||
fmtHostBytes(mem.used_bytes) + " / " + fmtHostBytes(mem.total_bytes);
|
||
}
|
||
if (diskVal) diskVal.textContent = disk.percent != null ? disk.percent + "%" : "—";
|
||
if (diskSub) {
|
||
diskSub.textContent =
|
||
fmtHostBytes(disk.used_bytes) + " / " + fmtHostBytes(disk.total_bytes);
|
||
}
|
||
if (netUp) netUp.textContent = "↑ " + fmtHostBytes(net.sent_rate_bps) + "/s";
|
||
if (netDown) netDown.textContent = "↓ " + fmtHostBytes(net.recv_rate_bps) + "/s";
|
||
}
|
||
|
||
async function fetchHostStatus() {
|
||
if (currentPage() !== "monitor") return;
|
||
try {
|
||
const r = await apiFetch("/api/host/status", { credentials: "same-origin" });
|
||
const data = await r.json();
|
||
renderHostStatusBar(data);
|
||
} catch (err) {
|
||
renderHostStatusBar({ ok: false, msg: String(err && err.message ? err.message : err) });
|
||
}
|
||
}
|
||
|
||
function stopHostStatusPoll() {
|
||
if (hostStatusTimer) {
|
||
clearInterval(hostStatusTimer);
|
||
hostStatusTimer = null;
|
||
}
|
||
}
|
||
|
||
function startHostStatusPoll() {
|
||
stopHostStatusPoll();
|
||
initHostStatusPanel();
|
||
void fetchHostStatus();
|
||
hostStatusTimer = setInterval(fetchHostStatus, HOST_STATUS_POLL_MS);
|
||
}
|
||
|
||
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");
|
||
if (globalThis.HubTheme && typeof HubTheme.get === "function") {
|
||
q.set("hub_theme", HubTheme.get());
|
||
}
|
||
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");
|
||
if (frame.dataset.themeSyncBound !== "1") {
|
||
frame.dataset.themeSyncBound = "1";
|
||
frame.addEventListener("load", function syncInstanceFrameTheme() {
|
||
try {
|
||
if (globalThis.HubTheme && typeof HubTheme.get === "function" && frame.contentWindow) {
|
||
frame.contentWindow.postMessage(
|
||
{ type: "hub-theme-sync", theme: HubTheme.get() },
|
||
"*"
|
||
);
|
||
}
|
||
} catch (_) {}
|
||
});
|
||
}
|
||
}
|
||
|
||
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, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function fmt(n, d) {
|
||
if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
|
||
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d });
|
||
}
|
||
|
||
/** 交易所持仓开仓价(四所子代理 entry_price) */
|
||
function positionEntryPrice(pos) {
|
||
if (!pos) return null;
|
||
const n = Number(pos.entry_price);
|
||
if (!Number.isFinite(n) || n <= 0) return null;
|
||
return n;
|
||
}
|
||
|
||
function symbolPriceKey(sym) {
|
||
return (sym || "").trim().toUpperCase();
|
||
}
|
||
|
||
function buildPriceTickMap(row) {
|
||
const map = Object.create(null);
|
||
const put = (sym, tick) => {
|
||
const k = symbolPriceKey(sym);
|
||
if (!k || tick == null || !Number.isFinite(Number(tick))) return;
|
||
if (map[k] == null) map[k] = Number(tick);
|
||
};
|
||
((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick));
|
||
const hm = (row && row.hub_monitor) || {};
|
||
(hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick));
|
||
(hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick));
|
||
return map;
|
||
}
|
||
|
||
function lookupPriceTick(symbol, tickMap) {
|
||
if (!tickMap || !symbol) return null;
|
||
const k = symbolPriceKey(symbol);
|
||
if (tickMap[k] != null) return tickMap[k];
|
||
const base = normSym(symbol);
|
||
if (base && tickMap[base] != null) return tickMap[base];
|
||
return null;
|
||
}
|
||
|
||
function decimalsFromTick(tick) {
|
||
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
|
||
const t = Number(tick);
|
||
if (t >= 1) return 0;
|
||
const s = t.toFixed(12).replace(/0+$/, "");
|
||
const frac = s.split(".")[1];
|
||
return frac ? Math.min(12, frac.length) : 0;
|
||
}
|
||
|
||
function defaultPriceDecimals(value) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return 4;
|
||
const av = Math.abs(n);
|
||
if (av >= 10000) return 2;
|
||
if (av >= 100) return 3;
|
||
if (av >= 1) return 4;
|
||
if (av >= 0.01) return 6;
|
||
return 8;
|
||
}
|
||
|
||
/** 按交易所 tick(子代理/Flask 下发)格式化价格 */
|
||
function fmtSymbolPrice(value, symbol, tickMap, displayFallback) {
|
||
if (displayFallback != null && displayFallback !== "") return String(displayFallback);
|
||
if (value == null || value === "") return "—";
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return "—";
|
||
const tick = lookupPriceTick(symbol, tickMap);
|
||
const d = decimalsFromTick(tick);
|
||
return fmt(n, d != null ? d : defaultPriceDecimals(n));
|
||
}
|
||
|
||
function fmtEntryPrice(pos, tickMap) {
|
||
if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt);
|
||
return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap);
|
||
}
|
||
|
||
function positionMarkPrice(pos) {
|
||
if (!pos) return null;
|
||
const n = Number(pos.mark_price);
|
||
if (!Number.isFinite(n) || n <= 0) return null;
|
||
return n;
|
||
}
|
||
|
||
function fmtMarkPrice(pos, tickMap) {
|
||
if (pos && pos.mark_price_fmt) return String(pos.mark_price_fmt);
|
||
return fmtSymbolPrice(positionMarkPrice(pos), pos && pos.symbol, tickMap);
|
||
}
|
||
|
||
function resolveTrendPositionRatioPct(trendPlan) {
|
||
const t = trendPlan || {};
|
||
if (t.position_ratio_pct != null && t.position_ratio_pct !== "") {
|
||
const n = Number(t.position_ratio_pct);
|
||
if (Number.isFinite(n)) return n;
|
||
}
|
||
const snap = Number(t.snapshot_available_usdt);
|
||
const margin = Number(t.plan_margin_capital);
|
||
if (Number.isFinite(snap) && snap > 0 && Number.isFinite(margin) && margin > 0) {
|
||
return Math.round((margin / snap) * 10000) / 100;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function resolveTrendSizingFooter(mo, trendPlan, isTrend) {
|
||
if (!isTrend || !trendPlan || !trendPlan.id) {
|
||
return {
|
||
leverage: mo.leverage,
|
||
planBase: mo.margin_capital,
|
||
positionRatio: mo.position_ratio,
|
||
};
|
||
}
|
||
const base =
|
||
trendPlan.snapshot_available_usdt != null && trendPlan.snapshot_available_usdt !== ""
|
||
? trendPlan.snapshot_available_usdt
|
||
: trendPlan.plan_margin_capital;
|
||
return {
|
||
leverage: trendPlan.leverage,
|
||
planBase: base,
|
||
positionRatio: resolveTrendPositionRatioPct(trendPlan),
|
||
};
|
||
}
|
||
|
||
function formatMonitorRiskMeta(mo, trendPlan) {
|
||
const m = mo || {};
|
||
const t = trendPlan || {};
|
||
const amt =
|
||
m.risk_amount != null && m.risk_amount !== ""
|
||
? Number(m.risk_amount)
|
||
: t.risk_amount != null && t.risk_amount !== ""
|
||
? Number(t.risk_amount)
|
||
: null;
|
||
const pctRaw =
|
||
m.risk_percent != null && m.risk_percent !== ""
|
||
? m.risk_percent
|
||
: t.risk_percent != null && t.risk_percent !== ""
|
||
? t.risk_percent
|
||
: null;
|
||
if (pctRaw == null || pctRaw === "") {
|
||
if (amt != null && Number.isFinite(amt)) {
|
||
return `风险: ${fmt(amt, 2)}U`;
|
||
}
|
||
return null;
|
||
}
|
||
const pct = esc(pctRaw);
|
||
if (amt != null && Number.isFinite(amt)) {
|
||
return `风险: ${pct}%≈${fmt(amt, 2)}U`;
|
||
}
|
||
return `风险: ${pct}%`;
|
||
}
|
||
|
||
function resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) {
|
||
const fromPos = fmtMarkPrice(pos, tickMap);
|
||
if (fromPos && fromPos !== "—") return fromPos;
|
||
const t = trendPlan || {};
|
||
const sym = symbol || (pos && pos.symbol) || t.exchange_symbol || t.symbol || "";
|
||
if (t.floating_mark != null && t.floating_mark !== "") {
|
||
return fmtSymbolPrice(t.floating_mark, sym, tickMap);
|
||
}
|
||
if (t.last_mark_price != null && t.last_mark_price !== "") {
|
||
return fmtSymbolPrice(t.last_mark_price, sym, tickMap);
|
||
}
|
||
return "—";
|
||
}
|
||
|
||
function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) {
|
||
const e = Number(entry);
|
||
const m = Number(mark);
|
||
const c = Math.abs(Number(contracts));
|
||
let mult = Number(contractSize);
|
||
if (!Number.isFinite(mult) || mult <= 0) mult = 1;
|
||
if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) {
|
||
return null;
|
||
}
|
||
const diff =
|
||
(side || "long").toLowerCase() === "long" ? m - e : e - m;
|
||
return Math.round(diff * c * mult * 100) / 100;
|
||
}
|
||
|
||
/** 展示浮盈:子代理 unrealized_pnl;与 entry/mark/张数 推算偏差 >20% 时用推算值 */
|
||
function resolvePositionUpnlUsdt(pos, trendPlan, markOverride) {
|
||
const p = pos || {};
|
||
const t = trendPlan || {};
|
||
let exchange =
|
||
p.unrealized_pnl != null && p.unrealized_pnl !== ""
|
||
? Number(p.unrealized_pnl)
|
||
: null;
|
||
if (exchange != null && !Number.isFinite(exchange)) exchange = null;
|
||
const entry =
|
||
t.avg_entry_price != null && t.avg_entry_price !== ""
|
||
? Number(t.avg_entry_price)
|
||
: p.entry_price != null && p.entry_price !== ""
|
||
? Number(p.entry_price)
|
||
: t.trigger_price != null
|
||
? Number(t.trigger_price)
|
||
: null;
|
||
let mark =
|
||
markOverride != null && Number.isFinite(Number(markOverride))
|
||
? Number(markOverride)
|
||
: p.mark_price != null && p.mark_price !== ""
|
||
? Number(p.mark_price)
|
||
: t.floating_mark != null
|
||
? Number(t.floating_mark)
|
||
: t.last_mark_price != null
|
||
? Number(t.last_mark_price)
|
||
: null;
|
||
const contracts = p.contracts;
|
||
const cs =
|
||
p.contract_size != null && p.contract_size !== ""
|
||
? Number(p.contract_size)
|
||
: 1;
|
||
const computed = estimateLinearSwapUpnl(
|
||
p.side || t.direction,
|
||
entry,
|
||
mark,
|
||
contracts,
|
||
cs
|
||
);
|
||
if (computed == null) {
|
||
if (exchange != null) return exchange;
|
||
if (t.floating_pnl != null && t.floating_pnl !== "") {
|
||
const n = Number(t.floating_pnl);
|
||
if (Number.isFinite(n)) return n;
|
||
}
|
||
return null;
|
||
}
|
||
if (exchange == null) return computed;
|
||
const ref = Math.max(Math.abs(computed), 1);
|
||
if (Math.abs(exchange - computed) / ref > 0.2) return computed;
|
||
return exchange;
|
||
}
|
||
|
||
function resolveTrendFloatingPnl(pos, trendPlan, markOverride) {
|
||
return resolvePositionUpnlUsdt(pos, trendPlan, markOverride);
|
||
}
|
||
|
||
function formatFloatingPnlText(upnl, notionalUsdt) {
|
||
if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" };
|
||
let pnlText = fmt(upnl, 2) + "U";
|
||
const notional = Number(notionalUsdt);
|
||
if (Number.isFinite(notional) && Math.abs(notional) > 1e-8) {
|
||
const pct = (Number(upnl) / Math.abs(notional)) * 100;
|
||
pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`;
|
||
}
|
||
return { text: pnlText, cls: pnlCls(upnl) };
|
||
}
|
||
|
||
/** 与实例策略页一致:浮盈亏 % = 浮盈亏 / 计划保证金 */
|
||
function formatTrendPlanFloatingPnl(upnl, planMargin) {
|
||
if (upnl == null || !Number.isFinite(Number(upnl))) {
|
||
return { text: "—", cls: "" };
|
||
}
|
||
let pnlText = fmt(upnl, 2) + "U";
|
||
const margin = Number(planMargin);
|
||
if (Number.isFinite(margin) && margin > 0) {
|
||
const pct = (Number(upnl) / margin) * 100;
|
||
pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`;
|
||
}
|
||
const n = Number(upnl);
|
||
let cls = "pnl-neutral";
|
||
if (n > 0) cls = "pnl-profit";
|
||
else if (n < 0) cls = "pnl-loss";
|
||
return { text: pnlText, cls };
|
||
}
|
||
|
||
function renderDirectionBadge(side) {
|
||
const s = normSide(side);
|
||
const label = sideDirLabel(side);
|
||
const cls = s === "long" ? "direction-long" : s === "short" ? "direction-short" : "";
|
||
if (!cls) return esc(String(label));
|
||
return `<span class="badge ${cls}">${esc(label)}</span>`;
|
||
}
|
||
|
||
function resolveTrendDcaLevels(t) {
|
||
if (Array.isArray(t.dca_levels) && t.dca_levels.length) return t.dca_levels;
|
||
const plan = t || {};
|
||
let grid = [];
|
||
let legAmounts = [];
|
||
try {
|
||
grid = JSON.parse(plan.grid_prices_json || "[]");
|
||
if (!Array.isArray(grid)) grid = [];
|
||
} catch (_e) {
|
||
grid = [];
|
||
}
|
||
try {
|
||
legAmounts = JSON.parse(plan.leg_amounts_json || "[]");
|
||
if (!Array.isArray(legAmounts)) legAmounts = [];
|
||
} catch (_e2) {
|
||
legAmounts = [];
|
||
}
|
||
const legsDone = Number(plan.legs_done) || 0;
|
||
const dcaLegs = Number(plan.dca_legs) || 0;
|
||
const firstDone = Number(plan.first_order_done) !== 0;
|
||
const out = [
|
||
{
|
||
label: "首仓",
|
||
price: null,
|
||
contracts: plan.first_order_amount,
|
||
status: firstDone ? "done" : "pending",
|
||
status_label: firstDone ? "已开仓" : "待开仓",
|
||
},
|
||
];
|
||
const n = Math.max(grid.length, legAmounts.length, dcaLegs);
|
||
for (let idx = 0; idx < n; idx += 1) {
|
||
const legI = idx + 1;
|
||
const done = legI <= legsDone;
|
||
out.push({
|
||
label: `补仓${legI}`,
|
||
price: idx < grid.length ? grid[idx] : null,
|
||
contracts: idx < legAmounts.length ? legAmounts[idx] : null,
|
||
status: done ? "done" : "pending",
|
||
status_label: done ? "已补仓" : "待补仓",
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
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 isTrendHandoffOrder(monitorOrder) {
|
||
const mo = monitorOrder || {};
|
||
return String(mo.trade_style || "").toLowerCase() === "trend_pullback_handoff";
|
||
}
|
||
|
||
function isTrendContext(monitorOrder, trendPlan) {
|
||
const mo = monitorOrder || {};
|
||
const tp = trendPlan || {};
|
||
if (tp.id != null && Number(tp.id) > 0) return true;
|
||
const tid = Number(mo.trend_plan_id);
|
||
if (Number.isFinite(tid) && tid > 0) return true;
|
||
const mt = String(mo.monitor_type || "").trim();
|
||
if (mt === "趋势回调") return true;
|
||
const kst = String(mo.key_signal_type || "").trim();
|
||
return kst === "趋势回调" || kst === "趋势回调计划";
|
||
}
|
||
|
||
function trendAddZoneLabel(direction) {
|
||
return (direction || "long").toLowerCase() === "short" ? "补仓下沿" : "补仓上沿";
|
||
}
|
||
|
||
function monitorOrderSourceLabel(mo, trendPlan) {
|
||
if (isTrendContext(mo, trendPlan)) return "趋势回调计划";
|
||
const o = mo || {};
|
||
const mt = String(o.monitor_type || "").trim();
|
||
return mt || "下单监控";
|
||
}
|
||
|
||
function monitorOrderSourceHtml(mo, trendPlan) {
|
||
if (isTrendContext(mo, trendPlan)) {
|
||
return `来源: ${esc(monitorOrderSourceLabel(mo, trendPlan))}`;
|
||
}
|
||
const src = monitorOrderSourceLabel(mo, trendPlan);
|
||
const kst = String((mo && mo.key_signal_type) || "").trim();
|
||
let text = src;
|
||
if (kst && kst !== src && !text.includes(kst)) {
|
||
text += " · " + kst;
|
||
}
|
||
return `来源: ${esc(text)}`;
|
||
}
|
||
|
||
function renderDirectionHtml(side) {
|
||
const cls = sideDirCls(side);
|
||
const label = sideDirLabel(side);
|
||
if (!cls) return esc(String(label));
|
||
return `<span class="${cls}">${esc(label)}</span>`;
|
||
}
|
||
|
||
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("archive")) return "archive";
|
||
if (p.includes("dashboard")) return "dashboard";
|
||
if (p.includes("funds")) return "funds";
|
||
if (p.includes("market")) return "market";
|
||
if (p.includes("/ai")) return "ai";
|
||
return "monitor";
|
||
}
|
||
|
||
function pageElementId(page) {
|
||
if (page === "settings") return "page-settings";
|
||
if (page === "archive") return "page-archive";
|
||
if (page === "dashboard") return "page-dashboard";
|
||
if (page === "funds") return "page-funds";
|
||
if (page === "market") return "page-market";
|
||
if (page === "ai") return "page-ai";
|
||
return "page-monitor";
|
||
}
|
||
|
||
function setActiveNav() {
|
||
let page = currentPage();
|
||
if (!pageNavAllowed(page)) {
|
||
history.replaceState({}, "", "/monitor");
|
||
page = "monitor";
|
||
}
|
||
const pageId = pageElementId(page);
|
||
document.querySelectorAll(".top-nav a").forEach((a) => {
|
||
const href = (a.getAttribute("href") || "").split("?")[0];
|
||
a.classList.toggle(
|
||
"active",
|
||
href === "/" + page || (page === "monitor" && (href === "/" || href === "/monitor"))
|
||
);
|
||
});
|
||
document.querySelectorAll(".page").forEach((el) => {
|
||
el.classList.toggle("hidden", el.id !== pageId);
|
||
});
|
||
document.body.classList.toggle("hub-page-ai", page === "ai");
|
||
document.body.classList.toggle("hub-page-funds", page === "funds");
|
||
document.body.classList.toggle("hub-page-dashboard", page === "dashboard");
|
||
syncHubAiMobileViewport();
|
||
if (page === "monitor") startMonitorPoll();
|
||
else stopMonitorPoll();
|
||
if (page === "dashboard" && window.hubDashboardPage) {
|
||
window.hubDashboardPage.init();
|
||
} else if (window.hubDashboardPage && window.hubDashboardPage.destroy) {
|
||
window.hubDashboardPage.destroy();
|
||
}
|
||
if (page === "settings") loadSettingsUI();
|
||
if (page === "ai") loadAiPage();
|
||
if (page === "archive" && window.hubArchivePage) {
|
||
window.hubArchivePage.init();
|
||
} else if (window.hubArchivePage && window.hubArchivePage.destroy) {
|
||
window.hubArchivePage.destroy();
|
||
}
|
||
if (page === "funds" && window.hubFundsPage) {
|
||
window.hubFundsPage.init();
|
||
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
|
||
window.hubFundsPage.destroy();
|
||
}
|
||
if (page === "market" && window.hubMarketChart) {
|
||
window.hubMarketChart.init();
|
||
} else if (window.hubMarketChart) {
|
||
if (window.hubMarketChart.stopChartLive) window.hubMarketChart.stopChartLive();
|
||
else {
|
||
if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh();
|
||
}
|
||
if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer();
|
||
}
|
||
}
|
||
|
||
function stopMonitorPoll() {
|
||
closeMonitorBoardStream();
|
||
stopHostStatusPoll();
|
||
if (sseReconnectTimer) {
|
||
clearTimeout(sseReconnectTimer);
|
||
sseReconnectTimer = null;
|
||
}
|
||
}
|
||
|
||
function closeMonitorBoardStream() {
|
||
if (boardEventSource) {
|
||
boardEventSource.close();
|
||
boardEventSource = null;
|
||
}
|
||
}
|
||
|
||
function connectMonitorBoardStream() {
|
||
closeMonitorBoardStream();
|
||
if (!document.getElementById("auto-monitor")?.checked) return;
|
||
if (currentPage() !== "monitor") return;
|
||
boardEventSource = new EventSource("/api/monitor/board/stream");
|
||
boardEventSource.addEventListener("board", (ev) => {
|
||
try {
|
||
const st = JSON.parse(ev.data || "{}");
|
||
const ver = Number(st.board_version) || 0;
|
||
if (ver !== localBoardVersion) {
|
||
void fetchMonitorBoardSnapshot({ background: true });
|
||
} else if (st.aggregating && lastMonitorRows.length) {
|
||
applyMonitorBoardUi(lastMonitorRows, st.updated_at || lastMonitorBoardUpdatedAt, {
|
||
stale: true,
|
||
});
|
||
}
|
||
} catch (_) {}
|
||
});
|
||
boardEventSource.onerror = () => {
|
||
closeMonitorBoardStream();
|
||
if (sseReconnectTimer) clearTimeout(sseReconnectTimer);
|
||
sseReconnectTimer = setTimeout(() => {
|
||
if (currentPage() === "monitor" && document.getElementById("auto-monitor")?.checked) {
|
||
connectMonitorBoardStream();
|
||
void fetchMonitorBoardSnapshot({ background: true });
|
||
}
|
||
}, 8000);
|
||
};
|
||
}
|
||
|
||
async function requestMonitorBoardRefresh() {
|
||
await apiFetch("/api/monitor/board/refresh", { method: "POST" });
|
||
}
|
||
|
||
function clearMonitorBoardSlowHint() {
|
||
if (monitorBoardSlowHintTimer) {
|
||
clearTimeout(monitorBoardSlowHintTimer);
|
||
monitorBoardSlowHintTimer = null;
|
||
}
|
||
}
|
||
|
||
function scheduleMonitorBoardSlowHint(box) {
|
||
clearMonitorBoardSlowHint();
|
||
if (!box) return;
|
||
monitorBoardSlowHintTimer = setTimeout(() => {
|
||
if (lastMonitorRows.length) return;
|
||
const el = box.querySelector(".board-loading");
|
||
if (!el) return;
|
||
const sub = el.querySelector(".board-loading-sub");
|
||
if (sub) {
|
||
sub.textContent =
|
||
"后台首次聚合较慢(四所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速。";
|
||
}
|
||
}, 12000);
|
||
}
|
||
|
||
function saveMonitorBoardCache(rows, updatedAt, boardVersion) {
|
||
try {
|
||
sessionStorage.setItem(
|
||
HUB_MONITOR_BOARD_CACHE_KEY,
|
||
JSON.stringify({
|
||
version: 1,
|
||
board_version: boardVersion != null ? boardVersion : localBoardVersion,
|
||
updated_at: updatedAt || "",
|
||
rows: rows || [],
|
||
saved_at: Date.now(),
|
||
})
|
||
);
|
||
} catch (_) {}
|
||
}
|
||
|
||
function loadMonitorBoardFromCache() {
|
||
try {
|
||
const raw = sessionStorage.getItem(HUB_MONITOR_BOARD_CACHE_KEY);
|
||
if (!raw) return null;
|
||
const data = JSON.parse(raw);
|
||
if (!data || !Array.isArray(data.rows) || !data.rows.length) return null;
|
||
const age = Date.now() - Number(data.saved_at || 0);
|
||
if (!Number.isFinite(age) || age > HUB_MONITOR_CACHE_MAX_AGE_MS) {
|
||
sessionStorage.removeItem(HUB_MONITOR_BOARD_CACHE_KEY);
|
||
return null;
|
||
}
|
||
return data;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function restoreMonitorBoardFromCache() {
|
||
const cached = loadMonitorBoardFromCache();
|
||
if (!cached) return false;
|
||
lastMonitorRows = cached.rows;
|
||
lastMonitorBoardUpdatedAt = cached.updated_at || "";
|
||
localBoardVersion = 0;
|
||
applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true });
|
||
return true;
|
||
}
|
||
|
||
function applyMonitorBoardUi(rows, updatedAt, opts) {
|
||
const options = opts || {};
|
||
const tsRaw = updatedAt || lastMonitorBoardUpdatedAt || "";
|
||
if (updatedAt) lastMonitorBoardUpdatedAt = updatedAt;
|
||
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);
|
||
if (options.stale) pill.classList.add("syncing");
|
||
else pill.classList.remove("syncing");
|
||
}
|
||
const upd = document.getElementById("monitor-updated");
|
||
if (upd) {
|
||
const ts = tsRaw.replace("T", " ");
|
||
upd.textContent = options.stale
|
||
? ts
|
||
? `缓存 ${ts} · 后台聚合中…`
|
||
: "后台聚合中…"
|
||
: ts
|
||
? `UPD ${ts}`
|
||
: "";
|
||
}
|
||
updateMonitorAlertSummary(rows || []);
|
||
renderMonitorGrid(rows || []);
|
||
}
|
||
|
||
function startMonitorPoll() {
|
||
const hadCache = restoreMonitorBoardFromCache();
|
||
void fetchMonitorBoardSnapshot({ showLoading: !hadCache });
|
||
connectMonitorBoardStream();
|
||
startHostStatusPoll();
|
||
}
|
||
|
||
async function loadSettings() {
|
||
const r = await apiFetch("/api/settings");
|
||
settingsCache = await r.json();
|
||
syncNavVisibility(settingsCache);
|
||
return settingsCache;
|
||
}
|
||
|
||
function enabledAccounts() {
|
||
return (settingsCache?.exchanges || []).filter((x) => x.enabled);
|
||
}
|
||
|
||
/** 窄屏布局:仅按视口宽度,监控区/行情等共用 */
|
||
function isMobileLayout() {
|
||
return window.matchMedia("(max-width: 720px)").matches;
|
||
}
|
||
|
||
/** AI 教练手机布局:窄屏或手机 PWA(桌面安装的 App 仍走桌面布局) */
|
||
function isMobileAiLayout() {
|
||
if (isMobileLayout()) return true;
|
||
if (
|
||
window.matchMedia("(display-mode: standalone)").matches &&
|
||
window.matchMedia("(max-width: 960px)").matches
|
||
) {
|
||
return true;
|
||
}
|
||
if (window.navigator && window.navigator.standalone === true) return true;
|
||
return false;
|
||
}
|
||
|
||
function positionHasContracts(p) {
|
||
const c = Number(p && p.contracts);
|
||
return Number.isFinite(c) && Math.abs(c) >= 1e-12;
|
||
}
|
||
|
||
function exchangeNeedsFlask(row) {
|
||
const caps = row.capabilities || [];
|
||
return caps.includes("key") || caps.includes("trend");
|
||
}
|
||
|
||
function positionMissingStopLoss(pos, orders, trends) {
|
||
if (!positionHasContracts(pos)) return false;
|
||
const mo = findMonitorOrder(orders, pos.symbol, pos.side);
|
||
const tp = findTrendPlan(trends, pos.symbol, pos.side);
|
||
const tpsl = resolvePositionTpsl(pos, mo, tp);
|
||
const sl = tpsl.sl;
|
||
if (sl !== "" && sl != null && Number.isFinite(Number(sl))) return false;
|
||
const cond = condOrdersFromPosition(pos);
|
||
const picked = pickExTpslOrders(cond);
|
||
if (picked.sl && picked.sl.trigger_price != null) return false;
|
||
const et = pos.exchange_tpsl;
|
||
if (et && et.sl) return false;
|
||
return true;
|
||
}
|
||
|
||
function analyzeExchangeAlert(row) {
|
||
const ag = row.agent || {};
|
||
const hm = row.hub_monitor || {};
|
||
const pos = Array.isArray(ag.positions) ? ag.positions : [];
|
||
const flaskOk = row.flask_ok !== false && hm.ok !== false;
|
||
const upnl = Number(ag.total_unrealized_pnl);
|
||
const tradingBal = Number(row.trading_usdt);
|
||
const balance =
|
||
Number.isFinite(tradingBal) && tradingBal > 0
|
||
? tradingBal
|
||
: Number(ag.balance_usdt);
|
||
const sortUpnl = Number.isFinite(upnl) ? upnl : 0;
|
||
|
||
if (!row.http_ok) {
|
||
return { level: "error", summary: "子代理离线", sortUpnl: 0 };
|
||
}
|
||
if (ag.ok === false) {
|
||
return {
|
||
level: "error",
|
||
summary: (ag.error || row.error || "子代理异常").slice(0, 24),
|
||
sortUpnl: 0,
|
||
};
|
||
}
|
||
if (exchangeNeedsFlask(row) && !flaskOk) {
|
||
const fe = row.flask_error || hm.error || hm.msg || "Flask未连通";
|
||
return { level: "error", summary: String(fe).slice(0, 24), sortUpnl };
|
||
}
|
||
|
||
const orders = flaskOk ? hm.orders || [] : [];
|
||
const trends = flaskOk ? hm.trends || [] : [];
|
||
let missingSl = false;
|
||
for (const p of pos) {
|
||
if (positionMissingStopLoss(p, orders, trends)) {
|
||
missingSl = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (Number.isFinite(upnl) && upnl < 0 && Number.isFinite(balance) && balance > 0) {
|
||
const lossPct = (Math.abs(upnl) / balance) * 100;
|
||
if (lossPct >= HUB_ALERT_FLOAT_LOSS_RATIO * 100) {
|
||
return {
|
||
level: "warn",
|
||
summary: `浮亏超10% · ${fmt(upnl, 2)}U`,
|
||
sortUpnl,
|
||
};
|
||
}
|
||
}
|
||
if (missingSl) {
|
||
return { level: "warn", summary: "缺止损", sortUpnl };
|
||
}
|
||
|
||
const openCount = pos.filter(positionHasContracts).length;
|
||
return {
|
||
level: "ok",
|
||
summary: openCount ? "正常" : "空仓",
|
||
sortUpnl,
|
||
};
|
||
}
|
||
|
||
function sortRowsForMobileDashboard(rows) {
|
||
const levelOrder = { error: 0, warn: 1, ok: 2 };
|
||
return rows
|
||
.map((r) => ({ r, a: analyzeExchangeAlert(r) }))
|
||
.sort((x, y) => {
|
||
const ld = levelOrder[x.a.level] - levelOrder[y.a.level];
|
||
if (ld !== 0) return ld;
|
||
return (x.a.sortUpnl || 0) - (y.a.sortUpnl || 0);
|
||
})
|
||
.map((x) => x.r);
|
||
}
|
||
|
||
function updateMonitorAlertSummary(rows) {
|
||
const el = document.getElementById("monitor-alert-summary");
|
||
if (!el) return;
|
||
if (!isMobileLayout() || !rows.length) {
|
||
el.classList.add("hidden");
|
||
el.innerHTML = "";
|
||
return;
|
||
}
|
||
let err = 0;
|
||
let warn = 0;
|
||
let ok = 0;
|
||
rows.forEach((r) => {
|
||
const lv = analyzeExchangeAlert(r).level;
|
||
if (lv === "error") err += 1;
|
||
else if (lv === "warn") warn += 1;
|
||
else ok += 1;
|
||
});
|
||
el.classList.remove("hidden");
|
||
el.innerHTML = `<span class="mas-item mas-ok">正常 ${ok}</span><span class="mas-sep">·</span><span class="mas-item mas-warn">关注 ${warn}</span><span class="mas-sep">·</span><span class="mas-item mas-err">异常 ${err}</span>`;
|
||
}
|
||
|
||
/** 监控卡片列数:桌面 3/2 列;手机端 2 列瓦片 */
|
||
function syncMonitorGridColumns(gridEl, count) {
|
||
if (!gridEl) return;
|
||
if (isMobileLayout()) {
|
||
gridEl.style.gridTemplateColumns = "repeat(2, 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))`;
|
||
}
|
||
|
||
const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab";
|
||
const AI_MOBILE_CHAT_TABS = new Set(["trading", "general"]);
|
||
|
||
function normalizeAiMobileTab(tab) {
|
||
const raw = (tab || "").trim().toLowerCase();
|
||
if (raw === "chat") return "trading";
|
||
if (AI_MOBILE_CHAT_TABS.has(raw) || raw === "history") return raw;
|
||
return "trading";
|
||
}
|
||
|
||
function applyAiMobileTab(tab) {
|
||
const layout = document.querySelector(".ai-layout");
|
||
const tabs = document.querySelectorAll(".ai-mobile-tab");
|
||
if (!layout) return;
|
||
const mobile = isMobileAiLayout();
|
||
if (!mobile) {
|
||
delete layout.dataset.aiMobileTab;
|
||
tabs.forEach((btn) => {
|
||
btn.classList.remove("is-active");
|
||
btn.setAttribute("aria-selected", "false");
|
||
});
|
||
return;
|
||
}
|
||
const active = normalizeAiMobileTab(
|
||
tab || localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"
|
||
);
|
||
layout.dataset.aiMobileTab = active;
|
||
tabs.forEach((btn) => {
|
||
const t = btn.dataset.aiTab || "";
|
||
const on = t === active;
|
||
btn.classList.toggle("is-active", on);
|
||
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||
});
|
||
if (AI_MOBILE_CHAT_TABS.has(active)) {
|
||
updateAiBotTabs(active);
|
||
scrollAiChatToEnd();
|
||
}
|
||
if (active === "history") {
|
||
const hist = document.getElementById("ai-chat-history-list");
|
||
if (hist) hist.scrollTop = 0;
|
||
}
|
||
}
|
||
|
||
function initAiMobileTabs() {
|
||
const tabs = document.querySelectorAll(".ai-mobile-tab");
|
||
if (!tabs.length) return;
|
||
tabs.forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const tab = btn.dataset.aiTab || "trading";
|
||
if (tab === "new") {
|
||
const prev = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading");
|
||
const botMode = prev === "general" ? "general" : "trading";
|
||
void newAiChat(botMode);
|
||
return;
|
||
}
|
||
localStorage.setItem(AI_MOBILE_TAB_KEY, tab);
|
||
applyAiMobileTab(tab);
|
||
if (AI_MOBILE_CHAT_TABS.has(tab)) {
|
||
const input = document.getElementById("ai-chat-input");
|
||
if (input && isMobileAiLayout()) input.focus();
|
||
}
|
||
});
|
||
});
|
||
window.addEventListener("resize", () => applyAiMobileTab());
|
||
applyAiMobileTab();
|
||
}
|
||
|
||
let syncHubAiMobileViewport = () => {};
|
||
|
||
function initHubAiMobileViewport() {
|
||
const shell = document.querySelector(".app-shell");
|
||
const chatInput = document.getElementById("ai-chat-input");
|
||
if (!shell || !window.visualViewport) {
|
||
syncHubAiMobileViewport = () => {};
|
||
return;
|
||
}
|
||
|
||
let baselineInnerH = Math.max(window.innerHeight, window.visualViewport.height || 0);
|
||
|
||
const scrollChatToEnd = () => {
|
||
const box = document.getElementById("ai-chat-messages");
|
||
if (box) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
|
||
};
|
||
|
||
syncHubAiMobileViewport = () => {
|
||
const onAi = document.body.classList.contains("hub-page-ai");
|
||
if (!onAi || !isMobileAiLayout()) {
|
||
shell.style.removeProperty("height");
|
||
shell.style.removeProperty("max-height");
|
||
shell.style.removeProperty("width");
|
||
shell.style.removeProperty("transform");
|
||
document.documentElement.style.removeProperty("--hub-vvh");
|
||
document.body.classList.remove("hub-ai-keyboard-open");
|
||
return;
|
||
}
|
||
const vv = window.visualViewport;
|
||
const h = Math.max(240, Math.round(vv.height));
|
||
const top = Math.round(vv.offsetTop || 0);
|
||
const left = Math.round(vv.offsetLeft || 0);
|
||
const inputFocused = !!(chatInput && document.activeElement === chatInput);
|
||
if (!inputFocused) {
|
||
baselineInnerH = Math.max(baselineInnerH, window.innerHeight, h);
|
||
}
|
||
document.documentElement.style.setProperty("--hub-vvh", `${h}px`);
|
||
shell.style.height = `${h}px`;
|
||
shell.style.maxHeight = `${h}px`;
|
||
shell.style.width = `${Math.round(vv.width)}px`;
|
||
shell.style.transform =
|
||
top > 0 || left > 0 ? `translate(${left}px, ${top}px)` : "";
|
||
const viewportShrunk = h < baselineInnerH * 0.72;
|
||
const keyboardLikely = inputFocused && (viewportShrunk || top > 48);
|
||
document.body.classList.toggle("hub-ai-keyboard-open", keyboardLikely);
|
||
};
|
||
|
||
window.visualViewport.addEventListener("resize", syncHubAiMobileViewport);
|
||
window.visualViewport.addEventListener("scroll", syncHubAiMobileViewport);
|
||
window.addEventListener("resize", syncHubAiMobileViewport);
|
||
window.addEventListener("orientationchange", () => {
|
||
setTimeout(syncHubAiMobileViewport, 80);
|
||
});
|
||
|
||
if (chatInput) {
|
||
chatInput.addEventListener("focus", () => {
|
||
syncHubAiMobileViewport();
|
||
scrollChatToEnd();
|
||
setTimeout(syncHubAiMobileViewport, 50);
|
||
setTimeout(syncHubAiMobileViewport, 280);
|
||
});
|
||
chatInput.addEventListener("blur", () => {
|
||
setTimeout(syncHubAiMobileViewport, 80);
|
||
setTimeout(syncHubAiMobileViewport, 320);
|
||
});
|
||
}
|
||
syncHubAiMobileViewport();
|
||
}
|
||
|
||
function initMobileLayout() {
|
||
initAiMobileTabs();
|
||
initHubAiMobileViewport();
|
||
let resizeTimer = null;
|
||
let wasMobile = isMobileLayout();
|
||
window.addEventListener("resize", () => {
|
||
clearTimeout(resizeTimer);
|
||
resizeTimer = setTimeout(() => {
|
||
const nowMobile = isMobileLayout();
|
||
if (lastMonitorRows.length && nowMobile !== wasMobile) {
|
||
wasMobile = nowMobile;
|
||
renderMonitorGrid(lastMonitorRows);
|
||
updateMonitorAlertSummary(lastMonitorRows);
|
||
return;
|
||
}
|
||
wasMobile = nowMobile;
|
||
const box = document.getElementById("monitor-grid");
|
||
if (box && lastMonitorRows.length) {
|
||
syncMonitorGridColumns(box, lastMonitorRows.length);
|
||
updateMonitorAlertSummary(lastMonitorRows);
|
||
}
|
||
}, 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 dedupeCondOrdersByTrigger(orders) {
|
||
const list = Array.isArray(orders) ? orders : [];
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const o of list) {
|
||
const px = orderTriggerOrPrice(o);
|
||
const key =
|
||
px != null
|
||
? "t:" + String(px)
|
||
: o && o.id
|
||
? "id:" + String(o.id)
|
||
: null;
|
||
if (key && seen.has(key)) continue;
|
||
if (key) seen.add(key);
|
||
out.push(o);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function upsertExTpslCondOrder(cond, role, slot) {
|
||
if (!slot || slot.trigger_price == null || slot.trigger_price === "") return;
|
||
const label = role === "sl" ? "止损" : "止盈";
|
||
const item = {
|
||
label: label,
|
||
trigger_price: Number(slot.trigger_price),
|
||
amount: slot.amount != null ? slot.amount : null,
|
||
id: slot.order_id || "",
|
||
channel: "algo",
|
||
};
|
||
const idx = cond.findIndex(function (o) {
|
||
const lb = o.label || "";
|
||
return role === "sl" ? /^止损\b/.test(lb) || lb.includes("止损") : /^止盈\b/.test(lb) || lb.includes("止盈");
|
||
});
|
||
if (idx >= 0) cond[idx] = Object.assign({}, cond[idx], item);
|
||
else cond.push(item);
|
||
}
|
||
|
||
function condOrdersFromPosition(pos) {
|
||
const cond = dedupeCondOrdersByTrigger(
|
||
Array.isArray(pos.conditional_orders) ? pos.conditional_orders : []
|
||
);
|
||
const et = pos.exchange_tpsl;
|
||
if (!et) return cond;
|
||
upsertExTpslCondOrder(cond, "sl", et.sl);
|
||
upsertExTpslCondOrder(cond, "tp", et.tp);
|
||
return cond;
|
||
}
|
||
|
||
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 resolveTrendPlanRr(trendPlan, side, entry, sl, tp) {
|
||
const t = trendPlan || {};
|
||
if (t.money_rr != null && t.money_rr !== "") {
|
||
const n = Number(t.money_rr);
|
||
if (Number.isFinite(n) && n > 0) return n;
|
||
}
|
||
if (t.planned_rr != null && t.planned_rr !== "") {
|
||
const n = Number(t.planned_rr);
|
||
if (Number.isFinite(n) && n > 0) return n;
|
||
}
|
||
const e = t.avg_entry_price != null && t.avg_entry_price !== "" ? t.avg_entry_price : entry;
|
||
const s = t.stop_loss != null && t.stop_loss !== "" ? t.stop_loss : sl;
|
||
const p = t.take_profit != null && t.take_profit !== "" ? t.take_profit : tp;
|
||
return calcRrRatio(side, e, s, p);
|
||
}
|
||
|
||
function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan) {
|
||
if (tpMonitored && isTrendContext(mo, trendPlan)) {
|
||
const rr = resolveTrendPlanRr(trendPlan, side, entry, sl, tp);
|
||
if (rr != null) return rr;
|
||
}
|
||
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 formatTpCellValue(tp, tpMonitored, symbol, tickMap) {
|
||
if (tpMonitored) {
|
||
if (tp != null && tp !== "") {
|
||
return `程序监控 · ${fmtSymbolPrice(tp, symbol, tickMap)}`;
|
||
}
|
||
return "程序监控";
|
||
}
|
||
if (tp != null && tp !== "") return fmtSymbolPrice(tp, symbol, tickMap);
|
||
return "—";
|
||
}
|
||
|
||
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 `<span class="pos-breakeven-badge">已保本</span>`;
|
||
}
|
||
|
||
async function fetchMonitorBoardSnapshot(opts) {
|
||
const options = opts || {};
|
||
const background = !!options.background;
|
||
const showLoading = !!options.showLoading && !lastMonitorRows.length;
|
||
const box = document.getElementById("monitor-grid");
|
||
if (monitorBoardInFlight) {
|
||
if (background) monitorBoardFetchPending = true;
|
||
else return;
|
||
}
|
||
if (showLoading && box) {
|
||
box.innerHTML =
|
||
'<div class="board-loading"><span class="board-loading-spin" aria-hidden="true"></span>正在加载监控快照…<p class="board-loading-sub"></p></div>';
|
||
scheduleMonitorBoardSlowHint(box);
|
||
} else if (background && lastMonitorRows.length) {
|
||
applyMonitorBoardUi(lastMonitorRows, null, { stale: true });
|
||
}
|
||
monitorBoardInFlight = true;
|
||
const ctrl = new AbortController();
|
||
const fetchTimer = setTimeout(() => ctrl.abort(), HUB_MONITOR_SNAPSHOT_TIMEOUT_MS);
|
||
try {
|
||
const r = await apiFetch(MONITOR_BOARD_SNAPSHOT_URL, { signal: ctrl.signal });
|
||
const data = await r.json();
|
||
if (!r.ok) {
|
||
throw new Error(data.msg || data.detail || `HTTP ${r.status}`);
|
||
}
|
||
const ver = Number(data.board_version) || 0;
|
||
const rows = data.rows || [];
|
||
const waitingFirst = data.aggregating && !rows.length && ver <= localBoardVersion;
|
||
if (waitingFirst && showLoading) {
|
||
if (box) {
|
||
const sub = box.querySelector(".board-loading-sub");
|
||
if (sub) sub.textContent = "后台正在首次聚合四所数据(约 5~15 秒)…";
|
||
}
|
||
return;
|
||
}
|
||
const ts = data.updated_at || "";
|
||
const versionChanged = ver !== localBoardVersion;
|
||
const timeChanged = ts && ts !== lastMonitorBoardUpdatedAt;
|
||
if (versionChanged || timeChanged || !lastMonitorRows.length) {
|
||
localBoardVersion = ver;
|
||
lastMonitorRows = rows;
|
||
saveMonitorBoardCache(lastMonitorRows, ts, ver);
|
||
applyMonitorBoardUi(lastMonitorRows, ts, {
|
||
stale: !!data.aggregating,
|
||
});
|
||
} else if (data.aggregating && lastMonitorRows.length) {
|
||
applyMonitorBoardUi(lastMonitorRows, data.updated_at || lastMonitorBoardUpdatedAt, {
|
||
stale: true,
|
||
});
|
||
}
|
||
if (data.ok === false && data.msg && !background) {
|
||
showToast(String(data.msg), true);
|
||
}
|
||
} catch (e) {
|
||
const msg =
|
||
e && e.name === "AbortError" ? "读取监控快照超时,请检查中控是否运行" : String(e);
|
||
if (background && lastMonitorRows.length) {
|
||
showToast("快照读取失败,仍显示上次数据", true);
|
||
applyMonitorBoardUi(lastMonitorRows, null, { stale: false });
|
||
return;
|
||
}
|
||
if (box) box.innerHTML = `<div class="err">${esc(msg)}</div>`;
|
||
} finally {
|
||
clearTimeout(fetchTimer);
|
||
clearMonitorBoardSlowHint();
|
||
monitorBoardInFlight = false;
|
||
if (monitorBoardFetchPending) {
|
||
monitorBoardFetchPending = false;
|
||
void fetchMonitorBoardSnapshot({ background: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshMonitorBoardNow() {
|
||
if (lastMonitorRows.length) {
|
||
applyMonitorBoardUi(lastMonitorRows, lastMonitorBoardUpdatedAt, { stale: true });
|
||
}
|
||
try {
|
||
await requestMonitorBoardRefresh();
|
||
await fetchMonitorBoardSnapshot({ background: false });
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
const mobileTiles = isMobileLayout() && !expandedExchangeId;
|
||
const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows;
|
||
box.classList.toggle("grid-monitor-tiles", mobileTiles);
|
||
try {
|
||
box.innerHTML =
|
||
displayRows
|
||
.map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r)))
|
||
.join("") || '<div class="err">无已启用账户</div>';
|
||
} catch (err) {
|
||
console.error("renderMonitorGrid", err);
|
||
box.innerHTML = `<div class="err">监控区渲染失败:${esc(String(err && err.message ? err.message : err))}</div>`;
|
||
}
|
||
syncMonitorGridColumns(box, displayRows.length);
|
||
bindMonitorInteractions(box);
|
||
if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) {
|
||
TimeCloseUI.tickLocalCountdowns();
|
||
}
|
||
|
||
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);
|
||
if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) {
|
||
TimeCloseUI.tickLocalCountdowns();
|
||
}
|
||
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 orderTriggerOrPrice(o) {
|
||
if (!o) return null;
|
||
if (o.trigger_price != null && o.trigger_price !== "") {
|
||
const t = Number(o.trigger_price);
|
||
if (Number.isFinite(t) && t > 0) return t;
|
||
}
|
||
if (o.price != null && o.price !== "") {
|
||
const p = Number(o.price);
|
||
if (Number.isFinite(p) && p > 0) return p;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function inferTpslFromCondOrders(side, cond, entry) {
|
||
const picked = pickExTpslOrders(cond);
|
||
let sl = picked.sl ? orderTriggerOrPrice(picked.sl) : "";
|
||
let tp = picked.tp ? orderTriggerOrPrice(picked.tp) : "";
|
||
if (sl !== "" && sl != null) sl = Number(sl);
|
||
if (tp !== "" && tp != null) tp = Number(tp);
|
||
if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) {
|
||
return { sl, tp };
|
||
}
|
||
|
||
const triggers = (cond || [])
|
||
.map(function (o) {
|
||
const px = orderTriggerOrPrice(o);
|
||
return px == null ? null : { price: px, label: o.label || "" };
|
||
})
|
||
.filter(function (o) {
|
||
return o != null;
|
||
});
|
||
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 = isTrendContext(mo, trendPlan);
|
||
const handoff = isTrendHandoffOrder(mo);
|
||
|
||
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 (handoff) {
|
||
tpMonitored = false;
|
||
} else if (isTrend) {
|
||
tpMonitored = true;
|
||
if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") {
|
||
sl = trendPlan.stop_loss;
|
||
}
|
||
if (trendPlan && trendPlan.take_profit != null && trendPlan.take_profit !== "") {
|
||
takeProfit = trendPlan.take_profit;
|
||
} else {
|
||
takeProfit = "";
|
||
}
|
||
}
|
||
|
||
const inferred = inferTpslFromCondOrders(pos.side, cond, entryN);
|
||
if (inferred.sl !== "" && inferred.sl != null) {
|
||
sl = inferred.sl;
|
||
} else if (sl === "" || sl == null) {
|
||
sl = inferred.sl;
|
||
}
|
||
if (!tpMonitored) {
|
||
if (inferred.tp !== "" && inferred.tp != null) {
|
||
takeProfit = inferred.tp;
|
||
} else if (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,
|
||
is_handoff: handoff,
|
||
};
|
||
}
|
||
|
||
function buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId) {
|
||
const mo = monitorOrder || {};
|
||
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),
|
||
});
|
||
});
|
||
const entryPx = num(pos.entry_price != null ? pos.entry_price : tpsl.entry);
|
||
const markPx = num(pos.mark_price);
|
||
const contractSize = num(pos.contract_size);
|
||
const upnl = resolvePositionUpnlUsdt(pos, trendPlan, markPx);
|
||
const planMargin =
|
||
trendPlan && trendPlan.plan_margin_capital != null
|
||
? num(trendPlan.plan_margin_capital)
|
||
: mo.margin_capital != null
|
||
? num(mo.margin_capital)
|
||
: null;
|
||
const leverage =
|
||
trendPlan && trendPlan.leverage != null
|
||
? num(trendPlan.leverage)
|
||
: mo.leverage != null
|
||
? num(mo.leverage)
|
||
: null;
|
||
return {
|
||
exchange_id: exchangeId || null,
|
||
symbol: (pos.symbol || "").trim(),
|
||
side: (pos.side || "long").toLowerCase(),
|
||
entry: entryPx,
|
||
mark_price: markPx,
|
||
stop_loss: num(tpsl.sl),
|
||
take_profit: num(tpsl.tp),
|
||
tp_monitored: !!tpsl.tp_monitored,
|
||
is_trend: !!tpsl.is_trend,
|
||
contracts: num(pos.contracts),
|
||
contract_size: contractSize != null ? contractSize : 1,
|
||
unrealized_pnl: upnl != null ? Number(upnl) : null,
|
||
notional_usdt: num(pos.notional_usdt),
|
||
plan_margin: planMargin,
|
||
leverage: leverage,
|
||
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, exchangeId))
|
||
).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();
|
||
const msg = (btn.dataset.confirm || "").trim();
|
||
if (msg && !confirm(msg)) return;
|
||
openInstance(btn.dataset.exId, btn.dataset.next || "/", {
|
||
newTab: ev.ctrlKey || ev.metaKey,
|
||
});
|
||
};
|
||
});
|
||
box.querySelectorAll(".btn-hub-trend-stop").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
hubTrendPlanStop(btn.dataset.exId, btn.dataset.planId);
|
||
};
|
||
});
|
||
box.querySelectorAll(".btn-hub-trend-be").forEach((btn) => {
|
||
btn.onclick = (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
const card = btn.closest(".hub-trend-plan-card");
|
||
const inp = card ? card.querySelector(".hub-plan-be-input") : null;
|
||
hubTrendPlanBreakeven(btn.dataset.exId, btn.dataset.planId, inp);
|
||
};
|
||
});
|
||
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, tickMap) {
|
||
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
|
||
? fmtSymbolPrice(o.trigger_price, symbol, tickMap)
|
||
: o.price != null
|
||
? fmtSymbolPrice(o.price, symbol, tickMap)
|
||
: "—";
|
||
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, entry) {
|
||
return inferTpslFromCondOrders(side, cond, entry);
|
||
}
|
||
|
||
function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) {
|
||
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", tickMap);
|
||
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap);
|
||
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 syntheticExTpslOrder(role, price, amount) {
|
||
if (price == null || price === "" || !Number.isFinite(Number(price))) return null;
|
||
return {
|
||
label: role === "sl" ? "止损" : "止盈",
|
||
trigger_price: Number(price),
|
||
price: Number(price),
|
||
amount: amount != null ? amount : null,
|
||
id: "",
|
||
channel: "plan",
|
||
};
|
||
}
|
||
|
||
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, tickMap, resolvedTpsl, contracts) {
|
||
const symAttr = esc(symbol || "").replace(/"/g, """);
|
||
let { sl, tp } = pickExTpslOrders(cond);
|
||
const plan = resolvedTpsl || {};
|
||
if (!sl && plan.sl != null && plan.sl !== "") {
|
||
sl = syntheticExTpslOrder("sl", plan.sl, contracts);
|
||
}
|
||
if (!tp && plan.tp != null && plan.tp !== "") {
|
||
tp = syntheticExTpslOrder("tp", plan.tp, contracts);
|
||
}
|
||
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 px = orderTriggerOrPrice(o);
|
||
const trig = px != null ? fmtSymbolPrice(px, symbol, tickMap) : "—";
|
||
const cancelBtn =
|
||
oid && o.channel !== "plan"
|
||
? `<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>`
|
||
: "";
|
||
const planHint = o.channel === "plan" ? '<span class="pos-ex-plan-hint">(下单监控)</span>' : "";
|
||
return `<div class="pos-ex-order-row">
|
||
<span class="pos-ex-order-main">${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}${planHint}</span>
|
||
${cancelBtn}
|
||
</div>`;
|
||
}
|
||
return row("止损", sl) + row("止盈", tp);
|
||
}
|
||
|
||
function trendAddSummaryHtml(t, tickMap) {
|
||
const done = t.add_count != null ? t.add_count : t.legs_done;
|
||
const total = t.add_count_total != null ? t.add_count_total : t.dca_legs;
|
||
const sym = t.exchange_symbol || t.symbol || "";
|
||
let html = "";
|
||
if (done != null && Number(done) >= 0) {
|
||
html += total != null ? ` · 补仓 <strong>${esc(done)}/${esc(total)}</strong>` : ` · 补仓 <strong>${esc(done)}</strong> 次`;
|
||
const pxs = t.add_prices_display;
|
||
if (Array.isArray(pxs) && pxs.length) {
|
||
html += ` · 加仓价 ${pxs.map((p) => esc(p)).join(" / ")}`;
|
||
} else if (Array.isArray(t.add_prices) && t.add_prices.length) {
|
||
html += ` · 加仓价 ${t.add_prices.map((p) => esc(fmtSymbolPrice(p, sym, tickMap))).join(" / ")}`;
|
||
} else if (Number(done) === 0) {
|
||
html += " · 加仓价 —";
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function timeCloseSymbolBadgeHtml(item) {
|
||
if (!item || !item.time_close_enabled) return "";
|
||
const tcLabel = item.time_close_label || `时间平仓 ${item.time_close_hours || ""}h`;
|
||
const tcCd = item.time_close_countdown || "--:--:--";
|
||
const tcAt = item.time_close_at_ms != null ? String(item.time_close_at_ms) : "";
|
||
return (
|
||
`<span class="pos-symbol-time-close pos-time-close-meta pos-meta-on" data-close-at-ms="${esc(tcAt)}">` +
|
||
`${esc(tcLabel)} · <span class="pos-time-close-cd">${esc(tcCd)}</span></span>`
|
||
);
|
||
}
|
||
|
||
function renderTrendDcaTable(t, tickMap) {
|
||
const levels = resolveTrendDcaLevels(t);
|
||
if (!levels.length) return "";
|
||
const sym = t.exchange_symbol || t.symbol || "";
|
||
const rows = levels
|
||
.map((lv) => {
|
||
const price =
|
||
lv.price != null && lv.price !== ""
|
||
? fmtSymbolPrice(lv.price, sym, tickMap)
|
||
: "—";
|
||
const amt =
|
||
lv.contracts != null && lv.contracts !== "" ? esc(String(lv.contracts)) : "—";
|
||
const avg =
|
||
lv.avg_entry != null && lv.avg_entry !== ""
|
||
? fmtSymbolPrice(lv.avg_entry, sym, tickMap)
|
||
: "—";
|
||
const profitU =
|
||
lv.profit_u != null && lv.profit_u !== "" ? fmt(lv.profit_u, 2) : "—";
|
||
const riskU = lv.risk_u != null && lv.risk_u !== "" ? fmt(lv.risk_u, 2) : "—";
|
||
const rr = lv.rr != null && lv.rr !== "" ? `${fmt(lv.rr, 2)}:1` : "—";
|
||
const stCls = lv.status === "done" ? "st-done" : "st-pending";
|
||
const label = lv.status_label || (lv.status === "done" ? "已补仓" : "待补仓");
|
||
return `<tr>
|
||
<td>${esc(lv.label || lv.leg_key || "—")}</td>
|
||
<td>${esc(price)}</td>
|
||
<td>${amt}</td>
|
||
<td>${esc(avg)}</td>
|
||
<td>${esc(profitU)}</td>
|
||
<td>${esc(riskU)}</td>
|
||
<td>${esc(rr)}</td>
|
||
<td class="${stCls}">${esc(label)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
return `<div class="plan-dca-block plan-dca-block--side">
|
||
<div class="plan-dca-title">补仓计划明细</div>
|
||
<table class="plan-dca-table">
|
||
<tr><th>档位</th><th>触发价</th><th>张数</th><th>加仓后均价</th><th>止盈盈利(U)</th><th>止损(U)</th><th>盈亏比</th><th>状态</th></tr>
|
||
${rows}
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function renderTrendPlanCard(t, tickMap, pos, exchangeRow) {
|
||
const sym = t.exchange_symbol || t.symbol || "";
|
||
const side = (t.direction || "long").toLowerCase();
|
||
const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap);
|
||
const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap);
|
||
const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap);
|
||
const addZone =
|
||
t.add_upper_display || fmtSymbolPrice(t.add_upper, sym, tickMap) || "—";
|
||
const rr = resolveTrendPlanRr(t, side, t.avg_entry_price, t.stop_loss, t.take_profit);
|
||
const rrTxt = rr != null ? `${fmt(rr, 2)}:1` : "—";
|
||
const mark = resolveTrendMarkPrice(pos, t, sym, tickMap);
|
||
const legsDone = t.add_count != null ? t.add_count : t.legs_done;
|
||
const legsTotal = t.add_count_total != null ? t.add_count_total : t.dca_legs;
|
||
const legsTxt =
|
||
legsDone != null && legsTotal != null
|
||
? `${esc(legsDone)}/${esc(legsTotal)}`
|
||
: legsDone != null
|
||
? esc(legsDone)
|
||
: "—";
|
||
const upnlTrend = resolveTrendFloatingPnl(pos, t);
|
||
const pnlFmt = formatTrendPlanFloatingPnl(upnlTrend, t.plan_margin_capital);
|
||
const pnlVal =
|
||
pnlFmt.text === "—"
|
||
? "—"
|
||
: `<span class="val ${pnlFmt.cls}">${esc(pnlFmt.text)}</span>`;
|
||
const riskTxt =
|
||
t.risk_percent != null && t.risk_percent !== "" ? `${esc(t.risk_percent)}%` : "—";
|
||
const snapTxt =
|
||
t.snapshot_available_usdt != null && t.snapshot_available_usdt !== ""
|
||
? `${fmt(t.snapshot_available_usdt, 2)}U`
|
||
: "—";
|
||
const marginTxt =
|
||
t.plan_margin_capital != null && t.plan_margin_capital !== ""
|
||
? `≈${fmt(t.plan_margin_capital, 2)}U`
|
||
: "—";
|
||
const levTxt = t.leverage != null && t.leverage !== "" ? `${esc(t.leverage)}x` : "—";
|
||
const bePctDefault =
|
||
t.breakeven_default_offset_pct != null && t.breakeven_default_offset_pct !== ""
|
||
? t.breakeven_default_offset_pct
|
||
: t.breakeven_offset_pct != null && t.breakeven_offset_pct !== ""
|
||
? t.breakeven_offset_pct
|
||
: "0.3";
|
||
const exId = exchangeRow && exchangeRow.id != null ? esc(exchangeRow.id) : "";
|
||
const planId = esc(t.id);
|
||
const caps = (exchangeRow && exchangeRow.capabilities) || [];
|
||
const flaskOk =
|
||
exchangeRow && exchangeRow.flask_ok !== false && (exchangeRow.hub_monitor || {}).ok !== false;
|
||
const canHubTrend = !!(flaskOk && caps.includes("trend") && exId && planId);
|
||
const beAppliedFlag = !!t.breakeven_applied;
|
||
const endBtn = canHubTrend
|
||
? `<button type="button" class="btn-close-plan btn-hub-trend-stop" data-ex-id="${exId}" data-plan-id="${planId}">结束计划</button>`
|
||
: "";
|
||
const beBtn = canHubTrend && !beAppliedFlag
|
||
? `<button type="button" class="hub-plan-be-btn btn-hub-trend-be" data-ex-id="${exId}" data-plan-id="${planId}">保本移交下单监控</button>`
|
||
: beAppliedFlag
|
||
? ""
|
||
: `<span class="hub-plan-be-btn hub-plan-be-btn--static">保本移交下单监控</span>`;
|
||
const beApplied =
|
||
t.breakeven_applied
|
||
? `<span class="hub-plan-be-done">已保本 ${esc(String(t.breakeven_applied_at || "").slice(0, 16))}</span>`
|
||
: "";
|
||
const dcaHtml = renderTrendDcaTable(t, tickMap);
|
||
const dcaCol = dcaHtml
|
||
? `<div class="hub-trend-plan-col hub-trend-plan-col-right">${dcaHtml}</div>`
|
||
: `<div class="hub-trend-plan-col hub-trend-plan-col-right"><div class="plan-dca-block plan-dca-block--side plan-dca-block--empty"><div class="plan-dca-title">补仓计划明细</div><div class="hub-dca-empty">暂无补仓档位</div></div></div>`;
|
||
return `<div class="plan-position-card hub-trend-plan-card">
|
||
<div class="plan-card-head">
|
||
<div class="plan-card-title">
|
||
<span>#${esc(t.id)} ${esc(sym)}</span>
|
||
${renderDirectionBadge(t.direction)}
|
||
</div>
|
||
${endBtn}
|
||
</div>
|
||
<div class="hub-trend-plan-body-cols">
|
||
<div class="hub-trend-plan-col hub-trend-plan-col-left">
|
||
<div class="plan-card-meta">
|
||
来源: 趋势回调计划 | 风险: ${riskTxt}
|
||
| <span class="accent">${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)}</span>
|
||
| 已补仓 <strong>${legsTxt}</strong>
|
||
</div>
|
||
<div class="plan-card-grid">
|
||
<div class="plan-cell"><span class="lbl">均价</span><span class="val">${esc(avg)}</span></div>
|
||
<div class="plan-cell"><span class="lbl">止损</span><span class="val">${esc(sl)}</span></div>
|
||
<div class="plan-cell"><span class="lbl">止盈</span><span class="val">${esc(tp)}</span></div>
|
||
<div class="plan-cell"><span class="lbl">盈亏比</span><span class="val">${esc(rrTxt)}</span></div>
|
||
<div class="plan-cell"><span class="lbl">标记价</span><span class="val">${esc(mark)}</span></div>
|
||
<div class="plan-cell"><span class="lbl">浮盈亏</span>${pnlVal}</div>
|
||
</div>
|
||
</div>
|
||
${dcaCol}
|
||
</div>
|
||
<div class="hub-trend-plan-foot">
|
||
<div class="plan-card-meta hub-plan-breakeven-row">
|
||
<label class="hub-plan-be-label">
|
||
保本移交 偏移%
|
||
<input type="number" min="0" step="0.01" value="${esc(bePctDefault)}" class="hub-plan-be-input" data-ex-id="${exId}" data-plan-id="${planId}" ${canHubTrend && !beAppliedFlag ? "" : "disabled"} />
|
||
</label>
|
||
${beBtn}
|
||
${beApplied}
|
||
</div>
|
||
<div class="plan-card-meta hub-plan-account-foot">
|
||
快照可用: ${esc(snapTxt)} | 计划保证金${esc(marginTxt)} | 杠杆: ${levTxt}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderTrendSection(trends, tickMap, positions, exchangeRow) {
|
||
if (!trends || !trends.length) return "";
|
||
const posList = Array.isArray(positions) ? positions : [];
|
||
const cards = trends
|
||
.map((t) => {
|
||
const sym = t.exchange_symbol || t.symbol || "";
|
||
const side = (t.direction || "long").toLowerCase();
|
||
let matched = null;
|
||
for (const p of posList) {
|
||
if (!symbolsMatchHub(p.symbol, sym)) continue;
|
||
const ps = (p.side || "").toLowerCase();
|
||
if (!ps || ps === side) {
|
||
matched = p;
|
||
break;
|
||
}
|
||
}
|
||
return renderTrendPlanCard(t, tickMap, matched, exchangeRow);
|
||
})
|
||
.join("");
|
||
return `<div class="hub-trend-running">
|
||
<div class="hub-trend-running-title">运行中的计划</div>
|
||
<div class="running-plans-stack hub-trend-plan-list">${cards}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) {
|
||
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 isTrend = isTrendContext(mo, trendPlan);
|
||
const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan);
|
||
const beSecured = isBreakevenSecured(side, entry, mo, cond, pos);
|
||
const upnl = resolveTrendFloatingPnl(pos, trendPlan);
|
||
const pnlFmt = formatFloatingPnlText(upnl, pos.notional_usdt);
|
||
const pnlText = pnlFmt.text;
|
||
const sizingFoot = resolveTrendSizingFooter(mo, trendPlan, isTrend);
|
||
const markDisplay = isTrend
|
||
? resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap)
|
||
: fmtMarkPrice(pos, tickMap);
|
||
const meta = [];
|
||
if (isTrend) {
|
||
meta.push(monitorOrderSourceHtml(mo, trendPlan));
|
||
const riskLine = formatMonitorRiskMeta(mo, trendPlan);
|
||
if (riskLine) meta.push(riskLine);
|
||
if (trendPlan && trendPlan.id) {
|
||
const zone =
|
||
trendPlan.add_upper_display ||
|
||
fmtSymbolPrice(trendPlan.add_upper, symbol, tickMap) ||
|
||
"—";
|
||
meta.push(
|
||
`<span class="pos-meta-accent">${esc(trendAddZoneLabel(trendPlan.direction))} ${esc(zone)}</span>`
|
||
);
|
||
const addSum = trendAddSummaryHtml(trendPlan, tickMap);
|
||
if (addSum) meta.push(addSum.replace(/^ · /, ""));
|
||
}
|
||
meta.push(`<span class="pos-meta-off">移动保本:关</span>`);
|
||
} else if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) {
|
||
meta.push(monitorOrderSourceHtml(mo, trendPlan));
|
||
if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`);
|
||
else meta.push("风格: —");
|
||
const riskLine = formatMonitorRiskMeta(mo, trendPlan);
|
||
if (riskLine) meta.push(riskLine);
|
||
const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true;
|
||
meta.push(
|
||
`<span class="${beOn ? "pos-meta-on" : "pos-meta-off"}">移动保本:${beOn ? "开" : "关"}</span>`
|
||
);
|
||
} else {
|
||
meta.push("来源: 交易所持仓");
|
||
meta.push("风格: —");
|
||
meta.push(`<span class="pos-meta-off">移动保本:关</span>`);
|
||
}
|
||
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
|
||
const tcSymBadge = !isTrend && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : "";
|
||
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
|
||
return `<div class="pos-card hub-pos-card">
|
||
<div class="pos-card-head">
|
||
<div class="pos-card-symbol">
|
||
<button type="button" class="btn-open-market sym-link pos-symbol-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)"><strong>${esc(symbol)}</strong></button>${tcSymBadge}${symBeBadge}
|
||
<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">${fmtEntryPrice(pos, tickMap)}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">${markDisplay}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">止损</span><span class="pos-value">${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">止盈</span><span class="pos-value${tpMonitored ? " pos-tp-program" : ""}">${formatTpCellValue(tp, tpMonitored, symbol, tickMap)}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">盈亏比</span><span class="pos-value">${rr != null ? fmt(rr, 2) + ":1" : "—"}</span></div>
|
||
<div class="pos-cell"><span class="pos-label">张数</span><span class="pos-value">${fmt(pos.contracts, 4)}</span></div>
|
||
${
|
||
showAccountPnlPref()
|
||
? `<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ${pnlFmt.cls}">${pnlText}</span></div>`
|
||
: ""
|
||
}
|
||
</div>
|
||
<div class="pos-footer">
|
||
<span>杠杆: ${sizingFoot.leverage != null && sizingFoot.leverage !== "" ? esc(sizingFoot.leverage) + "x" : "—"}</span>
|
||
<span>计划基数: ${sizingFoot.planBase != null && sizingFoot.planBase !== "" ? fmt(sizingFoot.planBase, 2) + "U" : "—"}</span>
|
||
<span>仓位占比: ${sizingFoot.positionRatio != null && sizingFoot.positionRatio !== "" ? fmt(sizingFoot.positionRatio, 2) + "%" : "—"}</span>
|
||
</div>
|
||
<div class="pos-ex-orders">
|
||
<div class="pos-ex-orders-title">交易所止盈止损</div>
|
||
${renderExTpslRows(exchangeId, symbol, cond, tickMap, tpsl, pos.contracts)}
|
||
</div>
|
||
${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
|
||
</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 "";
|
||
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
|
||
? `<span class="hub-key-pending-tag">挂单中</span>`
|
||
: "";
|
||
const amtTxt = fmtKeyOrderAmount(k);
|
||
const amtLine = amtTxt
|
||
? `<div class="hub-mini-line">挂单数量 ${esc(amtTxt)}</div>`
|
||
: "";
|
||
const keyTc =
|
||
k.time_close_enabled && k.time_close_at_ms
|
||
? timeCloseSymbolBadgeHtml(k)
|
||
: k.time_close_enabled && k.time_close_hours
|
||
? `<span class="pos-symbol-time-close pos-meta-on">时间平仓 ${esc(k.time_close_hours)}h</span>`
|
||
: "";
|
||
return `<div class="${cardCls}">
|
||
<div class="hub-mini-title">${esc(k.symbol)} ${keyTc} · ${esc(mt)}${dir} ${pendingTag}</div>
|
||
<div class="hub-mini-line">上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}</div>
|
||
${amtLine}
|
||
<div class="hub-mini-line hub-key-status-line">${esc(kp.gate_summary || kp.price_display || kp.price || "—")}${kp.gate_metrics ? ` · ${esc(kp.gate_metrics)}` : ""}</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
return `<div class="hub-key-list">${cards}</div>`;
|
||
}
|
||
|
||
function renderOrderMonitorSection(orders, tickMap) {
|
||
if (!orders || !orders.length) return "";
|
||
return orders
|
||
.map((o) => {
|
||
const sym = o.exchange_symbol || o.symbol || "";
|
||
const tcBadge = o.time_close_enabled ? timeCloseSymbolBadgeHtml(o) : "";
|
||
return `<div class="hub-mini-card">
|
||
<div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} ${tcBadge} · ${renderDirectionHtml(o.direction)}</div>
|
||
<div class="hub-mini-line">触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function renderRollSection(rolls, tickMap) {
|
||
if (!rolls || !rolls.length) return "";
|
||
return rolls
|
||
.map(
|
||
(g) => `<div class="hub-mini-card">
|
||
<div class="hub-mini-title">组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}</div>
|
||
<div class="hub-mini-line">腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmtSymbolPrice(g.current_stop_loss, g.symbol, tickMap)} · ${esc(g.status || "active")}</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function renderPositionTableRow(
|
||
exchangeId,
|
||
exchangeKey,
|
||
x,
|
||
monitorOrder,
|
||
trendPlan,
|
||
tickMap,
|
||
opts
|
||
) {
|
||
const options = opts || {};
|
||
const symAttr = esc(x.symbol || "").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 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()}` : "";
|
||
const mo = monitorOrder || {};
|
||
const tcBadge =
|
||
!isTrendContext(mo, trendPlan) && mo.time_close_enabled ? timeCloseSymbolBadgeHtml(mo) : "";
|
||
const actionCell = `<div class="pos-action-group">
|
||
<button type="button" class="btn-place-tpsl btn-sm ghost" 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="btn-close-pos btn-sm danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>
|
||
</div>`;
|
||
const pnlTd = showAccountPnlPref()
|
||
? `<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 2)}</td>`
|
||
: "";
|
||
return `<tr>
|
||
<td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${tcBadge}${symBeBadge}</td>
|
||
<td class="${sideDirCls(x.side)}">${renderDirectionHtml(x.side)}</td>
|
||
<td class="td-entry">${fmtEntryPrice(x, tickMap)}</td>
|
||
<td>${fmtMarkPrice(x, tickMap)}</td>
|
||
<td>${fmt(x.contracts, 4)}</td>
|
||
${pnlTd}
|
||
<td class="td-actions">${actionCell}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts) {
|
||
const options = opts || {};
|
||
const compact = !!options.compact;
|
||
const reg = Array.isArray(x.regular_orders) ? x.regular_orders : [];
|
||
const cond = condOrdersFromPosition(x);
|
||
const ordersBlock = compact
|
||
? ""
|
||
: renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap);
|
||
const rowHtml = renderPositionTableRow(
|
||
exchangeId,
|
||
exchangeKey,
|
||
x,
|
||
monitorOrder,
|
||
trendPlan,
|
||
tickMap,
|
||
opts
|
||
);
|
||
return `<div class="pos-block">
|
||
<div class="table-scroll">
|
||
${positionTableHeadHtml(false)}
|
||
${rowHtml}
|
||
</tbody></table>
|
||
</div>
|
||
${ordersBlock}
|
||
</div>`;
|
||
}
|
||
|
||
const KEY_BUCKET_FIB_TYPES = new Set([
|
||
"斐波回调0.618",
|
||
"斐波回调0.786",
|
||
"关键位斐波0.618",
|
||
"关键位斐波0.786",
|
||
]);
|
||
const KEY_BUCKET_BREAKOUT_TYPES = new Set([
|
||
"箱体突破",
|
||
"收敛突破",
|
||
"关键位箱体突破",
|
||
"关键位收敛突破",
|
||
"关键位收敛结构",
|
||
]);
|
||
const KEY_BUCKET_WATCH_TYPES = new Set([
|
||
"关键阻力位",
|
||
"关键支撑位",
|
||
"关键位监控",
|
||
]);
|
||
|
||
function classifyKeyMonitorBucket(monitorType) {
|
||
const t = String(monitorType || "").trim();
|
||
if (!t) return "watch";
|
||
if (KEY_BUCKET_FIB_TYPES.has(t) || /斐波/.test(t)) return "fib";
|
||
if (KEY_BUCKET_BREAKOUT_TYPES.has(t) || /突破/.test(t)) return "breakout";
|
||
if (KEY_BUCKET_WATCH_TYPES.has(t) || /阻力|支撑/.test(t)) return "watch";
|
||
return "watch";
|
||
}
|
||
|
||
function countKeyMonitorsByBucket(keys) {
|
||
const counts = { breakout: 0, fib: 0, watch: 0 };
|
||
(keys || []).forEach((k) => {
|
||
if (!k || typeof k !== "object") return;
|
||
const bucket = classifyKeyMonitorBucket(k.monitor_type || k.type);
|
||
if (bucket === "breakout") counts.breakout += 1;
|
||
else if (bucket === "fib") counts.fib += 1;
|
||
else counts.watch += 1;
|
||
});
|
||
return counts;
|
||
}
|
||
|
||
function renderCardStrategyStats(row, hm, flaskOk) {
|
||
if (!flaskOk || !hm || typeof hm !== "object") return "";
|
||
const caps = row.capabilities || [];
|
||
const chips = [];
|
||
if (caps.includes("key")) {
|
||
const kc = countKeyMonitorsByBucket(hm.keys || []);
|
||
if (kc.breakout > 0) chips.push({ kind: "key-breakout", label: `突破 ${kc.breakout}` });
|
||
if (kc.fib > 0) chips.push({ kind: "key-breakout", label: `斐波 ${kc.fib}` });
|
||
if (kc.watch > 0) chips.push({ kind: "key-watch", label: `监控 ${kc.watch}` });
|
||
}
|
||
if (caps.includes("trend")) {
|
||
const trendN = Array.isArray(hm.trends) ? hm.trends.length : 0;
|
||
if (trendN > 0) chips.push({ kind: "trend", label: `趋势回调 ${trendN}` });
|
||
}
|
||
const rollN = Array.isArray(hm.rolls) ? hm.rolls.length : 0;
|
||
if (rollN > 0) chips.push({ kind: "roll", label: `顺势加仓 ${rollN}` });
|
||
if (!chips.length) return "";
|
||
return `<div class="card-strategy-stats">${chips
|
||
.map(
|
||
(c) =>
|
||
`<span class="card-stat-chip card-stat-${esc(c.kind)}">${esc(c.label)}</span>`
|
||
)
|
||
.join("")}</div>`;
|
||
}
|
||
|
||
function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap) {
|
||
const rows = positions
|
||
.map((p) =>
|
||
renderPositionTableRow(
|
||
exchangeId,
|
||
exchangeKey,
|
||
p,
|
||
findMonitorOrder(orders, p.symbol, p.side),
|
||
findTrendPlan(trends, p.symbol, p.side),
|
||
tickMap,
|
||
{ compact: true }
|
||
)
|
||
)
|
||
.join("");
|
||
return `<div class="pos-table-wrap table-scroll">
|
||
${positionTableHeadHtml(true)}
|
||
${rows}
|
||
</tbody></table>
|
||
</div>`;
|
||
}
|
||
|
||
function renderAccountStatRow(row, ag) {
|
||
if (!showAccountPnlPref()) return "";
|
||
const upnl = ag.total_unrealized_pnl;
|
||
return `<div class="stat-row">
|
||
<div class="stat-box"><div class="stat-label">资金账户</div><div class="stat-value">${fmt(row.funding_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">${fmt(row.trading_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(upnl)}">${fmt(upnl, 2)}</div></div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
|
||
const tickMap = buildPriceTickMap(row);
|
||
let inner = renderAccountStatRow(row, ag);
|
||
inner += `<div class="section-title">交易所持仓 · ${pos.length} 仓</div>`;
|
||
if (pos.length) {
|
||
inner += renderGridPositionsTable(
|
||
row.id,
|
||
row.key || row.id,
|
||
pos,
|
||
orders,
|
||
trends,
|
||
tickMap
|
||
);
|
||
} else {
|
||
inner += '<div class="empty-hint">无持仓</div>';
|
||
}
|
||
inner += renderCardStrategyStats(row, hm, flaskOk);
|
||
inner += `<div class="card-expand-hint">点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓</div>`;
|
||
return inner;
|
||
}
|
||
|
||
function renderFullscreenExchange(row) {
|
||
const tickMap = buildPriceTickMap(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 = `<div class="fs-head">
|
||
<div>
|
||
<h2 class="fs-title">${esc(row.name)}</h2>
|
||
<div class="fs-sub">${esc(flaskOpen || "")}</div>
|
||
</div>
|
||
<div class="fs-head-actions">
|
||
<button type="button" class="ghost btn-expand-back">返回监控</button>
|
||
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/">打开实例</a>` : ""}
|
||
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/strategy">策略交易</a>` : ""}
|
||
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
|
||
</div>
|
||
</div>`;
|
||
if (!row.http_ok || ag.ok === false) {
|
||
html += `<div class="err">${esc(row.error || ag.error || "子代理不可用")}</div>`;
|
||
return html;
|
||
}
|
||
html += renderAccountStatRow(row, ag);
|
||
const posCount = pos.length;
|
||
const posListCls = hubPosListCountClass(posCount);
|
||
html += `<div class="section-title">持仓(${posCount} 仓 · 每币种一卡)</div>`;
|
||
html += `<div class="hub-pos-list ${posListCls}" data-pos-count="${posCount}">`;
|
||
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),
|
||
tickMap
|
||
);
|
||
});
|
||
} else {
|
||
html += '<div class="pos-empty">暂无持仓</div>';
|
||
}
|
||
html += "</div>";
|
||
if ((row.capabilities || []).includes("key")) {
|
||
if (!flaskOk) {
|
||
html += renderHubSectionCard("关键位", `<div class="err">${esc(row.flask_error || hm.error || "Flask 未连通")}</div>`, "");
|
||
} else {
|
||
html += renderHubSectionCard(
|
||
`关键位 · ${keys.length}`,
|
||
renderKeySection(keys, kmap),
|
||
"当前无关键位记录"
|
||
);
|
||
}
|
||
}
|
||
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders, tickMap), "暂无运行中的下单监控");
|
||
if ((row.capabilities || []).includes("trend")) {
|
||
html += renderHubSectionCard(
|
||
"趋势回调",
|
||
renderTrendSection(trends, tickMap, pos, row),
|
||
"暂无运行中的趋势回调计划"
|
||
);
|
||
}
|
||
html += renderHubSectionCard("顺势加仓", renderRollSection(rolls, tickMap), "暂无运行中的顺势加仓组");
|
||
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();
|
||
refreshMonitorBoardNow();
|
||
}
|
||
} 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);
|
||
refreshMonitorBoardNow();
|
||
} 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);
|
||
refreshMonitorBoardNow();
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
function renderMonitorTile(row) {
|
||
const ag = row.agent || {};
|
||
const pos = Array.isArray(ag.positions) ? ag.positions : [];
|
||
const alert = analyzeExchangeAlert(row);
|
||
const upnl = ag.total_unrealized_pnl;
|
||
const openCount = pos.filter(positionHasContracts).length;
|
||
const dotCls =
|
||
alert.level === "error" ? "bad" : alert.level === "warn" ? "warn" : "ok";
|
||
const tileCls =
|
||
alert.level === "error"
|
||
? "hub-tile-error"
|
||
: alert.level === "warn"
|
||
? "hub-tile-warn"
|
||
: "hub-tile-ok";
|
||
const ts = (lastMonitorBoardUpdatedAt || "").replace("T", " ");
|
||
const tsShort = ts ? ts.slice(-8) : "—";
|
||
const posLine =
|
||
openCount > 0 ? `${openCount}仓 · ${alert.summary}` : alert.summary;
|
||
const hm = row.hub_monitor || {};
|
||
const flaskOk = row.flask_ok !== false && hm.ok !== false;
|
||
const strategyStats = renderCardStrategyStats(row, hm, flaskOk);
|
||
return `<div class="card hub-tile ${tileCls}" data-ex-id="${esc(row.id)}">
|
||
<div class="hub-tile-body card-expand-zone" title="点击进入全屏详情">
|
||
<div class="hub-tile-top">
|
||
<span class="status-dot ${dotCls}" aria-hidden="true"></span>
|
||
<span class="hub-tile-name">${esc(row.name)}</span>
|
||
</div>
|
||
${
|
||
showAccountPnlPref()
|
||
? `<div class="hub-tile-pnl ${pnlCls(upnl)}">${fmt(upnl, 2)} <small>U</small></div>`
|
||
: ""
|
||
}
|
||
<div class="hub-tile-meta">${esc(posLine)}</div>
|
||
${strategyStats}
|
||
<div class="hub-tile-foot">UPD ${esc(tsShort)}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderMonitorCard(row) {
|
||
const ag = row.agent || {};
|
||
const pos = Array.isArray(ag.positions) ? ag.positions : [];
|
||
const hm = row.hub_monitor || {};
|
||
const flaskOk = row.flask_ok !== false && hm.ok !== false;
|
||
const keys = flaskOk ? hm.keys || [] : [];
|
||
const orders = flaskOk ? hm.orders || [] : [];
|
||
const trends = flaskOk ? hm.trends || [] : [];
|
||
const 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 = `<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 = 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
|
||
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/">实例</a>`
|
||
: "";
|
||
const openReview = flaskOpen
|
||
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>`
|
||
: "";
|
||
return `<div class="card ${cardCls}" data-ex-id="${esc(row.id)}">
|
||
<div class="card-head card-expand-zone" title="点击放大全屏">
|
||
<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}
|
||
${openReview}
|
||
<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 hubTrendPlanStop(exchangeId, planId) {
|
||
if (!exchangeId || !planId) {
|
||
showToast("缺少交易所或计划 ID", true);
|
||
return;
|
||
}
|
||
if (!confirm("结束计划:市价平仓并撤掉该合约全部挂单,确定?")) return;
|
||
try {
|
||
const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/stop", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ plan_id: Number(planId) }),
|
||
});
|
||
const j = await r.json();
|
||
showToast(j.message || (j.ok ? "已结束趋势回调计划" : "结束失败"), !j.ok);
|
||
if (j.ok) refreshMonitorBoardNow();
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
async function hubTrendPlanBreakeven(exchangeId, planId, inputEl) {
|
||
if (!exchangeId || !planId) {
|
||
showToast("缺少交易所或计划 ID", true);
|
||
return;
|
||
}
|
||
const raw = inputEl ? String(inputEl.value || "").trim() : "";
|
||
let pct = null;
|
||
if (raw !== "") {
|
||
pct = Number(raw);
|
||
if (!Number.isFinite(pct) || pct < 0) {
|
||
showToast("保本偏移% 须为非负数", true);
|
||
return;
|
||
}
|
||
}
|
||
if (
|
||
!confirm(
|
||
"确认保本?将结束本趋势计划,持仓移交「下单监控」,并在交易所挂保本止损与计划止盈;后续平仓写入交易记录。"
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
try {
|
||
const body = { plan_id: Number(planId) };
|
||
if (pct != null) body.breakeven_offset_pct = pct;
|
||
const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/breakeven", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const j = await r.json();
|
||
showToast(j.message || (j.ok ? "保本移交成功" : "保本移交失败"), !j.ok);
|
||
if (j.ok) refreshMonitorBoardNow();
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
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);
|
||
refreshMonitorBoardNow();
|
||
} 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);
|
||
refreshMonitorBoardNow();
|
||
} 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);
|
||
refreshMonitorBoardNow();
|
||
} 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) => {
|
||
syncDisplayPrefsUI(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")];
|
||
const pnlCb = document.getElementById("pref-show-account-pnl");
|
||
const fundsCb = document.getElementById("pref-show-nav-funds");
|
||
const dashCb = document.getElementById("pref-show-nav-dashboard");
|
||
return {
|
||
version: 1,
|
||
display: {
|
||
show_account_pnl: pnlCb ? !!pnlCb.checked : true,
|
||
show_nav_funds: fundsCb ? !!fundsCb.checked : true,
|
||
show_nav_dashboard: dashCb ? !!dashCb.checked : true,
|
||
},
|
||
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;
|
||
syncDisplayPrefsUI(j.settings);
|
||
renderSettingsList(j.settings);
|
||
loadSettingsMetaLine();
|
||
} else {
|
||
await loadSettingsUI();
|
||
}
|
||
if (lastMonitorRows.length) renderMonitorGrid(lastMonitorRows);
|
||
if (!pageNavAllowed(currentPage())) {
|
||
history.replaceState({}, "", "/monitor");
|
||
setActiveNav();
|
||
}
|
||
} 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 = () => refreshMonitorBoardNow();
|
||
document.getElementById("auto-monitor").onchange = () => {
|
||
if (document.getElementById("auto-monitor").checked) {
|
||
connectMonitorBoardStream();
|
||
} else {
|
||
closeMonitorBoardStream();
|
||
}
|
||
};
|
||
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 后点「保存设置」");
|
||
};
|
||
|
||
let aiChatLoading = false;
|
||
let aiChatSessionCache = null;
|
||
let aiChatSessionsCache = [];
|
||
let aiSelectedBotMode = "trading";
|
||
|
||
function renderHubMarkdown(text) {
|
||
const raw = String(text || "");
|
||
if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) {
|
||
return window.AiReviewRender.renderMarkdown(raw);
|
||
}
|
||
return esc(raw)
|
||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||
.replace(/\n/g, "<br>");
|
||
}
|
||
|
||
function scrollAiChatToEnd() {
|
||
const box = document.getElementById("ai-chat-messages");
|
||
if (!box) return;
|
||
const run = () => {
|
||
box.scrollTop = box.scrollHeight;
|
||
const rows = box.querySelectorAll(".ai-msg-row");
|
||
const last = rows[rows.length - 1];
|
||
if (last && last.scrollIntoView) {
|
||
try {
|
||
last.scrollIntoView({ block: "end", behavior: "auto" });
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
};
|
||
requestAnimationFrame(() => requestAnimationFrame(run));
|
||
}
|
||
|
||
function updateAiBotTabs(mode) {
|
||
const m = mode === "general" ? "general" : "trading";
|
||
aiSelectedBotMode = m;
|
||
document.querySelectorAll(".ai-bot-tab").forEach((btn) => {
|
||
const on = (btn.dataset.bot || "trading") === m;
|
||
btn.classList.toggle("is-active", on);
|
||
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||
});
|
||
const input = document.getElementById("ai-chat-input");
|
||
if (input) {
|
||
input.placeholder =
|
||
m === "general"
|
||
? "随便聊点什么,不绑交易数据…"
|
||
: "聊聊行情、心态、纪律、执行…";
|
||
}
|
||
}
|
||
|
||
function renderAiChatHistory(sessions) {
|
||
const list = document.getElementById("ai-chat-history-list");
|
||
if (!list) return;
|
||
const items = Array.isArray(sessions) ? sessions : [];
|
||
if (!items.length) {
|
||
list.innerHTML = '<p class="ai-placeholder">暂无历史,发送消息后会出现在这里。</p>';
|
||
return;
|
||
}
|
||
list.innerHTML = items
|
||
.map((s) => {
|
||
const mode = s.bot_mode === "general" ? "general" : "trading";
|
||
const badge = mode === "general" ? "普通" : "交易";
|
||
const badgeCls = mode === "general" ? "" : " trading";
|
||
const active = s.is_active ? " is-active" : "";
|
||
const time = esc((s.updated_at || s.created_at || "").slice(0, 16));
|
||
const title = esc(s.title || "新对话");
|
||
const preview = esc(s.preview || "(空会话)");
|
||
const sid = esc(s.id || "");
|
||
return (
|
||
`<div class="ai-chat-history-item${active}" role="listitem" data-session-id="${sid}">` +
|
||
`<div class="ai-chat-history-item-main">` +
|
||
`<span class="ai-chat-history-item-title">${title}</span>` +
|
||
`<span class="ai-chat-history-item-preview">${preview}</span>` +
|
||
`<span class="ai-chat-history-item-meta">` +
|
||
`<span>${time}</span>` +
|
||
`<span class="ai-chat-history-badge${badgeCls}">${badge}</span>` +
|
||
`<span>${Number(s.message_count) || 0} 条</span>` +
|
||
`</span>` +
|
||
`</div>` +
|
||
`<button type="button" class="ai-chat-history-del" title="删除" data-delete-session="${sid}" aria-label="删除">×</button>` +
|
||
`</div>`
|
||
);
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
function renderAiChatRow(role, content, extraClass, attachments, rowOpts) {
|
||
const opts = rowOpts || {};
|
||
const botMode = opts.botMode === "general" ? "general" : "trading";
|
||
const isUser = role === "user";
|
||
const label = isUser ? "主人" : botMode === "general" ? "助手" : "交易教练";
|
||
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
|
||
const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant";
|
||
const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
|
||
const isError =
|
||
!isUser &&
|
||
!isThinking &&
|
||
/^(AI 调用失败|AI 生成失败)/.test(String(content || "").trim());
|
||
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "");
|
||
const mdCls = !isUser && !isThinking ? " ai-result-md" : "";
|
||
const attList = Array.isArray(attachments) ? attachments : [];
|
||
const attHtml = attList.length
|
||
? `<div class="ai-msg-attachments">${attList
|
||
.map((a) => `<span class="ai-attach-chip">${esc(a.name || "附件")}</span>`)
|
||
.join("")}</div>`
|
||
: "";
|
||
const canCopy = !isThinking && String(content || "").trim();
|
||
const copyHtml = canCopy
|
||
? `<div class="ai-msg-actions"><button type="button" class="ai-msg-copy-btn" data-msg-idx="${opts.msgIdx != null ? opts.msgIdx : ""}">复制</button></div>`
|
||
: "";
|
||
return (
|
||
`<div class="ai-msg-row ${rowCls}">` +
|
||
`<span class="ai-msg-role">${label}</span>` +
|
||
`${attHtml}` +
|
||
`<div class="ai-bubble ${bubbleCls}${mdCls}${isError ? " ai-bubble-error" : ""}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
|
||
`${copyHtml}` +
|
||
`</div>`
|
||
);
|
||
}
|
||
|
||
function renderAiChatMessages(session, opts) {
|
||
const options = opts || {};
|
||
const box = document.getElementById("ai-chat-messages");
|
||
const title = document.getElementById("ai-chat-title");
|
||
if (!box) return;
|
||
const msgs = (session && session.messages) || [];
|
||
const botMode = (session && session.bot_mode) || aiSelectedBotMode || "trading";
|
||
if (title) {
|
||
const modeLabel = botMode === "general" ? "普通聊天" : "交易教练";
|
||
const sessionTitle = session && session.title ? String(session.title) : "";
|
||
if (isMobileAiLayout()) {
|
||
title.textContent = sessionTitle && sessionTitle !== "新对话"
|
||
? sessionTitle
|
||
: modeLabel;
|
||
} else {
|
||
title.textContent = sessionTitle
|
||
? `${modeLabel} · ${sessionTitle}`
|
||
: modeLabel;
|
||
}
|
||
}
|
||
const showPlaceholder =
|
||
!msgs.length && !options.pendingUser && !options.thinking;
|
||
if (showPlaceholder) {
|
||
const hint =
|
||
botMode === "general"
|
||
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。"
|
||
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可点「附件」上传图片或文档。";
|
||
box.innerHTML = `<p class="ai-placeholder">${hint}</p>`;
|
||
return;
|
||
}
|
||
let html = msgs
|
||
.map((m, idx) =>
|
||
renderAiChatRow(
|
||
m.role === "user" ? "user" : "assistant",
|
||
m.content || "",
|
||
null,
|
||
m.attachments,
|
||
{ botMode, msgIdx: idx }
|
||
)
|
||
)
|
||
.join("");
|
||
if (options.pendingUser) {
|
||
html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments);
|
||
}
|
||
if (options.thinking) {
|
||
html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking");
|
||
}
|
||
box.innerHTML = html;
|
||
scrollAiChatToEnd();
|
||
}
|
||
|
||
function setAiChatBusy(busy) {
|
||
aiChatLoading = !!busy;
|
||
const btn = document.getElementById("btn-ai-chat-send");
|
||
const input = document.getElementById("ai-chat-input");
|
||
if (btn) btn.disabled = busy;
|
||
if (input) input.disabled = busy;
|
||
}
|
||
|
||
async function loadAiChatSession() {
|
||
const r = await apiFetch("/api/ai/chat/session");
|
||
const j = await r.json();
|
||
aiChatSessionCache = j.session || null;
|
||
aiChatSessionsCache = j.sessions || [];
|
||
renderAiChatMessages(aiChatSessionCache);
|
||
renderAiChatHistory(aiChatSessionsCache);
|
||
updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode);
|
||
}
|
||
|
||
async function switchAiChatSession(sessionId) {
|
||
if (!sessionId || aiChatLoading) return;
|
||
try {
|
||
const r = await apiFetch("/api/ai/chat/switch", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ session_id: sessionId }),
|
||
});
|
||
const j = await r.json();
|
||
if (!r.ok) throw new Error(j.detail || j.msg || "切换失败");
|
||
aiChatSessionCache = j.session || null;
|
||
aiChatSessionsCache = j.sessions || [];
|
||
renderAiChatMessages(aiChatSessionCache);
|
||
renderAiChatHistory(aiChatSessionsCache);
|
||
const mode =
|
||
(aiChatSessionCache && aiChatSessionCache.bot_mode) === "general" ? "general" : "trading";
|
||
updateAiBotTabs(mode);
|
||
if (isMobileAiLayout()) {
|
||
localStorage.setItem(AI_MOBILE_TAB_KEY, mode);
|
||
applyAiMobileTab(mode);
|
||
}
|
||
scrollAiChatToEnd();
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
async function deleteAiChatSession(sessionId) {
|
||
if (!sessionId) return;
|
||
if (!confirm("确定删除这条聊天历史?")) return;
|
||
try {
|
||
const r = await apiFetch(`/api/ai/chat/session/${encodeURIComponent(sessionId)}`, {
|
||
method: "DELETE",
|
||
});
|
||
const j = await r.json();
|
||
if (!r.ok) throw new Error(j.detail || j.msg || "删除失败");
|
||
aiChatSessionCache = j.session || null;
|
||
aiChatSessionsCache = j.sessions || [];
|
||
renderAiChatMessages(aiChatSessionCache);
|
||
renderAiChatHistory(aiChatSessionsCache);
|
||
updateAiBotTabs(
|
||
(aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode || "trading"
|
||
);
|
||
showToast("已删除");
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai";
|
||
let archiveQuoteAiPending = false;
|
||
|
||
async function consumeArchiveQuoteAiPending() {
|
||
if (archiveQuoteAiPending || aiChatLoading) return;
|
||
let raw = "";
|
||
try {
|
||
raw = sessionStorage.getItem(ARCHIVE_QUOTE_AI_KEY) || "";
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
if (!raw) return;
|
||
sessionStorage.removeItem(ARCHIVE_QUOTE_AI_KEY);
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(raw);
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
const content = String((payload && payload.content) || "").trim();
|
||
const quoteDate = String((payload && payload.quote_date) || "").trim();
|
||
if (!content) return;
|
||
|
||
const input = document.getElementById("ai-chat-input");
|
||
if (input) input.value = content;
|
||
updateAiBotTabs("trading");
|
||
if (isMobileAiLayout()) {
|
||
localStorage.setItem(AI_MOBILE_TAB_KEY, "trading");
|
||
applyAiMobileTab("trading");
|
||
}
|
||
|
||
archiveQuoteAiPending = true;
|
||
setAiChatBusy(true);
|
||
renderAiChatMessages(aiChatSessionCache, {
|
||
pendingUser: content,
|
||
thinking: true,
|
||
});
|
||
try {
|
||
const r = await apiFetch("/api/ai/chat/archive-quote", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ quote_date: quoteDate, content }),
|
||
});
|
||
const j = await r.json();
|
||
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
|
||
aiChatSessionCache = j.session || null;
|
||
aiChatSessionsCache = j.sessions || aiChatSessionsCache;
|
||
renderAiChatMessages(aiChatSessionCache);
|
||
renderAiChatHistory(aiChatSessionsCache);
|
||
if (input) input.value = "";
|
||
showToast("复盘语录已发送给交易教练");
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
if (input) input.value = content;
|
||
try {
|
||
await loadAiChatSession();
|
||
} catch (_) {
|
||
renderAiChatMessages(aiChatSessionCache);
|
||
}
|
||
} finally {
|
||
archiveQuoteAiPending = false;
|
||
setAiChatBusy(false);
|
||
}
|
||
}
|
||
|
||
async function loadAiPage() {
|
||
applyAiMobileTab();
|
||
await loadAiChatSession();
|
||
await consumeArchiveQuoteAiPending();
|
||
const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading");
|
||
if (isMobileAiLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) {
|
||
const input = document.getElementById("ai-chat-input");
|
||
if (input && !aiChatLoading) {
|
||
setTimeout(() => input.focus(), 80);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function newAiChat(botMode) {
|
||
const mode = botMode === "general" ? "general" : "trading";
|
||
try {
|
||
const r = await apiFetch("/api/ai/chat/new", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ bot_mode: mode }),
|
||
});
|
||
const j = await r.json();
|
||
aiChatSessionCache = j.session || null;
|
||
aiChatSessionsCache = j.sessions || [];
|
||
renderAiChatMessages(aiChatSessionCache);
|
||
renderAiChatHistory(aiChatSessionsCache);
|
||
updateAiBotTabs(mode);
|
||
if (isMobileAiLayout()) {
|
||
localStorage.setItem(AI_MOBILE_TAB_KEY, mode);
|
||
applyAiMobileTab(mode);
|
||
}
|
||
showToast(mode === "general" ? "已开始普通聊天" : "已开始交易教练对话");
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
async function sendAiChat(ev) {
|
||
if (ev) ev.preventDefault();
|
||
if (aiChatLoading) return;
|
||
const input = document.getElementById("ai-chat-input");
|
||
const fileInput = document.getElementById("ai-chat-files");
|
||
const fileLabel = document.getElementById("ai-chat-files-label");
|
||
const text = (input && input.value || "").trim();
|
||
const files = fileInput && fileInput.files ? Array.from(fileInput.files) : [];
|
||
if (!text && !files.length) return;
|
||
const pendingAttachments = files.map((f) => ({ name: f.name, kind: f.type.startsWith("image/") ? "image" : "text" }));
|
||
const savedText = text;
|
||
if (input) input.value = "";
|
||
setAiChatBusy(true);
|
||
renderAiChatMessages(aiChatSessionCache, {
|
||
pendingUser: text || (files.length ? `(上传 ${files.length} 个附件)` : ""),
|
||
pendingAttachments,
|
||
thinking: true,
|
||
});
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append("message", text);
|
||
files.forEach((f) => fd.append("files", f, f.name));
|
||
const r = await apiFetch("/api/ai/chat/send", { method: "POST", body: fd });
|
||
const j = await r.json();
|
||
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
|
||
aiChatSessionCache = j.session || null;
|
||
aiChatSessionsCache = j.sessions || aiChatSessionsCache;
|
||
renderAiChatMessages(aiChatSessionCache);
|
||
renderAiChatHistory(aiChatSessionsCache);
|
||
if (fileInput) fileInput.value = "";
|
||
if (fileLabel) fileLabel.textContent = "";
|
||
if (j.attachment_warnings && j.attachment_warnings.length) {
|
||
showToast(j.attachment_warnings.join(";"), true);
|
||
}
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
if (input && savedText) input.value = savedText;
|
||
try {
|
||
await loadAiChatSession();
|
||
} catch (_) {
|
||
renderAiChatMessages(aiChatSessionCache);
|
||
}
|
||
} finally {
|
||
setAiChatBusy(false);
|
||
}
|
||
}
|
||
|
||
const aiChatFiles = document.getElementById("ai-chat-files");
|
||
const aiChatFilesLabel = document.getElementById("ai-chat-files-label");
|
||
if (aiChatFiles && aiChatFilesLabel) {
|
||
aiChatFiles.addEventListener("change", () => {
|
||
const names = aiChatFiles.files ? Array.from(aiChatFiles.files).map((f) => f.name) : [];
|
||
aiChatFilesLabel.textContent = names.length ? names.join("、") : "";
|
||
});
|
||
}
|
||
|
||
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
|
||
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode);
|
||
const aiChatForm = document.getElementById("ai-chat-form");
|
||
if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat);
|
||
|
||
function initAiChatInteractions() {
|
||
const hist = document.getElementById("ai-chat-history-list");
|
||
if (hist && !hist._aiBound) {
|
||
hist._aiBound = true;
|
||
hist.addEventListener("click", (ev) => {
|
||
const delBtn = ev.target.closest(".ai-chat-history-del");
|
||
if (delBtn) {
|
||
ev.stopPropagation();
|
||
const sid = delBtn.getAttribute("data-delete-session");
|
||
if (sid) deleteAiChatSession(sid);
|
||
return;
|
||
}
|
||
const item = ev.target.closest(".ai-chat-history-item");
|
||
if (!item) return;
|
||
const sid = item.getAttribute("data-session-id");
|
||
if (sid) switchAiChatSession(sid);
|
||
});
|
||
}
|
||
const box = document.getElementById("ai-chat-messages");
|
||
if (box && !box._aiCopyBound) {
|
||
box._aiCopyBound = true;
|
||
box.addEventListener("click", async (ev) => {
|
||
const btn = ev.target.closest(".ai-msg-copy-btn");
|
||
if (!btn) return;
|
||
const idx = Number(btn.getAttribute("data-msg-idx"));
|
||
const msgs = (aiChatSessionCache && aiChatSessionCache.messages) || [];
|
||
const text = msgs[idx] && msgs[idx].content ? String(msgs[idx].content) : "";
|
||
if (!text) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
showToast("已复制");
|
||
} catch (_) {
|
||
showToast("复制失败", true);
|
||
}
|
||
});
|
||
}
|
||
document.querySelectorAll(".ai-bot-tab").forEach((btn) => {
|
||
if (btn._aiBotBound) return;
|
||
btn._aiBotBound = true;
|
||
btn.addEventListener("click", () => {
|
||
const mode = btn.getAttribute("data-bot") || "trading";
|
||
newAiChat(mode);
|
||
});
|
||
});
|
||
}
|
||
initAiChatInteractions();
|
||
|
||
initTpslModal();
|
||
initInstanceFrame();
|
||
initFullscreen();
|
||
initMobileLayout();
|
||
if (globalThis.HubTheme && typeof HubTheme.initToggleUI === "function") {
|
||
HubTheme.initToggleUI();
|
||
}
|
||
|
||
function initShellNav() {
|
||
document.querySelectorAll(".top-nav a[href^='/']").forEach((a) => {
|
||
a.addEventListener("click", (ev) => {
|
||
const href = a.getAttribute("href");
|
||
if (!href || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
|
||
ev.preventDefault();
|
||
const path = href.split("?")[0];
|
||
if (path === window.location.pathname) {
|
||
setActiveNav();
|
||
return;
|
||
}
|
||
history.pushState({}, "", href);
|
||
setActiveNav();
|
||
});
|
||
});
|
||
window.addEventListener("popstate", setActiveNav);
|
||
}
|
||
|
||
window.hubNavigateTo = function hubNavigateTo(path) {
|
||
const href = String(path || "/").split("?")[0] || "/";
|
||
if (href === window.location.pathname) {
|
||
setActiveNav();
|
||
return;
|
||
}
|
||
history.pushState({}, "", href);
|
||
setActiveNav();
|
||
};
|
||
|
||
window.hubOpenMonitorExpand = function hubOpenMonitorExpand(exId) {
|
||
const id = String(exId || "").trim();
|
||
if (!id) return;
|
||
expandedExchangeId = id;
|
||
sessionStorage.setItem("hub_expanded_ex", id);
|
||
if (currentPage() !== "monitor") {
|
||
history.pushState({}, "", "/monitor");
|
||
setActiveNav();
|
||
}
|
||
if (lastMonitorRows.length) {
|
||
openExchangeFullscreen(id);
|
||
} else {
|
||
void fetchMonitorBoardSnapshot({ showLoading: true });
|
||
}
|
||
};
|
||
|
||
initAuth().then((ok) => {
|
||
if (!ok) return;
|
||
initShellNav();
|
||
loadSettings()
|
||
.then((data) => {
|
||
syncDisplayPrefsUI(data);
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
setActiveNav();
|
||
});
|
||
});
|
||
})();
|