Files
crypto_monitor/manual_trading_hub/static/app.js
T
dekun 850ffcd7d2 style(risk): polish account status badge for light and dark themes
Extract shared account_risk_badge.css with theme-aware contrast, dot indicator, and hub/instance layout fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:37:32 +08:00

4255 lines
155 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const toast = document.getElementById("toast");
let settingsCache = null;
let 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 >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad";
return "ok";
}
function hostOverallLevel(cpu, mem, disk) {
const vals = [cpu && cpu.percent, mem && mem.percent, disk && disk.percent];
for (let i = 0; i < vals.length; i++) {
const p = Number(vals[i]);
if (Number.isFinite(p) && p >= HOST_RESOURCE_ALERT_THRESHOLD) return "bad";
}
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", "ok");
fillEl.classList.add(level === "bad" ? "bad" : "ok");
}
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 hostMetricSummaryHtml(label, percent) {
const p = Number(percent);
if (!Number.isFinite(p)) {
return esc(label) + " —";
}
const tone = hostMetricLevel(p);
return (
esc(label) +
' <span class="host-metric-tone ' +
tone +
'">' +
p +
"%</span>"
);
}
function renderHostStatusSummary(data, el) {
if (!el) return;
if (!data || !data.ok) {
el.className = "host-status-summary-text bad";
el.textContent = (data && data.msg) || "状态不可用";
return;
}
const cpu = data.cpu || {};
const mem = data.memory || {};
const disk = data.disk || {};
const parts = [];
const host = String(data.hostname || "").trim();
if (host) {
parts.push('<span class="host-summary-host">' + esc(host) + "</span>");
}
if (cpu.percent != null) parts.push(hostMetricSummaryHtml("CPU", cpu.percent));
if (mem.percent != null) parts.push(hostMetricSummaryHtml("内存", mem.percent));
if (disk.percent != null) parts.push(hostMetricSummaryHtml("硬盘", disk.percent));
el.className = "host-status-summary-text";
el.innerHTML = parts.length
? parts.join(' <span class="host-summary-sep">·</span> ')
: "—";
}
function setHostMetricVal(el, percent) {
if (!el) return;
const p = Number(percent);
el.classList.remove("ok", "bad");
if (!Number.isFinite(p)) {
el.textContent = "—";
return;
}
el.textContent = p + "%";
el.classList.add(hostMetricLevel(p));
}
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");
renderHostStatusSummary(data, summaryText);
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 overall = hostOverallLevel(cpu, mem, disk);
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);
setHostMetricVal(cpuVal, cpu.percent);
setHostMetricVal(memVal, mem.percent);
setHostMetricVal(diskVal, disk.percent);
if (cpuSub) cpuSub.textContent = cpu.count ? cpu.count + " 核" : "";
if (memSub) {
memSub.textContent =
fmtHostBytes(mem.used_bytes) + " / " + fmtHostBytes(mem.total_bytes);
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatRiskStatusBadge(riskStatus) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const st = riskStatus.status || "normal";
const label = esc(riskStatus.status_label || "正常");
const title = esc(riskStatus.reason || "");
return `<span class="risk-status-badge risk-status-${esc(st)}" role="status" title="${title}">${label}</span>`;
}
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) {
const m = mo || {};
if (!isTrend || !trendPlan || !trendPlan.id) {
return {
margin: m.exchange_initial_margin ?? m.plan_margin ?? null,
leverage: m.leverage,
planBase: m.margin_capital,
positionRatio: m.position_ratio,
};
}
const base =
trendPlan.snapshot_available_usdt != null && trendPlan.snapshot_available_usdt !== ""
? trendPlan.snapshot_available_usdt
: trendPlan.plan_margin_capital;
return {
margin: m.exchange_initial_margin ?? trendPlan.plan_margin_capital ?? null,
leverage: trendPlan.leverage,
planBase: base,
positionRatio: resolveTrendPositionRatioPct(trendPlan),
};
}
function resolvePositionOpenMeta(mo, trendPlan, isTrend) {
const useTrend = isTrend && trendPlan && trendPlan.id;
const src = useTrend ? trendPlan : mo || {};
let ms = Number(src.opened_at_ms);
if (!Number.isFinite(ms) || ms <= 0) {
const s = String(src.opened_at || "").trim();
if (s) {
const parsed = Date.parse(s.replace(" ", "T"));
ms = Number.isFinite(parsed) ? parsed : null;
} else {
ms = null;
}
} else {
ms = Math.round(ms);
}
let display = "—";
if (src.opened_at) {
display = String(src.opened_at).replace("T", " ").slice(0, 16);
} else if (ms) {
display = new Date(ms).toISOString().slice(0, 16).replace("T", " ");
}
return { openedAtMs: ms, openedAtDisplay: display };
}
function formatLiveHoldDuration(openedMs, nowMs) {
if (openedMs == null || !Number.isFinite(Number(openedMs))) return "—";
const ms = Number(openedMs);
const now = nowMs != null ? nowMs : Date.now();
let sec = Math.floor((now - ms) / 1000);
if (sec < 0) sec = 0;
if (sec <= 0) return "0分钟";
const d = Math.floor(sec / 86400);
sec %= 86400;
const h = Math.floor(sec / 3600);
sec %= 3600;
const m = Math.floor(sec / 60);
const parts = [];
if (d) parts.push(`${d}`);
if (h) parts.push(`${h}小时`);
if (m || !parts.length) parts.push(`${m}分钟`);
return parts.join("");
}
let hubHoldDurationTimer = null;
function tickHubHoldDurations() {
const now = Date.now();
document.querySelectorAll(".pos-hold-duration[data-opened-ms]").forEach((el) => {
const ms = Number(el.getAttribute("data-opened-ms"));
if (!Number.isFinite(ms) || ms <= 0) return;
el.textContent = formatLiveHoldDuration(ms, now);
});
}
function ensureHubHoldDurationTimer() {
tickHubHoldDurations();
if (hubHoldDurationTimer) return;
hubHoldDurationTimer = setInterval(tickHubHoldDurations, 1000);
}
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();
}
ensureHubHoldDurationTimer();
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();
}
ensureHubHoldDurationTimer();
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, "&quot;");
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, "&quot;");
const ctxEnc = esc(
encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId))
).replace(
/"/g,
"&quot;"
);
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, "&quot;");
const rows = orders
.map((o) => {
const oidAttr = esc(o.id || "").replace(/"/g, "&quot;");
const chAttr = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig =
o.trigger_price != null
? 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, "&quot;");
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, "&quot;");
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, "&quot;");
const ch = esc(o.channel || "regular").replace(/"/g, "&quot;");
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, "&quot;");
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, "&quot;");
const sideAttr = esc(side).replace(/"/g, "&quot;");
const contractsAttr = esc(String(pos.contracts != null ? pos.contracts : "")).replace(/"/g, "&quot;");
const slAttr = esc(String(tpsl.sl)).replace(/"/g, "&quot;");
const tpAttr = esc(String(tpsl.tp)).replace(/"/g, "&quot;");
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 openMeta = resolvePositionOpenMeta(mo, trendPlan, isTrend);
const marginText =
sizingFoot.margin != null && sizingFoot.margin !== "" && Number.isFinite(Number(sizingFoot.margin))
? fmt(Number(sizingFoot.margin), 2) + "U"
: "—";
const holdMsAttr =
openMeta.openedAtMs != null && Number.isFinite(openMeta.openedAtMs)
? String(openMeta.openedAtMs)
: "";
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>保证金: ${marginText}</span>
<span>计划基数: ${sizingFoot.planBase != null && sizingFoot.planBase !== "" ? fmt(sizingFoot.planBase, 2) + "U" : "—"}</span>
<span>杠杆: ${sizingFoot.leverage != null && sizingFoot.leverage !== "" ? esc(sizingFoot.leverage) + "x" : "—"}</span>
<span>仓位占比: ${sizingFoot.positionRatio != null && sizingFoot.positionRatio !== "" ? fmt(sizingFoot.positionRatio, 2) + "%" : "—"}</span>
<span>开仓时间: ${esc(openMeta.openedAtDisplay)}</span>
<span>持仓时长: <span class="pos-hold-duration" data-opened-ms="${esc(holdMsAttr)}">${esc(formatLiveHoldDuration(openMeta.openedAtMs))}</span></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, "&quot;");
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;");
const side = sideAttr || "long";
const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace(
/"/g,
"&quot;"
);
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, "&quot;");
const tpAttr = esc(String(tpsl.tp)).replace(/"/g, "&quot;");
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>
${formatRiskStatusBadge(hm.risk_status)}
</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"><span>${esc(row.name)}</span>${formatRiskStatusBadge(hm.risk_status)}</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";
const AI_CHAT_MAX_ATTACHMENTS = 3;
let aiChatPendingFiles = [];
const aiChatMdCache = new Map();
const AI_CHAT_MD_CACHE_MAX = 120;
function aiChatFileKind(file) {
return file && file.type && file.type.startsWith("image/") ? "image" : "text";
}
function isValidAiChatFile(file) {
if (!file) return false;
if (file.type && file.type.startsWith("image/")) return true;
const mime = (file.type || "").toLowerCase();
if (["text/plain", "text/markdown", "application/json"].includes(mime)) return true;
const name = (file.name || "").toLowerCase();
return (
name.endsWith(".txt") ||
name.endsWith(".md") ||
name.endsWith(".markdown") ||
name.endsWith(".json")
);
}
function syncAiChatFileInput() {
const fileInput = document.getElementById("ai-chat-files");
if (!fileInput || typeof DataTransfer === "undefined") return;
const dt = new DataTransfer();
aiChatPendingFiles.forEach((f) => dt.items.add(f));
fileInput.files = dt.files;
}
function renderAiChatPendingAttachments() {
const box = document.getElementById("ai-chat-pending");
if (!box) return;
if (!aiChatPendingFiles.length) {
box.innerHTML = "";
box.hidden = true;
return;
}
box.hidden = false;
box.innerHTML = aiChatPendingFiles
.map((f, idx) => {
const kind = aiChatFileKind(f);
const icon = kind === "image" ? "图" : "文";
return (
`<span class="ai-chat-pending-chip" data-pending-idx="${idx}">` +
`<span class="ai-chat-pending-kind">${icon}</span>` +
`<span class="ai-chat-pending-name" title="${esc(f.name || "附件")}">${esc(f.name || "附件")}</span>` +
`<button type="button" class="ai-chat-pending-del" data-pending-del="${idx}" title="移除" aria-label="移除附件">×</button>` +
`</span>`
);
})
.join("");
}
function addAiChatPendingFiles(files) {
const incoming = Array.isArray(files) ? files : [];
if (!incoming.length) return;
let added = 0;
for (const file of incoming) {
if (aiChatPendingFiles.length >= AI_CHAT_MAX_ATTACHMENTS) {
showToast(`最多 ${AI_CHAT_MAX_ATTACHMENTS} 个附件`, true);
break;
}
if (!isValidAiChatFile(file)) {
showToast(`${file.name || "文件"}: 不支持的类型(仅图片或 txt/md/json`, true);
continue;
}
aiChatPendingFiles.push(file);
added += 1;
}
if (!added) return;
syncAiChatFileInput();
renderAiChatPendingAttachments();
}
function removeAiChatPendingFile(index) {
if (index < 0 || index >= aiChatPendingFiles.length) return;
aiChatPendingFiles.splice(index, 1);
syncAiChatFileInput();
renderAiChatPendingAttachments();
}
function clearAiChatPendingFiles() {
aiChatPendingFiles = [];
syncAiChatFileInput();
renderAiChatPendingAttachments();
}
function handleAiChatPaste(ev) {
if (aiChatLoading) return;
const clipboard = ev.clipboardData;
if (!clipboard || !clipboard.items) return;
const imageFiles = [];
for (const item of clipboard.items) {
if (!item.type || !item.type.startsWith("image/")) continue;
const blob = item.getAsFile();
if (!blob) continue;
const sub = (item.type.split("/")[1] || "png").toLowerCase();
const ext = sub === "jpeg" ? "jpg" : sub;
const name = `screenshot-${Date.now()}.${ext}`;
imageFiles.push(new File([blob], name, { type: item.type }));
}
if (!imageFiles.length) return;
ev.preventDefault();
addAiChatPendingFiles(imageFiles);
}
function renderHubMarkdown(text, cacheKey) {
const raw = String(text || "");
if (cacheKey && aiChatMdCache.has(cacheKey)) {
return aiChatMdCache.get(cacheKey);
}
let html;
if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) {
html = window.AiReviewRender.renderMarkdown(raw);
} else {
html = esc(raw)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\n/g, "<br>");
}
if (cacheKey) {
if (aiChatMdCache.size >= AI_CHAT_MD_CACHE_MAX) {
const firstKey = aiChatMdCache.keys().next().value;
if (firstKey != null) aiChatMdCache.delete(firstKey);
}
aiChatMdCache.set(cacheKey, html);
}
return html;
}
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"
? "随便聊点什么,不绑交易数据…可直接 Ctrl+V 粘贴截图"
: "聊聊行情、心态、纪律、执行…;可直接 Ctrl+V 粘贴截图";
}
}
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 mdKey =
!isUser && !isThinking && opts.cacheKey ? String(opts.cacheKey) : "";
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "", mdKey);
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;
}
const sessionId = session && session.id ? String(session.id) : "local";
let html = msgs
.map((m, idx) =>
renderAiChatRow(
m.role === "user" ? "user" : "assistant",
m.content || "",
null,
m.attachments,
{ botMode, msgIdx: idx, cacheKey: sessionId + ":" + 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;
document.querySelectorAll(".ai-chat-pending-del").forEach((el) => {
el.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 text = (input && input.value || "").trim();
const files = aiChatPendingFiles.slice();
if (!text && !files.length) return;
const pendingAttachments = files.map((f) => ({
name: f.name,
kind: aiChatFileKind(f),
}));
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);
clearAiChatPendingFiles();
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");
if (aiChatFiles) {
aiChatFiles.addEventListener("change", () => {
const picked = aiChatFiles.files ? Array.from(aiChatFiles.files) : [];
addAiChatPendingFiles(picked);
aiChatFiles.value = "";
});
}
const aiChatInput = document.getElementById("ai-chat-input");
if (aiChatInput) {
aiChatInput.addEventListener("paste", handleAiChatPaste);
}
const aiChatPending = document.getElementById("ai-chat-pending");
if (aiChatPending) {
aiChatPending.addEventListener("click", (ev) => {
const btn = ev.target.closest("[data-pending-del]");
if (!btn || aiChatLoading) return;
ev.preventDefault();
const idx = Number(btn.getAttribute("data-pending-del"));
if (!Number.isNaN(idx)) removeAiChatPendingFile(idx);
});
}
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();
});
});
})();