Files
dekun 08ae171e48 feat(hub): mobile AI one-screen and dashboard monitor counts
Fix mobile AI to scroll only in the chat area. Dashboard cards show monitor item counts with expand-to-fullscreen and color-coded position floating P&L.
2026-06-11 11:27:28 +08:00

364 lines
13 KiB
JavaScript
Raw Permalink 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.
/**
* 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁)。
*/
(function () {
const page = document.getElementById("page-dashboard");
if (!page) return;
let dashEventSource = null;
let dashReconnectTimer = null;
let localDashVersion = 0;
let inited = false;
let loading = false;
const elStatus = document.getElementById("dash-status");
const elBanner = document.getElementById("dash-alert-banner");
const elBannerText = document.getElementById("dash-alert-banner-text");
const elKpi = document.getElementById("dash-kpi-row");
const elAccounts = document.getElementById("dash-accounts");
const elTrades = document.getElementById("dash-trades-body");
const elUpdated = document.getElementById("dash-updated-at");
const elDay = document.getElementById("dash-trading-day");
const btnRefresh = document.getElementById("dash-btn-refresh");
function fmt(n, d) {
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
return Number(n).toFixed(d == null ? 2 : d);
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
return n > 0 ? "pos" : "neg";
}
function pnlSigned(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 esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function setStatus(msg, isErr) {
if (!elStatus) return;
elStatus.textContent = msg || "";
elStatus.className = "dash-status" + (isErr ? " err" : "");
}
function renderKpi(totals) {
if (!elKpi || !totals) return;
const closed = Number(totals.total_pnl_u);
const floating = Number(totals.float_pnl_u);
const funding = totals.total_funding_usdt;
const trading = totals.total_trading_usdt;
elKpi.innerHTML = [
kpiCard("交易日", esc(totals.trading_day || "—"), ""),
kpiCard("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)),
kpiCard(
"平仓笔数",
`${totals.closed_count || 0}`,
"",
`${totals.win_count || 0} / 负 ${totals.loss_count || 0}`
),
kpiCard("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)),
kpiCard(
"资金合计",
funding != null && trading != null
? `${fmt(Number(funding) + Number(trading), 2)}U`
: "—",
"",
`资金 ${fmt(funding, 2)} + 交易 ${fmt(trading, 2)}`
),
kpiCard("实盘持仓", `${totals.open_position_count || 0}`, ""),
].join("");
}
function kpiCard(label, value, valCls, sub) {
return `<div class="dash-kpi">
<div class="dash-kpi-label">${esc(label)}</div>
<div class="dash-kpi-value ${valCls || ""}">${value}</div>
${sub ? `<div class="dash-kpi-sub">${esc(sub)}</div>` : ""}
</div>`;
}
function renderMonitorCountChips(counts) {
const mc = counts || {};
const chips = [];
const keys = Number(mc.keys) || 0;
const orders = Number(mc.orders) || 0;
const trends = Number(mc.trends) || 0;
const rolls = Number(mc.rolls) || 0;
if (keys > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-key">关键位 ${keys}</span>`);
if (orders > 0) {
chips.push(`<span class="dash-monitor-chip dash-monitor-order">下单监控 ${orders}</span>`);
}
if (trends > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-trend">趋势回调 ${trends}</span>`);
if (rolls > 0) chips.push(`<span class="dash-monitor-chip dash-monitor-roll">顺势加仓 ${rolls}</span>`);
return chips;
}
function renderAccountDetail(ac) {
const counts = (ac && ac.monitor_counts) || {};
const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : [];
const issues = Array.isArray(ac && ac.issues) ? ac.issues : [];
const exId = ac && ac.id != null ? String(ac.id) : "";
const chips = renderMonitorCountChips(counts);
const expandBtn = exId
? `<button type="button" class="dash-ac-expand-btn" data-dash-ex-id="${esc(exId)}" title="放大查看监控详情" aria-label="放大查看监控详情">` +
`<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M15 3h6v6h-2V6.41l-7.29 7.3-1.42-1.42 7.3-7.29H15V3zM3 9h2v10h10v2H3V9z"/></svg>` +
`</button>`
: "";
const monitorRow =
chips.length || expandBtn
? `<div class="dash-ac-monitor-row">${chips.join("")}${expandBtn}</div>`
: "";
let posHtml = "";
if (positions.length) {
posHtml = positions
.map((ln) => {
const text = esc((ln && ln.text) || "");
if (ln.pnl != null && Number.isFinite(Number(ln.pnl))) {
const pnl = Number(ln.pnl);
return (
`<div class="dash-ac-remark-line dash-ac-remark-pos">` +
`${text} 浮<span class="${pnlClass(pnl)}">${pnlSigned(pnl, 2)}</span>` +
`</div>`
);
}
return `<div class="dash-ac-remark-line dash-ac-remark-pos">${text}</div>`;
})
.join("");
} else if (!chips.length && !issues.length) {
posHtml = `<div class="dash-ac-remark-line dash-ac-remark-empty">无持仓</div>`;
}
const issueHtml = issues
.map((text) => `<div class="dash-ac-remark-line dash-ac-remark-issue">${esc(text)}</div>`)
.join("");
return `<div class="dash-ac-remark">${monitorRow}<div class="dash-ac-positions">${posHtml}</div>${issueHtml}</div>`;
}
function bindDashboardExpand() {
if (!elAccounts) return;
elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const id = btn.getAttribute("data-dash-ex-id");
if (id && window.hubOpenMonitorExpand) window.hubOpenMonitorExpand(id);
});
});
}
function renderAccounts(accounts, threshold) {
if (!elAccounts) return;
const rows = Array.isArray(accounts) ? accounts : [];
if (!rows.length) {
elAccounts.innerHTML = '<div class="dash-empty">暂无账户数据</div>';
return;
}
elAccounts.innerHTML = rows
.map((ac) => {
const alert = !!ac.loss_alert;
const unmon = !ac.monitored;
const pnl = Number(ac.pnl_u);
const floatPnl = Number(ac.float_pnl_u);
const lossPct = Number(ac.daily_loss_pct);
const barW =
alert && Number.isFinite(lossPct)
? Math.min(100, (lossPct / Math.max(threshold, 1)) * 100)
: 0;
const badge = alert
? `<span class="dash-ac-badge alert">单日亏损 ≥${threshold}%</span>`
: `<span class="dash-ac-badge ok">${esc(ac.status || "—")}</span>`;
const lossBar =
alert && barW > 0
? `<div class="dash-loss-bar" title="占资金合计 ${fmt(lossPct, 2)}%"><i style="width:${barW}%"></i></div>`
: "";
return `<article class="dash-ac-card${alert ? " is-alert" : ""}${unmon ? " is-unmon" : ""}">
<div class="dash-ac-top">
<div class="dash-ac-name">${esc(ac.name || "—")}</div>
${badge}
</div>
<div class="dash-ac-metrics">
<div class="dash-ac-metric"><span>资金账户</span><strong>${fmt(ac.funding_usdt, 2)}U</strong></div>
<div class="dash-ac-metric"><span>交易账户</span><strong>${fmt(ac.trading_usdt, 2)}U</strong></div>
<div class="dash-ac-metric"><span>资金合计</span><strong>${fmt(ac.capital_total_usdt, 2)}U</strong></div>
<div class="dash-ac-metric"><span>今日盈亏</span><strong class="${pnlClass(pnl)}">${pnlSigned(pnl, 2)}</strong></div>
<div class="dash-ac-metric"><span>平仓笔数</span><strong>${Number(ac.closed_count) || 0}</strong></div>
<div class="dash-ac-metric"><span>浮盈亏</span><strong class="${pnlClass(floatPnl)}">${pnlSigned(floatPnl, 2)}</strong></div>
</div>
${lossBar}
${renderAccountDetail(ac)}
</article>`;
})
.join("");
bindDashboardExpand();
}
function renderTrades(trades, accounts) {
if (!elTrades) return;
const rows = Array.isArray(trades) ? trades : [];
if (!rows.length) {
elTrades.innerHTML = '<div class="dash-empty">今日暂无平仓</div>';
return;
}
const alertNames = new Set(
(accounts || []).filter((a) => a.loss_alert).map((a) => String(a.name || ""))
);
const body = rows
.map((t) => {
const pnl = Number(t.pnl_amount);
const rowAlert = alertNames.has(String(t.account_name || ""));
return `<tr class="${rowAlert ? "is-alert-row" : ""}">
<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 class="${pnlClass(pnl)}">${pnlSigned(pnl, 2)}</td>
<td>${esc(t.closed_at || "—")}</td>
</tr>`;
})
.join("");
elTrades.innerHTML = `<div class="dash-table-wrap"><table class="dash-table">
<thead><tr>
<th>交易日</th><th>账户</th><th>合约</th><th>方向</th><th>结果</th><th>盈亏</th><th>时间</th>
</tr></thead>
<tbody>${body}</tbody>
</table></div>`;
}
function renderPayload(data) {
const totals = data.totals || {};
const threshold = Number(data.loss_alert_pct_threshold) || 5;
const alertCount = Number(data.loss_alert_count) || 0;
if (elDay) elDay.textContent = totals.trading_day || data.trading_day || "—";
if (elUpdated) elUpdated.textContent = data.updated_at || "—";
renderKpi(totals);
renderAccounts(data.accounts, threshold);
renderTrades(data.closed_trades, data.accounts);
if (elBanner && elBannerText) {
if (alertCount > 0) {
const names = (data.accounts || [])
.filter((a) => a.loss_alert)
.map((a) => a.name)
.join("、");
elBanner.classList.add("is-on");
elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%${names}`;
} else {
elBanner.classList.remove("is-on");
elBannerText.textContent = "";
}
}
}
async function fetchDashboardSnapshot(opts) {
const options = opts || {};
if (loading && !options.force) return;
loading = true;
if (!options.silent) setStatus("同步中…");
try {
const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" });
if (r.status === 401) {
location.href = "/login?next=" + encodeURIComponent(location.pathname);
return;
}
const data = await r.json();
if (!data.ok) throw new Error(data.detail || data.msg || data.error || "加载失败");
const ver = Number(data.dashboard_version) || 0;
if (ver) localDashVersion = ver;
renderPayload(data);
const sec = Number(data.poll_interval_sec) || 60;
setStatus(options.silent ? `SSE 已连接 · 后台每 ${sec}s 聚合` : `已更新 · 后台每 ${sec}s 聚合`);
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
loading = false;
}
}
function closeDashboardStream() {
if (dashEventSource) {
dashEventSource.close();
dashEventSource = null;
}
if (dashReconnectTimer) {
clearTimeout(dashReconnectTimer);
dashReconnectTimer = null;
}
}
function connectDashboardStream() {
closeDashboardStream();
dashEventSource = new EventSource("/api/dashboard/stream");
dashEventSource.addEventListener("dashboard", (ev) => {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.dashboard_version) || 0;
if (ver && ver !== localDashVersion) {
void fetchDashboardSnapshot({ silent: true });
} else if (st.aggregating) {
setStatus("后台聚合中…");
}
} catch (_) {
/* ignore */
}
});
dashEventSource.onerror = () => {
closeDashboardStream();
setStatus("SSE 断开,8s 后重连…", true);
dashReconnectTimer = setTimeout(() => {
if (inited) {
connectDashboardStream();
void fetchDashboardSnapshot({ silent: true });
}
}, 8000);
};
}
async function requestDashboardRefresh() {
try {
await fetch("/api/dashboard/refresh", { method: "POST", credentials: "same-origin" });
} catch (_) {
/* ignore */
}
}
function startLive() {
void fetchDashboardSnapshot();
connectDashboardStream();
}
function stopLive() {
closeDashboardStream();
setStatus("");
}
if (btnRefresh) {
btnRefresh.addEventListener("click", () => {
void requestDashboardRefresh();
void fetchDashboardSnapshot({ force: true });
});
}
window.hubDashboardPage = {
init() {
inited = true;
startLive();
},
destroy() {
inited = false;
stopLive();
},
};
})();