1d2bdc3aa1
Only dashboard path changes: background snapshot, SSE refresh, smaller header type, options net PnL. Co-authored-by: Cursor <cursoragent@cursor.com>
509 lines
15 KiB
JavaScript
509 lines
15 KiB
JavaScript
/**
|
|
* 实例数据看板:拉 /api/instance/dashboard 渲染只读表格.
|
|
* 各区块无数据时不展示;有数据按表格展示.
|
|
*/
|
|
(function (global) {
|
|
const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"];
|
|
let loading = false;
|
|
let localDashVersion = 0;
|
|
let dashEventSource = null;
|
|
let dashReconnectTimer = null;
|
|
let booted = false;
|
|
|
|
function root() {
|
|
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]');
|
|
if (active) return active;
|
|
return document.getElementById("instance-dashboard");
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function fmtNum(v) {
|
|
if (v == null || v === "") return "—";
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return escapeHtml(v);
|
|
return String(n);
|
|
}
|
|
|
|
function fmtPnl(v) {
|
|
if (v == null || v === "") return "—";
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return "—";
|
|
const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
|
|
const sign = n > 0 ? "+" : "";
|
|
return '<span class="' + cls + '">' + sign + n.toFixed(2) + "U</span>";
|
|
}
|
|
|
|
function fmtPnlPlain(v) {
|
|
if (v == null || v === "") return "—";
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return "—";
|
|
const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
|
|
return '<span class="' + cls + '">' + n.toFixed(2) + "</span>";
|
|
}
|
|
|
|
function dirCell(it) {
|
|
const d = String(it.direction || "").toLowerCase();
|
|
const label = it.direction_label || (d === "short" ? "做空" : d === "long" ? "做多" : "-");
|
|
const cls = d === "short" ? "inst-dash-dir-short" : d === "long" ? "inst-dash-dir-long" : "";
|
|
return '<td class="' + cls + '">' + escapeHtml(label) + "</td>";
|
|
}
|
|
|
|
function fmtExpiry(ms) {
|
|
const n = Number(ms);
|
|
if (!Number.isFinite(n) || n <= 0) return "—";
|
|
let fallback = "—";
|
|
try {
|
|
const d = new Date(n);
|
|
if (!Number.isNaN(d.getTime())) {
|
|
const pad = function (x) {
|
|
return String(x).padStart(2, "0");
|
|
};
|
|
fallback =
|
|
d.getFullYear() +
|
|
"-" +
|
|
pad(d.getMonth() + 1) +
|
|
"-" +
|
|
pad(d.getDate()) +
|
|
" " +
|
|
pad(d.getHours()) +
|
|
":" +
|
|
pad(d.getMinutes());
|
|
}
|
|
} catch (_) {}
|
|
return (
|
|
'<span class="opt-expiry-cd" data-opt-exp-ms="' +
|
|
escapeHtml(String(Math.floor(n))) +
|
|
'">' +
|
|
escapeHtml(fallback) +
|
|
"</span>"
|
|
);
|
|
}
|
|
|
|
function goTab(tab) {
|
|
if (!tab) return;
|
|
if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") {
|
|
global.InstanceEmbed.loadTab(tab);
|
|
return;
|
|
}
|
|
const pathMap = {
|
|
trade: "/trade",
|
|
key_monitor: "/key_monitor",
|
|
strategy: "/strategy",
|
|
options: "/options",
|
|
hedge_plan: "/hedge-plan",
|
|
};
|
|
const path = pathMap[tab] || "/" + tab;
|
|
location.href = path;
|
|
}
|
|
|
|
function tableWrap(headers, rowsHtml) {
|
|
return (
|
|
'<div class="inst-dash-table-wrap">' +
|
|
'<table class="inst-dash-table">' +
|
|
"<thead><tr>" +
|
|
headers
|
|
.map(function (h) {
|
|
return "<th>" + escapeHtml(h) + "</th>";
|
|
})
|
|
.join("") +
|
|
"</tr></thead>" +
|
|
"<tbody>" +
|
|
rowsHtml +
|
|
"</tbody></table></div>"
|
|
);
|
|
}
|
|
|
|
function rowClickAttrs(tab) {
|
|
return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"';
|
|
}
|
|
|
|
function renderOrdersTable(items) {
|
|
const rows = items
|
|
.map(function (it) {
|
|
const sym = it.symbol || "-";
|
|
const mark =
|
|
it.mark_display != null && it.mark_display !== ""
|
|
? escapeHtml(it.mark_display)
|
|
: fmtNum(it.mark_price);
|
|
const tpProfit =
|
|
it.tp_profit != null && Number.isFinite(Number(it.tp_profit))
|
|
? '<span class="pos-tp-profit">' + Number(it.tp_profit).toFixed(2) + "U</span>"
|
|
: "—";
|
|
return (
|
|
"<tr" +
|
|
rowClickAttrs(it.tab || "trade") +
|
|
">" +
|
|
'<td class="td-symbol"><span class="inst-dash-sym-link">' +
|
|
escapeHtml(sym) +
|
|
"</span></td>" +
|
|
dirCell(it) +
|
|
"<td>" +
|
|
fmtNum(it.entry) +
|
|
"</td>" +
|
|
"<td>" +
|
|
mark +
|
|
"</td>" +
|
|
"<td>" +
|
|
fmtNum(it.contracts) +
|
|
"</td>" +
|
|
"<td>" +
|
|
tpProfit +
|
|
"</td>" +
|
|
"<td>" +
|
|
fmtPnlPlain(it.float_pnl) +
|
|
"</td>" +
|
|
"<td>—</td>" +
|
|
"</tr>"
|
|
);
|
|
})
|
|
.join("");
|
|
return tableWrap(
|
|
["合约", "方向", "开仓价", "标记价", "张数", "盈利金额", "浮盈", "操作"],
|
|
rows
|
|
);
|
|
}
|
|
|
|
function renderKeysTable(items) {
|
|
const rows = items
|
|
.map(function (it) {
|
|
return (
|
|
"<tr" +
|
|
rowClickAttrs(it.tab || "key_monitor") +
|
|
">" +
|
|
"<td>" +
|
|
escapeHtml(it.symbol || "-") +
|
|
"</td>" +
|
|
dirCell(it) +
|
|
"<td>" +
|
|
escapeHtml(it.subtitle || "—") +
|
|
"</td>" +
|
|
"<td>" +
|
|
fmtNum(it.upper) +
|
|
"</td>" +
|
|
"<td>" +
|
|
fmtNum(it.lower) +
|
|
"</td>" +
|
|
"</tr>"
|
|
);
|
|
})
|
|
.join("");
|
|
return tableWrap(["合约", "方向", "信号", "上沿", "下沿"], rows);
|
|
}
|
|
|
|
function renderStrategyTable(items) {
|
|
const rows = items
|
|
.map(function (it) {
|
|
const kindLabel = it.kind === "roll" ? "顺势加仓" : it.kind === "trend" ? "趋势回调" : "策略";
|
|
return (
|
|
"<tr" +
|
|
rowClickAttrs(it.tab || "strategy") +
|
|
">" +
|
|
"<td>" +
|
|
escapeHtml(kindLabel) +
|
|
"</td>" +
|
|
"<td>" +
|
|
escapeHtml(it.symbol || "-") +
|
|
"</td>" +
|
|
dirCell(it) +
|
|
"<td>" +
|
|
escapeHtml(it.status || "—") +
|
|
"</td>" +
|
|
"<td>" +
|
|
fmtNum(it.entry) +
|
|
"</td>" +
|
|
"</tr>"
|
|
);
|
|
})
|
|
.join("");
|
|
return tableWrap(["类型", "合约", "方向", "状态", "入场"], rows);
|
|
}
|
|
|
|
function renderOptionsTable(items) {
|
|
const rows = items
|
|
.map(function (it) {
|
|
const opt = it.opt_type_label ||
|
|
(String(it.opt_type || "").toUpperCase() === "C"
|
|
? "Call"
|
|
: String(it.opt_type || "").toUpperCase() === "P"
|
|
? "Put"
|
|
: it.opt_type || "—");
|
|
return (
|
|
"<tr" +
|
|
rowClickAttrs(it.tab || "options") +
|
|
">" +
|
|
"<td>" +
|
|
escapeHtml(it.inst_id || it.title || "-") +
|
|
"</td>" +
|
|
"<td>" +
|
|
escapeHtml(it.source_label || "纯期权") +
|
|
"</td>" +
|
|
"<td>" +
|
|
escapeHtml(opt) +
|
|
"</td>" +
|
|
"<td>" +
|
|
fmtNum(it.pos) +
|
|
"</td>" +
|
|
"<td>" +
|
|
fmtExpiry(it.exp_time_ms) +
|
|
"</td>" +
|
|
"<td>" +
|
|
escapeHtml(it.target_monitor || "—") +
|
|
"</td>" +
|
|
"<td>" +
|
|
fmtPnl(it.pnl) +
|
|
"</td>" +
|
|
"</tr>"
|
|
);
|
|
})
|
|
.join("");
|
|
return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "净盈亏"], rows);
|
|
}
|
|
|
|
function renderHedgeTable(items) {
|
|
const rows = items
|
|
.map(function (it) {
|
|
const stCls = it.status_active ? "inst-dash-status-active" : "";
|
|
const stText = it.status_label || (it.status_active ? "进行中" : it.status || "—");
|
|
return (
|
|
"<tr" +
|
|
rowClickAttrs(it.tab || "hedge_plan") +
|
|
">" +
|
|
"<td>#" +
|
|
escapeHtml(it.id != null ? it.id : "—") +
|
|
"</td>" +
|
|
"<td>" +
|
|
escapeHtml(it.underlying || "-") +
|
|
"</td>" +
|
|
"<td>" +
|
|
escapeHtml(it.plan_type_label || it.plan_type || "—") +
|
|
"</td>" +
|
|
'<td class="' +
|
|
stCls +
|
|
'">' +
|
|
escapeHtml(stText) +
|
|
"</td>" +
|
|
"<td>" +
|
|
escapeHtml(it.contracts_summary || it.subtitle || "—") +
|
|
"</td>" +
|
|
"</tr>"
|
|
);
|
|
})
|
|
.join("");
|
|
return tableWrap(["ID", "标的", "计划类型", "状态", "说明"], rows);
|
|
}
|
|
|
|
function renderTable(key, items) {
|
|
if (key === "orders") return renderOrdersTable(items);
|
|
if (key === "keys") return renderKeysTable(items);
|
|
if (key === "strategy") return renderStrategyTable(items);
|
|
if (key === "options") return renderOptionsTable(items);
|
|
if (key === "hedge_plan") return renderHedgeTable(items);
|
|
return "";
|
|
}
|
|
|
|
function sectionHasData(sec) {
|
|
if (!sec) return false;
|
|
const count = Number(sec.count);
|
|
if (Number.isFinite(count) && count > 0) return true;
|
|
return Array.isArray(sec.items) && sec.items.length > 0;
|
|
}
|
|
|
|
function renderSection(key, sec) {
|
|
if (!sectionHasData(sec)) return "";
|
|
const items = sec.items || [];
|
|
const count = Number(sec.count) || items.length;
|
|
return (
|
|
'<section class="inst-dash-section" data-dash-section="' +
|
|
escapeHtml(key) +
|
|
'">' +
|
|
'<div class="inst-dash-section-head">' +
|
|
"<h3>" +
|
|
escapeHtml(sec.title || key) +
|
|
' <span class="inst-dash-count">' +
|
|
count +
|
|
"</span></h3>" +
|
|
'<button type="button" class="btn-sm inst-dash-goto" data-dash-tab="' +
|
|
escapeHtml(sec.tab || "") +
|
|
'">打开</button>' +
|
|
"</div>" +
|
|
renderTable(key, items) +
|
|
"</section>"
|
|
);
|
|
}
|
|
|
|
function bindClicks(el) {
|
|
if (!el) return;
|
|
el.querySelectorAll("[data-dash-tab]").forEach(function (node) {
|
|
const handler = function () {
|
|
goTab(node.getAttribute("data-dash-tab"));
|
|
};
|
|
node.addEventListener("click", handler);
|
|
node.addEventListener("keydown", function (ev) {
|
|
if (ev.key === "Enter" || ev.key === " ") {
|
|
ev.preventDefault();
|
|
handler();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function load(opts) {
|
|
const el = root();
|
|
if (!el) return;
|
|
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
|
|
const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections");
|
|
const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated");
|
|
const options = opts || {};
|
|
if (loading && !options.force) return;
|
|
loading = true;
|
|
if (status && !options.silent) status.textContent = "同步中…";
|
|
try {
|
|
const dashRes = await fetch("/api/instance/dashboard", { credentials: "same-origin" });
|
|
if (dashRes.status === 401) {
|
|
location.href = "/login?next=" + encodeURIComponent(location.pathname);
|
|
return;
|
|
}
|
|
const data = await dashRes.json().catch(function () {
|
|
return {};
|
|
});
|
|
if (!dashRes.ok || !data.ok) {
|
|
if (
|
|
data.aggregating ||
|
|
(data.msg && String(data.msg).indexOf("尚未就绪") >= 0)
|
|
) {
|
|
if (status) status.textContent = "后台聚合中…";
|
|
return;
|
|
}
|
|
throw new Error(data.msg || data.error || dashRes.statusText || "加载失败");
|
|
}
|
|
const ver = Number(data.dashboard_version) || 0;
|
|
if (ver) localDashVersion = ver;
|
|
if (data.orders && Array.isArray(data.orders.items)) {
|
|
data.orders.count = data.orders.items.length;
|
|
}
|
|
if (updated) updated.textContent = "更新 " + (data.updated_at || "—");
|
|
if (sections) {
|
|
const html = SECTION_ORDER.map(function (k) {
|
|
return renderSection(k, data[k]);
|
|
}).join("");
|
|
sections.innerHTML =
|
|
html || '<p class="inst-dash-empty muted">当前无活跃监控与持仓</p>';
|
|
bindClicks(sections);
|
|
if (global.OptionsExpiryCountdown) {
|
|
if (typeof global.OptionsExpiryCountdown.tick === "function") {
|
|
global.OptionsExpiryCountdown.tick(sections);
|
|
}
|
|
if (typeof global.OptionsExpiryCountdown.ensureTimer === "function") {
|
|
global.OptionsExpiryCountdown.ensureTimer();
|
|
}
|
|
}
|
|
}
|
|
const sec = Number(data.poll_interval_sec) || 5;
|
|
if (status) {
|
|
status.textContent = options.silent
|
|
? "SSE 已连接 · 后台每 " + sec + "s 聚合"
|
|
: "已更新 · 后台每 " + sec + "s 聚合";
|
|
}
|
|
} catch (e) {
|
|
if (status) status.textContent = e.message || "加载失败";
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function closeDashboardStream() {
|
|
if (dashEventSource) {
|
|
dashEventSource.close();
|
|
dashEventSource = null;
|
|
}
|
|
if (dashReconnectTimer) {
|
|
clearTimeout(dashReconnectTimer);
|
|
dashReconnectTimer = null;
|
|
}
|
|
}
|
|
|
|
function connectDashboardStream() {
|
|
const el = root();
|
|
if (!el) return;
|
|
closeDashboardStream();
|
|
dashEventSource = new EventSource("/api/instance/dashboard/stream");
|
|
dashEventSource.addEventListener("dashboard", function (ev) {
|
|
try {
|
|
const st = JSON.parse(ev.data || "{}");
|
|
const ver = Number(st.dashboard_version) || 0;
|
|
if (ver && ver !== localDashVersion) {
|
|
load({ silent: true });
|
|
} else if (st.aggregating) {
|
|
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
|
|
if (status) status.textContent = "后台聚合中…";
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
dashEventSource.onerror = function () {
|
|
closeDashboardStream();
|
|
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
|
|
if (status) status.textContent = "SSE 断开,8s 后重连…";
|
|
dashReconnectTimer = setTimeout(function () {
|
|
if (booted) {
|
|
connectDashboardStream();
|
|
load({ silent: true });
|
|
}
|
|
}, 8000);
|
|
};
|
|
}
|
|
|
|
async function requestDashboardRefresh() {
|
|
try {
|
|
await fetch("/api/instance/dashboard/refresh", {
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
});
|
|
} catch (_) {}
|
|
load({ force: true });
|
|
}
|
|
|
|
function stopAuto() {
|
|
closeDashboardStream();
|
|
}
|
|
|
|
function init(force) {
|
|
const el = root();
|
|
if (!el) return;
|
|
if (!force && el.getAttribute("data-dash-booted") === "1") {
|
|
booted = true;
|
|
load({ silent: true });
|
|
connectDashboardStream();
|
|
return;
|
|
}
|
|
el.setAttribute("data-dash-booted", "1");
|
|
booted = true;
|
|
const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh");
|
|
if (btn && !btn.getAttribute("data-bound")) {
|
|
btn.setAttribute("data-bound", "1");
|
|
btn.addEventListener("click", function () {
|
|
requestDashboardRefresh();
|
|
});
|
|
}
|
|
load({});
|
|
connectDashboardStream();
|
|
}
|
|
|
|
function refreshSoft(opts) {
|
|
load(Object.assign({ silent: true }, opts || {}));
|
|
}
|
|
|
|
global.InstanceDashboard = {
|
|
init: init,
|
|
refreshSoft: refreshSoft,
|
|
load: load,
|
|
stopAuto: stopAuto,
|
|
};
|
|
})(window);
|