Files
crypto_monitor/manual_trading_hub/static/app.js
T
dekun 62e48dab92 feat(hub): enrich AI coach with fund history, closed trades, and chat uploads
- Add 15-day fund snapshot store and /api/hub/account on all instances

- Summary includes yesterday/today trades, fund columns, and section 5 操作建议

- Chat context distinguishes empty positions from local monitors

- Support image/document attachments in AI chat

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 08:54:20 +08:00

3349 lines
123 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 };
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;
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 fmt(n, d) {
if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d });
}
/** 交易所持仓开仓价(四所子代理 entry_price */
function positionEntryPrice(pos) {
if (!pos) return null;
const n = Number(pos.entry_price);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function symbolPriceKey(sym) {
return (sym || "").trim().toUpperCase();
}
function buildPriceTickMap(row) {
const map = Object.create(null);
const put = (sym, tick) => {
const k = symbolPriceKey(sym);
if (!k || tick == null || !Number.isFinite(Number(tick))) return;
if (map[k] == null) map[k] = Number(tick);
};
((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick));
const hm = (row && row.hub_monitor) || {};
(hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick));
(hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick));
return map;
}
function lookupPriceTick(symbol, tickMap) {
if (!tickMap || !symbol) return null;
const k = symbolPriceKey(symbol);
if (tickMap[k] != null) return tickMap[k];
const base = normSym(symbol);
if (base && tickMap[base] != null) return tickMap[base];
return null;
}
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const t = Number(tick);
if (t >= 1) return 0;
const s = t.toFixed(12).replace(/0+$/, "");
const frac = s.split(".")[1];
return frac ? Math.min(12, frac.length) : 0;
}
function defaultPriceDecimals(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 4;
const av = Math.abs(n);
if (av >= 10000) return 2;
if (av >= 100) return 3;
if (av >= 1) return 4;
if (av >= 0.01) return 6;
return 8;
}
/** 按交易所 tick(子代理/Flask 下发)格式化价格 */
function fmtSymbolPrice(value, symbol, tickMap, displayFallback) {
if (displayFallback != null && displayFallback !== "") return String(displayFallback);
if (value == null || value === "") return "—";
const n = Number(value);
if (!Number.isFinite(n)) return "—";
const tick = lookupPriceTick(symbol, tickMap);
const d = decimalsFromTick(tick);
return fmt(n, d != null ? d : defaultPriceDecimals(n));
}
function fmtEntryPrice(pos, tickMap) {
if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt);
return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap);
}
function positionMarkPrice(pos) {
if (!pos) return null;
const n = Number(pos.mark_price);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function fmtMarkPrice(pos, tickMap) {
if (pos && pos.mark_price_fmt) return String(pos.mark_price_fmt);
return fmtSymbolPrice(positionMarkPrice(pos), pos && pos.symbol, tickMap);
}
function resolveTrendPositionRatioPct(trendPlan) {
const t = trendPlan || {};
if (t.position_ratio_pct != null && t.position_ratio_pct !== "") {
const n = Number(t.position_ratio_pct);
if (Number.isFinite(n)) return n;
}
const snap = Number(t.snapshot_available_usdt);
const margin = Number(t.plan_margin_capital);
if (Number.isFinite(snap) && snap > 0 && Number.isFinite(margin) && margin > 0) {
return Math.round((margin / snap) * 10000) / 100;
}
return null;
}
function resolveTrendSizingFooter(mo, trendPlan, isTrend) {
if (!isTrend || !trendPlan || !trendPlan.id) {
return {
leverage: mo.leverage,
planBase: mo.margin_capital,
positionRatio: mo.position_ratio,
};
}
const base =
trendPlan.snapshot_available_usdt != null && trendPlan.snapshot_available_usdt !== ""
? trendPlan.snapshot_available_usdt
: trendPlan.plan_margin_capital;
return {
leverage: trendPlan.leverage,
planBase: base,
positionRatio: resolveTrendPositionRatioPct(trendPlan),
};
}
function formatMonitorRiskMeta(mo, trendPlan) {
const m = mo || {};
const t = trendPlan || {};
const amt =
m.risk_amount != null && m.risk_amount !== ""
? Number(m.risk_amount)
: t.risk_amount != null && t.risk_amount !== ""
? Number(t.risk_amount)
: null;
const pctRaw =
m.risk_percent != null && m.risk_percent !== ""
? m.risk_percent
: t.risk_percent != null && t.risk_percent !== ""
? t.risk_percent
: null;
if (pctRaw == null || pctRaw === "") {
if (amt != null && Number.isFinite(amt)) {
return `风险: ${fmt(amt, 2)}U`;
}
return null;
}
const pct = esc(pctRaw);
if (amt != null && Number.isFinite(amt)) {
return `风险: ${pct}%≈${fmt(amt, 2)}U`;
}
return `风险: ${pct}%`;
}
function resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) {
const fromPos = fmtMarkPrice(pos, tickMap);
if (fromPos && fromPos !== "—") return fromPos;
const t = trendPlan || {};
const sym = symbol || (pos && pos.symbol) || t.exchange_symbol || t.symbol || "";
if (t.floating_mark != null && t.floating_mark !== "") {
return fmtSymbolPrice(t.floating_mark, sym, tickMap);
}
if (t.last_mark_price != null && t.last_mark_price !== "") {
return fmtSymbolPrice(t.last_mark_price, sym, tickMap);
}
return "—";
}
function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) {
const e = Number(entry);
const m = Number(mark);
const c = Math.abs(Number(contracts));
let mult = Number(contractSize);
if (!Number.isFinite(mult) || mult <= 0) mult = 1;
if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) {
return null;
}
const diff =
(side || "long").toLowerCase() === "long" ? m - e : e - m;
return Math.round(diff * c * mult * 100) / 100;
}
/** 展示浮盈:子代理 unrealized_pnl;与 entry/mark/张数 推算偏差 >20% 时用推算值 */
function resolvePositionUpnlUsdt(pos, trendPlan, markOverride) {
const p = pos || {};
const t = trendPlan || {};
let exchange =
p.unrealized_pnl != null && p.unrealized_pnl !== ""
? Number(p.unrealized_pnl)
: null;
if (exchange != null && !Number.isFinite(exchange)) exchange = null;
const entry =
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("market")) return "market";
if (p.includes("/ai")) return "ai";
return "monitor";
}
function pageElementId(page) {
if (page === "settings") return "page-settings";
if (page === "market") return "page-market";
if (page === "ai") return "page-ai";
return "page-monitor";
}
function setActiveNav() {
const page = currentPage();
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");
if (page === "monitor") startMonitorPoll();
else stopMonitorPoll();
if (page === "settings") loadSettingsUI();
if (page === "ai") loadAiPage();
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();
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();
}
async function loadSettings() {
const r = await apiFetch("/api/settings");
settingsCache = await r.json();
return settingsCache;
}
function enabledAccounts() {
return (settingsCache?.exchanges || []).filter((x) => x.enabled);
}
function isMobileLayout() {
return window.matchMedia("(max-width: 720px)").matches;
}
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 balance = 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))`;
}
function initMobileLayout() {
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 condOrdersFromPosition(pos) {
const cond = dedupeCondOrdersByTrigger(
Array.isArray(pos.conditional_orders) ? pos.conditional_orders : []
);
if (cond.length) return cond;
const et = pos.exchange_tpsl;
if (!et) return [];
const out = [];
if (et.sl && et.sl.trigger_price != null) {
out.push({
label: "止损",
trigger_price: Number(et.sl.trigger_price),
amount: null,
id: et.sl.order_id,
channel: "algo",
});
}
if (et.tp && et.tp.trigger_price != null) {
out.push({
label: "止盈",
trigger_price: Number(et.tp.trigger_price),
amount: null,
id: et.tp.order_id,
channel: "algo",
});
}
return out;
}
function findMonitorOrder(orders, symbol, side) {
const want = (side || "").toLowerCase();
for (const o of orders || []) {
const sym = o.exchange_symbol || o.symbol || "";
if (!symbolsMatchHub(sym, symbol)) continue;
const d = (o.direction || "").toLowerCase();
if (!d || d === want) return o;
}
return null;
}
function calcRrRatio(side, entry, sl, tp) {
const e = Number(entry);
const s = Number(sl);
const t = Number(tp);
if (![e, s, t].every((n) => Number.isFinite(n) && n > 0)) return null;
if ((side || "long").toLowerCase() === "short") {
const risk = s - e;
const reward = e - t;
if (risk <= 0 || reward <= 0) return null;
return reward / risk;
}
const risk = e - s;
const reward = t - e;
if (risk <= 0 || reward <= 0) return null;
return reward / risk;
}
function resolveTrendPlanRr(trendPlan, side, entry, sl, tp) {
const t = trendPlan || {};
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 (expandedExchangeId && fs && fsInner) {
const row = rows.find((r) => String(r.id) === String(expandedExchangeId));
if (row) {
try {
fsInner.innerHTML = renderFullscreenExchange(row);
fs.classList.remove("hidden");
fs.setAttribute("aria-hidden", "false");
document.body.classList.add("hub-fullscreen-open");
bindMonitorInteractions(fsInner);
fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
};
});
} catch (err) {
console.error("renderFullscreenExchange", err);
closeExchangeFullscreen();
showToast("全屏渲染失败: " + err, true);
}
} else {
closeExchangeFullscreen();
}
} else {
closeExchangeFullscreen();
}
}
function normalizeMarketSymbol(raw) {
let s = (raw || "").trim().toUpperCase();
if (!s) return "";
if (s.includes(":")) {
const base = s.split(":")[0];
if (base.includes("/")) return base;
}
return s;
}
function resolveExchangeKey(exchangeId) {
const row = (lastMonitorRows || []).find((r) => String(r.id) === String(exchangeId));
return (row && (row.key || row.id)) || exchangeId;
}
function findTrendPlan(trends, symbol, side) {
const want = (side || "").toLowerCase();
for (const t of trends || []) {
const sym = t.symbol || t.exchange_symbol || "";
if (!symbolsMatchHub(sym, symbol)) continue;
const d = (t.direction || "").toLowerCase();
if (!d || d === want) return t;
}
return null;
}
function 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 (sl === "" || sl == null) sl = inferred.sl;
if (!tpMonitored && (takeProfit === "" || takeProfit == null)) takeProfit = inferred.tp;
if (sl !== "" && takeProfit !== "" && Number(sl) === Number(takeProfit)) {
takeProfit = "";
}
return {
entry: entryRaw,
sl,
tp: takeProfit,
tp_monitored: tpMonitored,
is_trend: isTrend,
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 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 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 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></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 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 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>${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>
<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ${pnlFmt.cls}">${pnlText}</span></div>
</div>
<div class="pos-footer">
<span>杠杆: ${sizingFoot.leverage != null && sizingFoot.leverage !== "" ? esc(sizingFoot.leverage) + "x" : "—"}</span>
<span>计划基数: ${sizingFoot.planBase != null && sizingFoot.planBase !== "" ? fmt(sizingFoot.planBase, 2) + "U" : "—"}</span>
<span>仓位占比: ${sizingFoot.positionRatio != null && sizingFoot.positionRatio !== "" ? fmt(sizingFoot.positionRatio, 2) + "%" : "—"}</span>
</div>
<div class="pos-ex-orders">
<div class="pos-ex-orders-title">交易所止盈止损</div>
${renderExTpslRows(exchangeId, symbol, cond, tickMap, tpsl, pos.contracts)}
</div>
${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
</div>`;
}
function renderHubSectionCard(title, bodyHtml, emptyHint) {
const inner = bodyHtml || `<div class="pos-empty">${esc(emptyHint || "暂无")}</div>`;
return `<div class="hub-section-card">
<div class="hub-section-head">${esc(title)}</div>
<div class="hub-section-body">${inner}</div>
</div>`;
}
function renderKeySection(keys, kmap) {
if (!keys.length) return "";
const cards = keys
.map((k) => {
const kp = kmap[k.id] || kmap[String(k.id)] || {};
const mt = k.monitor_type || k.type || "";
const pending = keyHasPendingOrder(k, kp);
const cardCls = pending ? "hub-mini-card hub-key-pending" : "hub-mini-card";
const dir = k.direction ? ` · ${renderDirectionHtml(k.direction)}` : "";
const pendingTag = pending
? `<span class="hub-key-pending-tag">挂单中</span>`
: "";
const amtTxt = fmtKeyOrderAmount(k);
const amtLine = amtTxt
? `<div class="hub-mini-line">挂单数量 ${esc(amtTxt)}</div>`
: "";
return `<div class="${cardCls}">
<div class="hub-mini-title">${esc(k.symbol)} · ${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 || "";
return `<div class="hub-mini-card">
<div class="hub-mini-title">#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${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 compact = !!options.compact;
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 actionCell = compact
? `<button type="button" class="btn-close-pos btn-sm danger" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button>`
: `<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>`;
return `<tr>
<td class="td-symbol"><button type="button" class="btn-open-market sym-link" ${mktAttrs} title="打开行情区(含入场/止盈止损)">${esc(x.symbol)}</button>${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>
<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 2)}</td>
<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">
<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>开仓价</th><th>标记价</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
${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">
<table class="data-table data-table-positions"><thead><tr><th>合约</th><th>方向</th><th>开仓价</th><th>标记价</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
${rows}
</tbody></table>
</div>`;
}
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
const tickMap = buildPriceTickMap(row);
let inner = `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 2)}</div></div>
</div>`;
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 += `<div class="stat-row">
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} U</div></div>
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 2)}</div></div>
</div>`;
const posCount = pos.length;
const posListCls = hubPosListCountClass(posCount);
html += `<div class="section-title">持仓(${posCount} 仓 · 每币种一卡)</div>`;
html += `<div class="hub-pos-list ${posListCls}" data-pos-count="${posCount}">`;
if (posCount) {
pos.forEach((p) => {
html += renderLivePositionCard(
row.id,
row.key || row.id,
p,
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side),
tickMap
);
});
} else {
html += '<div class="pos-empty">暂无持仓</div>';
}
html += "</div>";
if ((row.capabilities || []).includes("key")) {
if (!flaskOk) {
html += renderHubSectionCard("关键位", `<div class="err">${esc(row.flask_error || hm.error || "Flask 未连通")}</div>`, "");
} else {
html += renderHubSectionCard(
`关键位 · ${keys.length}`,
renderKeySection(keys, kmap),
"当前无关键位记录"
);
}
}
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders, tickMap), "暂无运行中的下单监控");
if ((row.capabilities || []).includes("trend")) {
html += renderHubSectionCard(
"趋势回调",
renderTrendSection(trends, tickMap, pos, row),
"暂无运行中的趋势回调计划"
);
}
html += renderHubSectionCard("顺势加仓", renderRollSection(rolls, tickMap), "暂无运行中的顺势加仓组");
return html;
}
function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) {
tpslPending = {
exchangeId,
symbol,
side: (side || "long").toLowerCase(),
contracts: parseFloat(contracts),
};
const modal = document.getElementById("tpsl-modal");
const meta = document.getElementById("tpsl-modal-meta");
const slIn = document.getElementById("tpsl-sl");
const tpIn = document.getElementById("tpsl-tp");
if (!modal || !meta || !slIn || !tpIn) return;
meta.textContent = `${symbol} · ${side} · ${contracts}`;
slIn.value = slHint !== "" && slHint != null ? String(slHint) : "";
tpIn.value = tpHint !== "" && tpHint != null ? String(tpHint) : "";
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
slIn.focus();
}
function closeTpslModal() {
tpslPending = null;
const modal = document.getElementById("tpsl-modal");
if (modal) {
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
}
}
async function submitTpslModal() {
if (!tpslPending) return;
const slIn = document.getElementById("tpsl-sl");
const tpIn = document.getElementById("tpsl-tp");
const sl = parseFloat(slIn && slIn.value);
const tp = parseFloat(tpIn && tpIn.value);
if (!sl || sl <= 0 || !tp || tp <= 0) {
showToast("请填写有效的止损价与止盈价", true);
return;
}
const { exchangeId, symbol, side, contracts } = tpslPending;
if (
!confirm(
`确认 ${symbol} ${side}\n先撤销全部条件单,再挂止损 ${sl}、止盈 ${tp}`
)
) {
return;
}
const btn = document.getElementById("tpsl-submit");
if (btn) btn.disabled = true;
try {
const r = await apiFetch(
"/api/orders/" + encodeURIComponent(exchangeId) + "/place-tpsl",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
symbol,
side,
stop_loss: sl,
take_profit: tp,
contracts: contracts > 0 ? contracts : null,
}),
}
);
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
const n = pl.placed && pl.placed.cancelled_conditional;
showToast(
ok
? `已挂单(已撤 ${n != null ? n : "?"} 笔旧条件单)`
: pl.error || JSON.stringify(j),
!ok
);
if (ok) {
closeTpslModal();
refreshMonitorBoardNow();
}
} catch (e) {
showToast(String(e), true);
} finally {
if (btn) btn.disabled = false;
}
}
function initInstanceFrame() {
const back = document.getElementById("instance-frame-back");
const refresh = document.getElementById("instance-frame-refresh");
const newTab = document.getElementById("instance-frame-newtab");
const frame = document.getElementById("instance-frame");
if (back) back.onclick = () => closeInstanceFrame();
if (refresh) refresh.onclick = () => refreshInstanceFrame();
if (newTab) {
newTab.onclick = () => {
if (instanceFrameCtx) {
openInstance(instanceFrameCtx.exchangeId, instanceFrameCtx.nextPath, {
newTab: true,
});
return;
}
if (instanceFrameUrl) window.open(instanceFrameUrl, "_blank", "noopener");
};
}
}
function initFullscreen() {
const backdrop = document.getElementById("exchange-fullscreen-backdrop");
if (backdrop) {
backdrop.onclick = () => {
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
};
}
const fs = document.getElementById("exchange-fullscreen");
if (fs && !expandedExchangeId) {
fs.classList.add("hidden");
fs.setAttribute("aria-hidden", "true");
}
}
function initTpslModal() {
const backdrop = document.getElementById("tpsl-modal-backdrop");
const cancel = document.getElementById("tpsl-cancel");
const submit = document.getElementById("tpsl-submit");
if (backdrop) backdrop.onclick = closeTpslModal;
if (cancel) cancel.onclick = closeTpslModal;
if (submit) submit.onclick = () => submitTpslModal();
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") {
closeTpslModal();
const shell = document.getElementById("instance-frame-shell");
if (shell && !shell.classList.contains("hidden")) {
closeInstanceFrame();
return;
}
if (expandedExchangeId) {
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
}
}
});
}
async function cancelOneOrder(exchangeId, symbol, orderId, channel) {
if (!confirm(`撤销委托 ${symbol} #${orderId}`)) return;
try {
const r = await apiFetch("/api/orders/" + encodeURIComponent(exchangeId) + "/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symbol, order_id: orderId, channel: channel || "regular" }),
});
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
showToast(ok ? "已撤单" : pl.error || JSON.stringify(j), !ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function cancelSymbolOrders(exchangeId, symbol, scope) {
const label = scope === "conditional" ? "全部条件单" : "全部委托";
if (!confirm(`确认撤销 ${symbol}${label}`)) return;
try {
const r = await apiFetch(
"/api/orders/" + encodeURIComponent(exchangeId) + "/cancel-symbol",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symbol, scope }),
}
);
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
const n = pl.cancelled_count != null ? pl.cancelled_count : "?";
showToast(ok ? `已撤销 ${n}` : pl.error || JSON.stringify(j), !ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
function renderMonitorTile(row) {
const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const alert = analyzeExchangeAlert(row);
const upnl = ag.total_unrealized_pnl;
const openCount = pos.filter(positionHasContracts).length;
const dotCls =
alert.level === "error" ? "bad" : alert.level === "warn" ? "warn" : "ok";
const tileCls =
alert.level === "error"
? "hub-tile-error"
: alert.level === "warn"
? "hub-tile-warn"
: "hub-tile-ok";
const ts = (lastMonitorBoardUpdatedAt || "").replace("T", " ");
const tsShort = ts ? ts.slice(-8) : "—";
const posLine =
openCount > 0 ? `${openCount}仓 · ${alert.summary}` : alert.summary;
const hm = row.hub_monitor || {};
const flaskOk = row.flask_ok !== false && hm.ok !== false;
const strategyStats = renderCardStrategyStats(row, hm, flaskOk);
return `<div class="card hub-tile ${tileCls}" data-ex-id="${esc(row.id)}">
<div class="hub-tile-body card-expand-zone" title="点击进入全屏详情">
<div class="hub-tile-top">
<span class="status-dot ${dotCls}" aria-hidden="true"></span>
<span class="hub-tile-name">${esc(row.name)}</span>
</div>
<div class="hub-tile-pnl ${pnlCls(upnl)}">${fmt(upnl, 2)} <small>U</small></div>
<div class="hub-tile-meta">${esc(posLine)}</div>
${strategyStats}
<div class="hub-tile-foot">UPD ${esc(tsShort)}</div>
</div>
</div>`;
}
function renderMonitorCard(row) {
const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const hm = row.hub_monitor || {};
const flaskOk = row.flask_ok !== false && hm.ok !== false;
const keys = flaskOk ? hm.keys || [] : [];
const orders = flaskOk ? hm.orders || [] : [];
const trends = flaskOk ? hm.trends || [] : [];
const rolls = flaskOk ? hm.rolls || [] : [];
const kmap = {};
(row.key_prices || []).forEach((k) => {
kmap[k.id] = k;
});
let inner = "";
const agOk = ag.ok !== false;
const agErr = ag.error || row.error || "";
if (!row.http_ok) {
inner = `<div class="err">${esc(row.error || "子代理不可用")}</div>`;
} else if (!agOk) {
inner = `<div class="err">${esc(agErr || "子代理返回失败")}</div>`;
inner += `<div class="empty-hint">请检查 PM2 子代理与 <code>${esc(row.agent_url || "")}/status</code></div>`;
} else {
inner = renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap);
}
const online = row.http_ok && agOk;
const cardCls = online ? "card-online" : "card-offline";
const dotCls = online ? "ok" : "bad";
const flaskOpen = row.flask_url_browser || row.flask_url;
const openFlask = flaskOpen
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/">实例</a>`
: "";
const openReview = flaskOpen
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>`
: "";
return `<div class="card ${cardCls}" data-ex-id="${esc(row.id)}">
<div class="card-head card-expand-zone" title="点击放大全屏">
<div>
<div class="card-title-row">
<span class="status-dot ${dotCls}" title="${online ? "在线" : "离线"}"></span>
<div class="card-title">${esc(row.name)}</div>
</div>
<div class="card-sub">${esc(flaskOpen || "")}</div>
</div>
<div class="card-actions">
${openFlask}
${openReview}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div>
</div>
<div class="card-body">${inner}</div>
</div>`;
}
async function hubTrendPlanStop(exchangeId, planId) {
if (!exchangeId || !planId) {
showToast("缺少交易所或计划 ID", true);
return;
}
if (!confirm("结束计划:市价平仓并撤掉该合约全部挂单,确定?")) return;
try {
const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan_id: Number(planId) }),
});
const j = await r.json();
showToast(j.message || (j.ok ? "已结束趋势回调计划" : "结束失败"), !j.ok);
if (j.ok) refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function hubTrendPlanBreakeven(exchangeId, planId, inputEl) {
if (!exchangeId || !planId) {
showToast("缺少交易所或计划 ID", true);
return;
}
const raw = inputEl ? String(inputEl.value || "").trim() : "";
let pct = null;
if (raw !== "") {
pct = Number(raw);
if (!Number.isFinite(pct) || pct < 0) {
showToast("保本偏移% 须为非负数", true);
return;
}
}
if (
!confirm(
"确认保本?将结束本趋势计划,持仓移交「下单监控」,并在交易所挂保本止损与计划止盈;后续平仓写入交易记录。"
)
) {
return;
}
try {
const body = { plan_id: Number(planId) };
if (pct != null) body.breakeven_offset_pct = pct;
const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/breakeven", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const j = await r.json();
showToast(j.message || (j.ok ? "保本移交成功" : "保本移交失败"), !j.ok);
if (j.ok) refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function closeOnePosition(exchangeId, symbol, side) {
const label = `${symbol} · ${side}`;
if (!confirm(`确认对该账户市价平仓:${label}`)) return;
try {
const r = await apiFetch(
"/api/close/" + encodeURIComponent(exchangeId) + "/position",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symbol, side }),
}
);
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
const msg =
(ok && pl.closed
? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}`
: pl.error) || JSON.stringify(j, null, 2);
showToast(msg, !ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function closeOne(id) {
if (!confirm("确认对该账户市价全平?")) return;
try {
const r = await apiFetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function closeAll() {
const n = enabledAccounts().length;
if (!confirm(`${n} 个已启用账户执行紧急全平?`)) return;
try {
const r = await apiFetch("/api/close-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exclude_ids: [] }),
});
const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function loadSettingsMetaLine() {
try {
const r = await apiFetch("/api/settings/meta");
const m = await r.json();
const el = document.getElementById("settings-meta-line");
if (!el) return;
const parts = [];
if (m.password_required) parts.push("已启用用户名+密码登录");
else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置 HUB_USERNAME + HUB_PASSWORD");
if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN");
else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)");
if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin);
else parts.push("未设 HUB_PUBLIC_ORIGIN(复盘链接仅本机可开)");
if ((m.env_disabled_ids || []).length) {
parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ") + "(改 .env 后须重启 hub");
} else {
parts.push("HUB_DISABLED_IDS 未强制关闭任何账户");
}
el.textContent = parts.join(" · ");
} catch (_) {}
}
function renderSettingsList(data) {
const list = document.getElementById("settings-list");
if (!list) return;
list.innerHTML = (data.exchanges || [])
.map((ex, idx) => renderSettingsCard(ex, idx))
.join("");
list.querySelectorAll(".btn-del-ex").forEach((btn) => {
btn.onclick = () => {
const i = Number(btn.dataset.idx);
data.exchanges.splice(i, 1);
settingsCache = data;
renderSettingsList(data);
};
});
}
function loadSettingsUI() {
loadSettingsMetaLine();
loadSettings().then((data) => {
renderSettingsList(data);
});
}
function renderSettingsCard(ex, idx) {
const caps = ex.capabilities || [];
const envOff = ex.env_disabled
? '<span class="badge">环境变量强制关</span>'
: "";
return `<div class="settings-card" data-idx="${idx}" data-key="${esc(ex.key || ex.id || "")}">
<div class="settings-card-head">
<label class="chk-label"><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/> 启用</label>
${envOff}
<input class="ex-name" value="${esc(ex.name || "")}" placeholder="显示名称" />
</div>
<div class="settings-grid">
<div class="field"><label>Flask URL</label><input class="ex-flask" value="${esc(ex.flask_url || "")}" /></div>
<div class="field"><label>Agent URL</label><input class="ex-agent" value="${esc(ex.agent_url || "")}" /></div>
<div class="field field-wide"><label>复盘链接(可空)</label><input class="ex-review" value="${esc(ex.review_url || "")}" placeholder="留空则自动生成 /records" /></div>
</div>
<div class="cap-chips">
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
<label><input type="checkbox" class="cap-trend" ${caps.includes("trend") ? "checked" : ""}/> 监控趋势计划</label>
</div>
<div class="settings-card-foot">
<div class="field"><label>id</label><input class="ex-id" value="${esc(ex.id || "")}" /></div>
<button type="button" class="danger btn-del-ex" data-idx="${idx}">删除账户</button>
</div>
</div>`;
}
function collectSettingsFromUI() {
const rows = [...document.querySelectorAll("#settings-list .settings-card")];
return {
version: 1,
exchanges: rows.map((card) => {
const caps = [];
if (card.querySelector(".cap-key").checked) caps.push("key");
if (card.querySelector(".cap-trend").checked) caps.push("trend");
const id = card.querySelector(".ex-id").value.trim();
const stableKey = (card.dataset.key || id).trim();
return {
id: id,
key: stableKey,
name: card.querySelector(".ex-name").value.trim(),
flask_url: card.querySelector(".ex-flask").value.trim(),
agent_url: card.querySelector(".ex-agent").value.trim(),
review_url: card.querySelector(".ex-review").value.trim(),
enabled: card.querySelector(".ex-enabled").checked,
capabilities: caps,
};
}),
};
}
async function saveSettings() {
const body = collectSettingsFromUI();
try {
const r = await apiFetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const j = await r.json();
if (j.ok) {
showToast("设置已保存(已写入 hub_settings.json");
if (j.settings) {
settingsCache = j.settings;
renderSettingsList(j.settings);
loadSettingsMetaLine();
} else {
await loadSettingsUI();
}
} else showToast("保存失败", true);
} catch (e) {
showToast(String(e), true);
}
}
document.getElementById("btn-logout").onclick = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch (_) {}
location.href = "/login";
};
document.getElementById("btn-monitor-refresh").onclick = () => 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 aiSummaryLoading = false;
let aiChatLoading = false;
let aiChatSessionCache = null;
function aiPnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
return n > 0 ? "pos" : "neg";
}
function aiPnlSigned(v, digits) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const abs = fmt(Math.abs(n), digits);
if (Math.abs(n) < 1e-9) return `${abs}U`;
return `${n > 0 ? "+" : "-"}${abs}U`;
}
function renderHubMarkdown(text) {
const raw = String(text || "");
if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) {
return window.AiReviewRender.renderMarkdown(raw);
}
return esc(raw)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\n/g, "<br>");
}
function renderAiMarkdown(text) {
return renderHubMarkdown(text);
}
function enhanceHubSummaryMarkdown(md) {
let out = String(md || "");
out = out.replace(/\*\*今日交易总结(([^]+)\*\*/g, "# 📋 今日交易总结($1");
out = out.replace(/\*\*1\.\s*(?:📊\s*)?总览\*\*/g, "## 1. 📊 总览");
out = out.replace(/\*\*2\.\s*(?:👥\s*)?分户明细\*\*/g, "## 2. 👥 分户明细");
out = out.replace(/\*\*3\.\s*(?:⚠️\s*)?需关注\*\*/g, "## 3. ⚠️ 需关注");
out = out.replace(/\*\*4\.\s*(?:️\s*)?数据说明\*\*/g, "## 4. ️ 数据说明");
out = out.replace(/\*\*5\.\s*(?:💡\s*)?操作建议\*\*/g, "## 5. 💡 操作建议");
return out;
}
function aiFmtFund(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return `${fmt(n, 2)}U`;
}
function aiPnlCellHtml(v, digits) {
const cls = aiPnlClass(v);
const valCls = cls ? ` ai-stat-val ${cls}` : " ai-stat-val";
return `<span class="${valCls.trim()}">${aiPnlSigned(v, digits)}</span>`;
}
function aiAccountStatusClass(status) {
const s = String(status || "");
if (s === "未监控") return "ai-ac-unmon";
if (s.includes("异常")) return "ai-ac-err";
if (s.includes("需关注")) return "ai-ac-warn";
return "";
}
function renderAiAccountTable(snapshot) {
const accounts = snapshot && snapshot.by_account;
if (!accounts || typeof accounts !== "object") return "";
const rows = Object.values(accounts);
if (!rows.length) return "";
const head =
"<thead><tr>" +
"<th>账户</th><th>状态</th><th>资金账户</th><th>交易账户</th><th>今日盈亏</th><th>笔数</th><th>浮盈亏</th><th>备注</th>" +
"</tr></thead>";
const body = rows
.map((ac) => {
const closedPnl = Number(ac.pnl_u);
const floatPnl = Number(ac.float_pnl_u);
const remark =
ac.remark ||
(Array.isArray(ac.issues) && ac.issues.length ? ac.issues.join("") : "无");
const statusCls = aiAccountStatusClass(ac.status);
const countLabel = `${Number(ac.closed_count) || 0}${Number(ac.closed_count_yesterday) ? ` / 昨${Number(ac.closed_count_yesterday)}` : ""}`;
return (
"<tr>" +
`<td class="ai-ac-name">${esc(ac.name || "—")}</td>` +
`<td class="${statusCls}">${esc(ac.status || "—")}</td>` +
`<td>${aiFmtFund(ac.funding_usdt)}</td>` +
`<td>${aiFmtFund(ac.trading_usdt)}</td>` +
`<td>${aiPnlCellHtml(closedPnl, 2)}</td>` +
`<td>${countLabel}</td>` +
`<td>${aiPnlCellHtml(floatPnl, 2)}</td>` +
`<td class="ai-ac-remark">${esc(remark)}</td>` +
"</tr>"
);
})
.join("");
return `<div class="ai-ac-table-wrap"><table class="ai-ac-table">${head}<tbody>${body}</tbody></table></div>`;
}
function renderAiClosedTradesBlock(snapshot) {
const rows = (snapshot && snapshot.closed_trades) || [];
if (!rows.length) return "";
const head =
"<thead><tr><th>交易日</th><th>账户</th><th>合约</th><th>方向</th><th>结果</th><th>盈亏</th><th>时间</th></tr></thead>";
const body = rows
.map((t) => {
const pnl = Number(t.pnl_amount);
return (
"<tr>" +
`<td>${esc(t.trading_day || "—")}</td>` +
`<td>${esc(t.account_name || "—")}</td>` +
`<td>${esc(t.symbol || "—")}</td>` +
`<td>${esc(t.direction || "—")}</td>` +
`<td>${esc(t.result || "—")}</td>` +
`<td>${aiPnlCellHtml(pnl, 2)}</td>` +
`<td class="ai-ac-remark">${esc(t.closed_at || "—")}</td>` +
"</tr>"
);
})
.join("");
return (
`<div class="ai-closed-trades-wrap">` +
`<h4 class="ai-closed-trades-title">平仓明细(昨日 + 今日)</h4>` +
`<div class="ai-ac-table-wrap"><table class="ai-ac-table ai-closed-trades-table">${head}<tbody>${body}</tbody></table></div>` +
`</div>`
);
}
function renderAiSummaryBody(contentMd, snapshot) {
const md = enhanceHubSummaryMarkdown(contentMd);
const sec2 = /##\s*2\.\s*👥\s*分户明细/;
const sec3 = /##\s*3\.\s*⚠️\s*需关注/;
const i2 = md.search(sec2);
const i3 = md.search(sec3);
const tableHtml = renderAiAccountTable(snapshot);
const closedHtml = renderAiClosedTradesBlock(snapshot);
if (i2 >= 0 && i3 > i2 && tableHtml) {
const headEnd = i2 + md.slice(i2).match(sec2)[0].length;
const part1 = md.slice(0, headEnd);
const part2 = md.slice(i3);
return renderHubMarkdown(part1) + tableHtml + closedHtml + renderHubMarkdown(part2);
}
return renderHubMarkdown(md) + (tableHtml ? tableHtml + closedHtml : "");
}
function setAiSummaryMarkdown(body, contentMd, snapshot) {
if (!body) return;
body.classList.add("ai-result-md");
body.innerHTML = renderAiSummaryBody(contentMd, snapshot);
}
function setAiSummaryPlaceholder(body, html) {
if (!body) return;
body.classList.remove("ai-result-md");
body.innerHTML = html;
}
function renderAiSummaryStats(snapshot) {
const el = document.getElementById("ai-summary-stats");
if (!el) return;
if (!snapshot || !snapshot.totals) {
el.innerHTML = "";
return;
}
const t = snapshot.totals;
const closedPnl = Number(t.total_pnl_u);
const floatPnl = Number(t.float_pnl_u);
const closedCls = aiPnlClass(closedPnl);
const floatCls = aiPnlClass(floatPnl);
el.innerHTML = [
`<span class="ai-stat-chip"><strong>交易日</strong>${esc(t.trading_day || "—")}</span>`,
`<span class="ai-stat-chip ${closedCls}"><strong>平仓盈亏</strong><span class="ai-stat-val ${closedCls}">${aiPnlSigned(closedPnl, 2)}</span></span>`,
`<span class="ai-stat-chip"><strong>笔数</strong>${t.closed_count || 0}(胜${t.win_count || 0}/负${t.loss_count || 0}</span>`,
`<span class="ai-stat-chip ${floatCls}"><strong>浮盈亏</strong><span class="ai-stat-val ${floatCls}">${aiPnlSigned(floatPnl, 2)}</span></span>`,
].join("");
}
function renderAiChatRow(role, content, extraClass, attachments) {
const isUser = role === "user";
const label = isUser ? "主人" : "AI教练";
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 bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "");
const mdCls = !isUser && !isThinking ? " ai-result-md" : "";
const attList = Array.isArray(attachments) ? attachments : [];
const attHtml = attList.length
? `<div class="ai-msg-attachments">${attList
.map((a) => `<span class="ai-attach-chip">${esc(a.name || "附件")}</span>`)
.join("")}</div>`
: "";
return (
`<div class="ai-msg-row ${rowCls}">` +
`<span class="ai-msg-role">${label}</span>` +
`${attHtml}` +
`<div class="ai-bubble ${bubbleCls}${mdCls}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
`</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) || [];
if (title) {
title.textContent = session && session.title ? `聊天 · ${session.title}` : "聊天";
}
const showPlaceholder =
!msgs.length && !options.pendingUser && !options.thinking;
if (showPlaceholder) {
box.innerHTML =
'<p class="ai-placeholder">主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。可点「附件」上传图片或文档。</p>';
return;
}
let html = msgs
.map((m) =>
renderAiChatRow(
m.role === "user" ? "user" : "assistant",
m.content || "",
null,
m.attachments
)
)
.join("");
if (options.pendingUser) {
html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments);
}
if (options.thinking) {
html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking");
}
box.innerHTML = html;
box.scrollTop = box.scrollHeight;
}
function setAiChatBusy(busy) {
aiChatLoading = !!busy;
const btn = document.getElementById("btn-ai-chat-send");
const input = document.getElementById("ai-chat-input");
if (btn) btn.disabled = busy;
if (input) input.disabled = busy;
}
async function loadAiSummary() {
const body = document.getElementById("ai-summary-body");
try {
const r = await apiFetch("/api/ai/summary");
const j = await r.json();
const latest = j.latest;
if (latest && latest.content_md) {
if (body) setAiSummaryMarkdown(body, latest.content_md, latest.stats_snapshot);
renderAiSummaryStats(latest.stats_snapshot);
}
} catch (e) {
if (body) setAiSummaryPlaceholder(body, `<p class="ai-placeholder">${esc(String(e))}</p>`);
}
}
async function loadAiChatSession() {
const r = await apiFetch("/api/ai/chat/session");
const j = await r.json();
aiChatSessionCache = j.session || null;
renderAiChatMessages(aiChatSessionCache);
}
async function loadAiPage() {
await Promise.all([loadAiSummary(), loadAiChatSession()]);
}
async function generateAiSummary() {
if (aiSummaryLoading) return;
aiSummaryLoading = true;
const btn = document.getElementById("btn-ai-summary");
const body = document.getElementById("ai-summary-body");
if (btn) btn.disabled = true;
if (body) setAiSummaryPlaceholder(body, '<p class="ai-placeholder">正在聚合四户数据并生成总结…</p>');
try {
const r = await apiFetch("/api/ai/summary/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: true }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "生成失败");
if (!j.ok && j.detail) throw new Error(j.detail);
const sum = j.summary;
if (sum && sum.content_md && body) {
setAiSummaryMarkdown(body, sum.content_md, sum.stats_snapshot);
renderAiSummaryStats(sum.stats_snapshot);
}
showToast(j.cached ? "已是最新上下文,返回缓存总结" : "今日总结已生成");
await loadAiSummary();
} catch (e) {
showToast(String(e), true);
if (body) setAiSummaryPlaceholder(body, `<p class="ai-placeholder">${esc(String(e))}</p>`);
} finally {
aiSummaryLoading = false;
if (btn) btn.disabled = false;
}
}
async function newAiChat() {
try {
const r = await apiFetch("/api/ai/chat/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const j = await r.json();
aiChatSessionCache = j.session || null;
renderAiChatMessages(aiChatSessionCache);
showToast("已开始新对话");
} catch (e) {
showToast(String(e), true);
}
}
async function sendAiChat(ev) {
if (ev) ev.preventDefault();
if (aiChatLoading) return;
const input = document.getElementById("ai-chat-input");
const fileInput = document.getElementById("ai-chat-files");
const fileLabel = document.getElementById("ai-chat-files-label");
const text = (input && input.value || "").trim();
const files = fileInput && fileInput.files ? Array.from(fileInput.files) : [];
if (!text && !files.length) return;
const pendingAttachments = files.map((f) => ({ name: f.name, kind: f.type.startsWith("image/") ? "image" : "text" }));
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;
renderAiChatMessages(aiChatSessionCache);
if (fileInput) fileInput.value = "";
if (fileLabel) fileLabel.textContent = "";
if (j.attachment_warnings && j.attachment_warnings.length) {
showToast(j.attachment_warnings.join(""), true);
}
} catch (e) {
showToast(String(e), true);
renderAiChatMessages(aiChatSessionCache);
} finally {
setAiChatBusy(false);
}
}
const aiChatFiles = document.getElementById("ai-chat-files");
const aiChatFilesLabel = document.getElementById("ai-chat-files-label");
if (aiChatFiles && aiChatFilesLabel) {
aiChatFiles.addEventListener("change", () => {
const names = aiChatFiles.files ? Array.from(aiChatFiles.files).map((f) => f.name) : [];
aiChatFilesLabel.textContent = names.length ? names.join("、") : "";
});
}
const aiSummaryBtn = document.getElementById("btn-ai-summary");
if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary();
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat();
const aiChatForm = document.getElementById("ai-chat-form");
if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat);
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);
}
initAuth().then((ok) => {
if (!ok) return;
initShellNav();
setActiveNav();
if (currentPage() === "settings") {
loadSettings().catch(() => {});
}
});
})();