feat(hub): add data dashboard and AI chat with session history
Add /dashboard with daily PnL overview and loss alerts. Extend AI coach chat with history sidebar, delete/switch sessions, message copy, and trading vs general bot modes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 中控数据看板:总览 / 分户 / 平仓明细,60s 自动刷新。
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-dashboard");
|
||||
if (!page) return;
|
||||
|
||||
const POLL_MS = 60 * 1000;
|
||||
let timer = null;
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 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}
|
||||
<div class="dash-ac-remark">${esc(ac.remark || "—")}</div>
|
||||
</article>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
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 fetchDashboard() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
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 || "加载失败");
|
||||
renderPayload(data);
|
||||
setStatus(`每 ${(data.poll_interval_sec || 60)}s 自动刷新`);
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
void fetchDashboard();
|
||||
timer = setInterval(fetchDashboard, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (btnRefresh) {
|
||||
btnRefresh.addEventListener("click", () => void fetchDashboard());
|
||||
}
|
||||
|
||||
window.hubDashboardPage = {
|
||||
init() {
|
||||
if (!inited) inited = true;
|
||||
startPoll();
|
||||
},
|
||||
destroy() {
|
||||
stopPoll();
|
||||
setStatus("");
|
||||
},
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user