feat(instance): add exchange account ledger tab with SSE sync
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 账户流水:资金/交易 Tab · 分页 10 · SSE 自动刷新 · 时间窗跟随顶栏预设.
|
||||
*/
|
||||
(function (global) {
|
||||
const PAGE_SIZE = 10;
|
||||
let account = "funding";
|
||||
let page = 1;
|
||||
let pages = 0;
|
||||
let localVersion = 0;
|
||||
let es = null;
|
||||
let reconnectTimer = null;
|
||||
let loading = false;
|
||||
let booted = false;
|
||||
|
||||
function root() {
|
||||
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-account-ledger="1"]');
|
||||
if (active) return active;
|
||||
return document.getElementById("account-ledger-root");
|
||||
}
|
||||
|
||||
function $(id) {
|
||||
const r = root();
|
||||
return (r && r.querySelector("#" + id)) || document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function listWindowQs() {
|
||||
if (typeof global.listWindowQueryString === "function") {
|
||||
const q = global.listWindowQueryString();
|
||||
return q ? (q.charAt(0) === "?" ? q.slice(1) : q) : "";
|
||||
}
|
||||
try {
|
||||
return new URLSearchParams(location.search).toString();
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBj(ms) {
|
||||
const n = Number(ms);
|
||||
if (!Number.isFinite(n) || n <= 0) return "—";
|
||||
try {
|
||||
const d = new Date(n);
|
||||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const get = (t) => (parts.find((p) => p.type === t) || {}).value || "";
|
||||
return (
|
||||
get("year") +
|
||||
"-" +
|
||||
get("month") +
|
||||
"-" +
|
||||
get("day") +
|
||||
" " +
|
||||
get("hour") +
|
||||
":" +
|
||||
get("minute") +
|
||||
":" +
|
||||
get("second")
|
||||
);
|
||||
} catch (_) {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
function fmtAmt(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
const cls = n > 0 ? "account-ledger-amt-pos" : n < 0 ? "account-ledger-amt-neg" : "";
|
||||
const sign = n > 0 ? "+" : "";
|
||||
return '<span class="' + cls + '">' + sign + n.toFixed(6).replace(/\.?0+$/, "") + "</span>";
|
||||
}
|
||||
|
||||
function fmtBal(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return n.toFixed(6).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
const el = $("account-ledger-status");
|
||||
if (!el) return;
|
||||
el.textContent = msg || "";
|
||||
el.style.color = isErr ? "#f07178" : "";
|
||||
}
|
||||
|
||||
function setSyncLabel(data) {
|
||||
const el = $("account-ledger-sync");
|
||||
if (!el) return;
|
||||
const ts = data && data.last_sync_at;
|
||||
if (!ts) {
|
||||
el.textContent = "尚未同步";
|
||||
return;
|
||||
}
|
||||
el.textContent = "同步 " + fmtBj(Number(ts) * 1000);
|
||||
}
|
||||
|
||||
function renderRows(items) {
|
||||
const tbody = $("account-ledger-tbody");
|
||||
if (!tbody) return;
|
||||
if (!items || !items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">当前时间窗暂无流水</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items
|
||||
.map(function (it) {
|
||||
const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · ");
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtBj(it.ts_ms)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.ccy || "") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.kind_label || it.kind || "") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
fmtAmt(it.amount) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtBal(it.balance_after)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(note || "—") +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderPager(data) {
|
||||
pages = Number(data.pages || 0);
|
||||
page = Number(data.page || 1);
|
||||
const info = $("account-ledger-page-info");
|
||||
const prev = $("account-ledger-prev");
|
||||
const next = $("account-ledger-next");
|
||||
if (info) {
|
||||
info.textContent =
|
||||
"第 " + page + " / " + (pages || 1) + " 页 · 共 " + (data.total || 0) + " 条 · 每页 " + PAGE_SIZE;
|
||||
}
|
||||
if (prev) prev.disabled = page <= 1;
|
||||
if (next) next.disabled = !pages || page >= pages;
|
||||
}
|
||||
|
||||
async function loadList(opts) {
|
||||
const r = root();
|
||||
if (!r) return;
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
const force = opts && opts.force;
|
||||
try {
|
||||
if (!force) setStatus("加载中…");
|
||||
const qs = new URLSearchParams(listWindowQs());
|
||||
qs.set("account", account);
|
||||
qs.set("page", String(page));
|
||||
const res = await fetch("/api/account_ledger?" + qs.toString(), {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const data = await res.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.msg || res.statusText || "加载失败");
|
||||
}
|
||||
if (data.ledger_version != null) localVersion = Number(data.ledger_version) || localVersion;
|
||||
renderRows(data.items || []);
|
||||
renderPager(data);
|
||||
setSyncLabel(data);
|
||||
const winLabel = (data.window && data.window.label) || "";
|
||||
const err = data.last_error ? " · 同步提示: " + data.last_error : "";
|
||||
setStatus(
|
||||
(winLabel ? "时间窗 " + winLabel + " · " : "") +
|
||||
(account === "trading" ? "交易账户" : "资金账户") +
|
||||
err,
|
||||
!!data.last_error
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshNow() {
|
||||
setStatus("正在从交易所同步…");
|
||||
try {
|
||||
const res = await fetch("/api/account_ledger/refresh", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const data = await res.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.msg || "同步失败");
|
||||
}
|
||||
await loadList({ force: true });
|
||||
} catch (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
function bindUi() {
|
||||
const r = root();
|
||||
if (!r || r.getAttribute("data-ledger-bound") === "1") return;
|
||||
r.setAttribute("data-ledger-bound", "1");
|
||||
r.querySelectorAll(".account-ledger-tab").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const acc = btn.getAttribute("data-ledger-account") || "funding";
|
||||
if (acc === account) return;
|
||||
account = acc;
|
||||
page = 1;
|
||||
r.querySelectorAll(".account-ledger-tab").forEach(function (b) {
|
||||
const on = b.getAttribute("data-ledger-account") === account;
|
||||
b.classList.toggle("active", on);
|
||||
b.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
loadList();
|
||||
});
|
||||
});
|
||||
const prev = $("account-ledger-prev");
|
||||
const next = $("account-ledger-next");
|
||||
const ref = $("account-ledger-refresh");
|
||||
if (prev)
|
||||
prev.addEventListener("click", function () {
|
||||
if (page > 1) {
|
||||
page -= 1;
|
||||
loadList();
|
||||
}
|
||||
});
|
||||
if (next)
|
||||
next.addEventListener("click", function () {
|
||||
if (!pages || page < pages) {
|
||||
page += 1;
|
||||
loadList();
|
||||
}
|
||||
});
|
||||
if (ref) ref.addEventListener("click", refreshNow);
|
||||
}
|
||||
|
||||
function connectSse() {
|
||||
if (es) {
|
||||
try {
|
||||
es.close();
|
||||
} catch (_) {}
|
||||
es = null;
|
||||
}
|
||||
if (typeof EventSource === "undefined") return;
|
||||
try {
|
||||
es = new EventSource("/api/account_ledger/stream");
|
||||
es.addEventListener("ledger", function (ev) {
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(ev.data || "{}");
|
||||
} catch (_) {}
|
||||
const ver = Number(data.ledger_version || 0);
|
||||
if (ver && ver !== localVersion) {
|
||||
localVersion = ver;
|
||||
loadList({ force: true });
|
||||
}
|
||||
});
|
||||
es.onerror = function () {
|
||||
try {
|
||||
es.close();
|
||||
} catch (_) {}
|
||||
es = null;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connectSse, 5000);
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const r = root();
|
||||
if (!r) return;
|
||||
bindUi();
|
||||
if (!booted) {
|
||||
booted = true;
|
||||
connectSse();
|
||||
}
|
||||
loadList();
|
||||
}
|
||||
|
||||
function onTabActivated(tab) {
|
||||
if (tab !== "account_ledger") return;
|
||||
boot();
|
||||
}
|
||||
|
||||
global.AccountLedgerPage = {
|
||||
boot: boot,
|
||||
onTabActivated: onTabActivated,
|
||||
reload: function () {
|
||||
page = 1;
|
||||
loadList();
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const page =
|
||||
(document.body && document.body.getAttribute("data-page")) ||
|
||||
(document.body && document.body.getAttribute("data-initial-tab")) ||
|
||||
"";
|
||||
if (page === "account_ledger" || root()) {
|
||||
// embed 延后到 tab 激活;独立页直接 boot
|
||||
if (!document.body || document.body.getAttribute("data-embed-shell") !== "1") {
|
||||
boot();
|
||||
} else if (page === "account_ledger") {
|
||||
boot();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("instance-embed-tab-activated", function (ev) {
|
||||
const tab = ev && ev.detail && ev.detail.tab;
|
||||
onTabActivated(tab);
|
||||
});
|
||||
})(window);
|
||||
@@ -5,6 +5,7 @@
|
||||
(function (global) {
|
||||
const TAB_PATH = {
|
||||
dashboard: "/dashboard",
|
||||
account_ledger: "/account_ledger",
|
||||
key_monitor: "/key_monitor",
|
||||
trade: "/trade",
|
||||
strategy: "/strategy",
|
||||
@@ -114,6 +115,9 @@
|
||||
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") {
|
||||
global.InstanceDashboard.init(!!revisit);
|
||||
}
|
||||
if (tab === "account_ledger" && global.AccountLedgerPage && typeof global.AccountLedgerPage.boot === "function") {
|
||||
global.AccountLedgerPage.boot();
|
||||
}
|
||||
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
|
||||
global.initStrategyRollForm();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
}
|
||||
|
||||
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
|
||||
const NAV_DEFAULT_OFF = { show_nav_dashboard: true, show_nav_system_guide: true };
|
||||
const NAV_DEFAULT_OFF = {
|
||||
show_nav_dashboard: true,
|
||||
show_nav_account_ledger: true,
|
||||
show_nav_system_guide: true,
|
||||
};
|
||||
|
||||
function navPrefShow(display, key) {
|
||||
if (!key) return true;
|
||||
@@ -31,6 +35,7 @@
|
||||
function applyDisplayToNav(display) {
|
||||
const map = {
|
||||
dashboard: "show_nav_dashboard",
|
||||
account_ledger: "show_nav_account_ledger",
|
||||
key_monitor: "show_nav_key_monitor",
|
||||
trade: "show_nav_trade",
|
||||
strategy: "show_nav_strategy",
|
||||
@@ -66,6 +71,7 @@
|
||||
const d = DISPLAY();
|
||||
const map = {
|
||||
dashboard: "show_nav_dashboard",
|
||||
account_ledger: "show_nav_account_ledger",
|
||||
key_monitor: "show_nav_key_monitor",
|
||||
trade: "show_nav_trade",
|
||||
strategy: "show_nav_strategy",
|
||||
|
||||
Reference in New Issue
Block a user