Files
crypto_okx_hub/static/hub.js
T

1089 lines
36 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 (global) {
function fmtPnl(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return (n > 0 ? "+" : "") + n.toFixed(2) + " U";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "pnl-flat";
return n > 0 ? "pnl-up" : "pnl-down";
}
function fmtPnlHtml(v) {
return '<span class="' + pnlClass(v) + '">' + escapeHtml(fmtPnl(v)) + "</span>";
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatExitBrief(p) {
if (p && p.target_monitor_text && String(p.target_monitor_text).trim() && p.target_monitor_text !== "—") {
return String(p.target_monitor_text).trim();
}
const parts = [];
const tgt = Number(p && p.target_index);
if (Number.isFinite(tgt) && tgt > 0) {
const ot = String((p && p.opt_type) || "").toUpperCase();
const side = ot === "P" ? "Put≤" : ot === "C" ? "Call≥" : "目标";
parts.push(side + " " + tgt);
}
if (p && p.profit_exit_enabled) {
const m = Number(p.profit_exit_mult);
if (Number.isFinite(m) && m > 0) {
parts.push(Math.abs(m - Math.round(m)) < 1e-9 ? Math.round(m) + "倍" : m + "倍");
} else {
parts.push("利润出场");
}
}
return parts.length ? parts.join(" · ") : "未设出场";
}
function activeCondOrder(snap) {
const list = snap && snap.cond_orders_active;
if (Array.isArray(list) && list.length && list[0]) return list[0];
if (snap && snap.cond_order) return snap.cond_order;
if (snap && snap.cond_order_locked) return { status: "armed" };
return null;
}
function condStatusLabel(st) {
const map = { armed: "待命", triggering: "触发中", open: "持仓中" };
return map[st] || "占用";
}
function condStatusClass(st) {
if (st === "open") return "is-open";
if (st === "triggering") return "is-trig";
return "is-armed";
}
function condTagHtml(cond) {
if (!cond) return "";
const st = String(cond.status || "armed");
return (
'<span class="overview-cond-tag ' +
condStatusClass(st) +
'">条件单 · ' +
escapeHtml(condStatusLabel(st)) +
"</span>"
);
}
function condWaitLine(cond) {
if (!cond) return "条件单占用";
const put = String(cond.opt_type || "").toUpperCase() !== "C";
const uly = String(cond.underlying || "").trim();
const side =
String(cond.trigger_side || "").toLowerCase() === "lte"
? "下穿 ≤"
: put
? "上穿 ≥"
: "下穿 ≤";
const px = cond.trigger_px != null && cond.trigger_px !== "" ? String(cond.trigger_px) : "";
const bits = [];
if (uly) bits.push(uly + " " + (put ? "Put" : "Call"));
if (px) bits.push(side + " " + px);
return bits.length ? bits.join(" · ") : "条件单占用";
}
function positionSummary(brief, cond) {
const list = brief || [];
if (!list.length) {
if (cond) {
return (
'<div class="overview-cond-wait">' +
'<div class="overview-cond-wait-line">' +
escapeHtml(condWaitLine(cond)) +
"</div></div>"
);
}
return '<span class="muted">暂无持仓</span>';
}
const opened = cond && String(cond.opened_inst_id || "").trim();
const tagOpen = !!(cond && String(cond.status || "") === "open");
const items = list.slice(0, 8).map(function (p) {
const id = p.inst_id || "";
const sheets = escapeHtml(String(p.sheets != null ? p.sheets : "—"));
const lev = Number(p.contract_leverage != null ? p.contract_leverage : p.leverage);
const levTxt =
Number.isFinite(lev) && lev > 0
? " · 约" + lev.toFixed(1) + "×"
: "";
const fromCond = tagOpen && (!opened || opened === id);
return (
"<li>" +
escapeHtml(id) +
" · " +
sheets +
"张" +
escapeHtml(levTxt) +
" · " +
fmtPnlHtml(p.pnl_usdt) +
(fromCond ? '<span class="overview-cond-src">条件单</span>' : "") +
"</li>"
);
});
const more =
list.length > 8
? '<li class="muted">…共 ' + list.length + " 笔</li>"
: "";
return "<ul class=\"pos-brief\">" + items.join("") + more + "</ul>";
}
function exitSummary(brief) {
const list = brief || [];
if (!list.length) return '<span class="muted">—</span>';
const items = list.slice(0, 8).map(function (p) {
const exitText = formatExitBrief(p);
const exitCls = exitText === "未设出场" ? "exit-unset" : "exit-set";
return '<li class="' + exitCls + '">' + escapeHtml(exitText) + "</li>";
});
const more =
list.length > 8
? '<li class="muted">…共 ' + list.length + " 笔</li>"
: "";
return '<ul class="pos-brief exit-brief">' + items.join("") + more + "</ul>";
}
function closeActionButtons(brief, instanceId, online) {
const list = (brief || []).filter(function (p) {
return !!(p && p.inst_id);
});
if (!online) {
return (
'<button type="button" class="btn btn-close-disabled" disabled title="实例离线">平仓</button>'
);
}
if (!list.length) {
return (
'<button type="button" class="btn btn-close-disabled" disabled title="暂无持仓">平仓</button>'
);
}
return list
.slice(0, 8)
.map(function (p) {
const id = p.inst_id || "";
const invalid = !!(
p.bid_invalid ||
(p.bid_px == null && (p.bid_sz == null || p.bid_sz === ""))
);
const reason = (p.bid_invalid_reason || "买一无效,禁止平仓").toString();
const tip = list.length > 1 ? id + " · 买一限价全平" : "买一限价全平";
if (invalid) {
return (
'<button type="button" class="btn btn-danger" disabled title="' +
escapeHtml(reason) +
'">平仓</button>'
);
}
return (
'<button type="button" class="btn btn-danger" data-close-inst="' +
encodeURIComponent(String(instanceId)) +
'" data-close-opt="' +
encodeURIComponent(id) +
'" title="' +
escapeHtml(tip) +
'">平仓</button>'
);
})
.join("");
}
function fmtBidPx(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
let s = n.toFixed(4).replace(/\.?0+$/, "");
return s || "0";
}
function isLiquidityWeak(p) {
if (!p) return false;
// 仅明确残档/流动性不足时标黄;liquidity_ok==null 表示未同步,不标黄
if (p.bid_invalid || p.liquidity_ok === false) return true;
const uncovered = Number(p.uncovered_sheets);
return Number.isFinite(uncovered) && uncovered > 0;
}
function bidLiquidityLine(p) {
const noBid = p.bid_px == null && (p.bid_sz == null || p.bid_sz === "");
if (p.bid_invalid) {
const reason = (p.bid_invalid_reason || "无买一/残档").toString();
const short = reason.length > 18 ? reason.slice(0, 18) + "…" : reason;
return { text: short, weak: true };
}
if (noBid) {
// 已判定流动性不足 → 标黄;否则视为未同步,显示 —
if (p.liquidity_ok === false) {
return { text: "无买一", weak: true };
}
return { text: "—", weak: false };
}
const px = fmtBidPx(p.bid_px);
const sz = p.bid_sz != null ? String(p.bid_sz) : "—";
const uncovered = Number(p.uncovered_sheets);
let text = px + " / " + sz + "张";
const weak = isLiquidityWeak(p);
if (Number.isFinite(uncovered) && uncovered > 0) {
text += "(差" + uncovered + ")";
}
return { text: text, weak: weak };
}
function bidLiquiditySummary(brief) {
const list = brief || [];
if (!list.length) return '<span class="muted">—</span>';
const items = list.slice(0, 8).map(function (p) {
const line = bidLiquidityLine(p);
const cls = line.weak ? "liq-warn" : "liq-ok";
return '<li class="' + cls + '">' + escapeHtml(line.text) + "</li>";
});
const more =
list.length > 8
? '<li class="muted">…共 ' + list.length + " 笔</li>"
: "";
const anyWeak = list.some(isLiquidityWeak);
const wrapCls = anyWeak ? "pos-brief liq-brief liq-brief--warn" : "pos-brief liq-brief";
return '<ul class="' + wrapCls + '">' + items.join("") + more + "</ul>";
}
function fmtMoneyU(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(2) + " U";
}
function fmtRatioPct(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(2) + "%";
}
function premiumCell(snap) {
const prem = snap.premium || {};
const paid = prem.paid_usdt;
const equity = prem.equity_usdt;
const cash = prem.cash_usdt;
const tipParts = [];
if (cash != null) tipParts.push("余额 " + fmtMoneyU(cash));
if (paid != null) tipParts.push("开仓占用 " + fmtMoneyU(paid));
if (equity != null) tipParts.push("合计(余额+占用) " + fmtMoneyU(equity));
const tip = tipParts.join(" · ") || "账户资金暂无";
return (
'<span title="' +
escapeHtml(tip) +
'">' +
escapeHtml(fmtMoneyU(paid)) +
"</span>"
);
}
function premiumRatioCell(snap) {
const prem = snap.premium || {};
const ratio = prem.ratio_pct;
const tip =
prem.paid_usdt != null && prem.equity_usdt != null
? "开仓占用 " +
fmtMoneyU(prem.paid_usdt) +
" / (余额+占用) " +
fmtMoneyU(prem.equity_usdt)
: "";
return (
'<span title="' +
escapeHtml(tip) +
'">' +
escapeHtml(fmtRatioPct(ratio)) +
"</span>"
);
}
function isHubPhone() {
return (
document.documentElement.classList.contains("hub-phone-html") ||
!!(document.body && document.body.classList.contains("hub-phone"))
);
}
function openClient(instanceId) {
if (!instanceId) return;
fetch("/api/instances/" + encodeURIComponent(instanceId) + "/open_url")
.then(function (r) {
return r.json();
})
.then(function (d) {
if (d && d.url) {
window.location.href = d.url;
return;
}
window.location.href = "/desk?id=" + encodeURIComponent(instanceId);
})
.catch(function () {
window.location.href = "/desk?id=" + encodeURIComponent(instanceId);
});
}
function bindOverviewActions(root) {
if (!root) return;
root.querySelectorAll("[data-close-opt]").forEach(function (btn) {
btn.addEventListener("click", function () {
requestClose(btn);
});
});
root.querySelectorAll("[data-open-inst]").forEach(function (btn) {
btn.addEventListener("click", function () {
openClient(btn.getAttribute("data-open-inst"));
});
});
}
function shortOptName(id) {
const s = String(id || "").trim();
if (!s) return "—";
const parts = s.split("-");
if (parts.length >= 5) {
const base = parts[0];
const strike = parts[3];
const ot = String(parts[4] || "").toUpperCase();
const side = ot === "P" ? "Put" : ot === "C" ? "Call" : ot;
return base + " " + side + " " + strike;
}
return s;
}
function formatExitBriefPhone(p) {
if (!p) return "";
if (p.target_monitor_text && String(p.target_monitor_text).trim() && p.target_monitor_text !== "—") {
return String(p.target_monitor_text).trim();
}
const tgt = Number(p.target_index);
if (Number.isFinite(tgt) && tgt > 0) {
const ot = String(p.opt_type || "").toUpperCase();
const side = ot === "P" ? "Put≤" : ot === "C" ? "Call≥" : "目标";
return side + tgt;
}
if (p.profit_exit_enabled) {
const m = Number(p.profit_exit_mult);
if (Number.isFinite(m) && m > 0) {
return (Math.abs(m - Math.round(m)) < 1e-9 ? Math.round(m) : m) + "倍出场";
}
return "利润出场";
}
return "";
}
function phonePosLine(p) {
const sheets = p.sheets != null ? String(p.sheets) : "—";
const lev = Number(p.contract_leverage != null ? p.contract_leverage : p.leverage);
const levTxt = Number.isFinite(lev) && lev > 0 ? " · " + lev.toFixed(1) + "×" : "";
return escapeHtml(shortOptName(p.inst_id) + " · " + sheets + "张" + levTxt);
}
function phonePositionBlock(brief, cond) {
const list = brief || [];
if (!list.length) {
if (cond) {
return (
'<div class="hub-phone-card-pos">' +
escapeHtml(condWaitLine(cond)) +
"</div>"
);
}
return '<div class="hub-phone-card-pos muted">暂无持仓</div>';
}
const shown = list.slice(0, 3).map(function (p) {
return '<div class="hub-phone-pos-line">' + phonePosLine(p) + "</div>";
});
if (list.length > 3) {
shown.push('<div class="muted">还有 ' + (list.length - 3) + " 笔</div>");
}
return '<div class="hub-phone-card-pos">' + shown.join("") + "</div>";
}
function phoneFootLine(brief) {
const list = brief || [];
if (!list.length) return "";
const bits = [];
const exitText = formatExitBriefPhone(list[0]);
if (exitText) bits.push(exitText);
const liq = bidLiquidityLine(list[0]);
if (liq && liq.text) bits.push((liq.weak ? "买一弱 " : "") + liq.text);
if (!bits.length) return "";
const cls = liq && liq.weak ? "hub-phone-card-foot liq-warn" : "hub-phone-card-foot muted";
return '<div class="' + cls + '">' + escapeHtml(bits.join(" · ")) + "</div>";
}
function renderPhoneCard(row) {
const snap = row.snapshot || {};
const inst = snap.instance || {};
const pnl =
snap.pnl && snap.pnl.unrealized_usdt != null
? snap.pnl.unrealized_usdt
: snap.upl_total_usdc;
const brief = snap.positions_brief || snap.positions || [];
const cond = activeCondOrder(snap);
const online = !!row.online;
const m = String(inst.mode || "").toLowerCase();
const modeHtml =
m === "sim" || m === "live"
? '<span class="overview-mode-tag overview-mode-tag--' + m + '">' + escapeHtml(m) + "</span>"
: inst.mode
? '<span class="muted">' + escapeHtml(inst.mode) + "</span>"
: "";
const err = row.error
? '<div class="muted hub-phone-card-err">错误: ' + escapeHtml(row.error) + "</div>"
: "";
return (
'<article class="hub-phone-card">' +
'<div class="hub-phone-card-head">' +
"<strong>" +
escapeHtml(row.name || "") +
"</strong>" +
'<div class="hub-phone-card-tags">' +
(online ? '<span class="status-on">在线</span>' : '<span class="status-off">离线</span>') +
modeHtml +
condTagHtml(cond) +
"</div></div>" +
err +
'<div class="hub-phone-card-pnl">' +
'<span class="muted">浮盈</span>' +
fmtPnlHtml(pnl) +
"</div>" +
'<div class="hub-phone-card-kpis">' +
"<div><span class=\"muted\">权利金</span>" +
premiumCell(snap) +
"</div>" +
"<div><span class=\"muted\">占比</span>" +
premiumRatioCell(snap) +
"</div>" +
"</div>" +
phonePositionBlock(brief, cond) +
phoneFootLine(brief) +
'<div class="hub-phone-card-actions">' +
'<button type="button" class="btn" data-open-inst="' +
escapeHtml(String(row.id || "")) +
'">打开</button>' +
closeActionButtons(brief, row.id, online) +
"</div>" +
"</article>"
);
}
function renderRow(row) {
const snap = row.snapshot || {};
const inst = snap.instance || {};
const pnl =
snap.pnl && snap.pnl.unrealized_usdt != null
? snap.pnl.unrealized_usdt
: snap.upl_total_usdc;
const brief = snap.positions_brief || snap.positions || [];
const cond = activeCondOrder(snap);
const online = !!row.online;
const status = online
? '<span class="status-on">在线</span>'
: '<span class="status-off">离线</span>';
const err = row.error
? '<div class="muted">错误: ' + escapeHtml(row.error) + "</div>"
: "";
return (
"<tr>" +
"<td><strong>" +
escapeHtml(row.name || "") +
"</strong>" +
condTagHtml(cond) +
err +
"</td>" +
"<td>" +
status +
"</td>" +
"<td>" +
(function () {
const m = String(inst.mode || "").toLowerCase();
if (m === "sim" || m === "live") {
return (
'<span class="overview-mode-tag overview-mode-tag--' +
m +
'">' +
escapeHtml(m) +
"</span>"
);
}
return escapeHtml(inst.mode || "—");
})() +
"</td>" +
"<td>" +
fmtPnlHtml(pnl) +
"</td>" +
"<td>" +
premiumCell(snap) +
"</td>" +
"<td>" +
premiumRatioCell(snap) +
"</td>" +
"<td>" +
positionSummary(brief, cond) +
"</td>" +
"<td>" +
exitSummary(brief) +
"</td>" +
"<td>" +
bidLiquiditySummary(brief) +
"</td>" +
'<td><div class="overview-actions">' +
'<a class="btn" href="/desk?id=' +
encodeURIComponent(row.id) +
'">打开</a>' +
closeActionButtons(brief, row.id, online) +
"</div></td>" +
"</tr>"
);
}
async function requestClose(btn) {
const instanceId = decodeURIComponent(btn.getAttribute("data-close-inst") || "");
const optId = decodeURIComponent(btn.getAttribute("data-close-opt") || "");
if (!instanceId || !optId) return;
if (
!confirm(
"确认向该客户端下发买一限价全平?\n\n合约: " +
optId +
"\n\n无效买一将被拒绝;下单在客户端本地执行。"
)
) {
return;
}
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = "平仓中…";
try {
const rr = await fetch("/api/instances/" + encodeURIComponent(instanceId) + "/options/close", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ inst_id: optId }),
});
let dd = {};
try {
dd = await rr.json();
} catch (e) {
dd = {};
}
if (dd && dd.ok) {
let msg = dd.msg || "已提交买一全平";
if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张";
if (dd.fully_closed) msg += " · 已全平";
alert(msg);
await requestOverviewRefresh();
} else {
alert((dd && dd.msg) || "平仓失败");
btn.disabled = false;
btn.textContent = oldText;
}
} catch (e) {
alert((e && e.message) || "请求失败");
btn.disabled = false;
btn.textContent = oldText;
}
}
function numOrNull(v) {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function emptyAgg() {
return {
machines: 0,
online: 0,
offline: 0,
positions: 0,
pnl: 0,
premium: 0,
cash: 0,
hasPnl: false,
hasPremium: false,
hasCash: false,
};
}
function addOnlineMetrics(bucket, snap) {
const brief = snap.positions_brief || snap.positions || [];
bucket.positions += brief.length;
const pnl =
snap.pnl && snap.pnl.unrealized_usdt != null
? numOrNull(snap.pnl.unrealized_usdt)
: numOrNull(snap.upl_total_usdc);
if (pnl != null) {
bucket.pnl += pnl;
bucket.hasPnl = true;
}
const prem = snap.premium || {};
const paid = numOrNull(prem.paid_usdt);
const cash = numOrNull(prem.cash_usdt);
if (paid != null) {
bucket.premium += paid;
bucket.hasPremium = true;
}
if (cash != null) {
bucket.cash += cash;
bucket.hasCash = true;
}
}
function summarizeOverview(list) {
const all = emptyAgg();
const sim = emptyAgg();
const live = emptyAgg();
(list || []).forEach(function (row) {
const snap = row.snapshot || {};
const mode = String((snap.instance || {}).mode || "").toLowerCase();
const online = !!row.online;
const targets = [all];
if (mode === "sim") targets.push(sim);
else if (mode === "live") targets.push(live);
targets.forEach(function (b) {
b.machines += 1;
if (online) b.online += 1;
else b.offline += 1;
});
if (!online) return;
addOnlineMetrics(all, snap);
if (mode === "sim") addOnlineMetrics(sim, snap);
else if (mode === "live") addOnlineMetrics(live, snap);
});
return { all: all, sim: sim, live: live };
}
function fmtMoneyPlain(v, known) {
if (!known || v == null || !Number.isFinite(Number(v))) return "—";
return Number(v).toFixed(2) + " U";
}
function renderOverviewSummary(list) {
const box = document.getElementById("overview-summary");
if (!box) return;
if (!list || !list.length) {
box.hidden = true;
box.innerHTML = "";
return;
}
const s = summarizeOverview(list);
function modeRow(label, tag, bucket) {
return (
"<tr>" +
'<td><span class="overview-mode-tag overview-mode-tag--' +
tag +
'">' +
escapeHtml(label) +
"</span></td>" +
'<td class="' +
(bucket.online > 0 ? "status-on" : "") +
'">' +
bucket.online +
"</td>" +
'<td class="' +
(bucket.offline > 0 ? "status-off" : "") +
'">' +
bucket.offline +
"</td>" +
"<td>" +
bucket.positions +
"</td>" +
'<td class="' +
(bucket.hasPnl ? pnlClass(bucket.pnl) : "") +
'">' +
escapeHtml(bucket.hasPnl ? fmtPnl(bucket.pnl) : "—") +
"</td>" +
"<td>" +
escapeHtml(fmtMoneyPlain(bucket.premium, bucket.hasPremium)) +
"</td>" +
"<td>" +
escapeHtml(fmtMoneyPlain(bucket.cash, bucket.hasCash)) +
"</td>" +
"</tr>"
);
}
const rows = [];
if (s.sim.machines > 0) rows.push(modeRow("sim", "sim", s.sim));
if (s.live.machines > 0) rows.push(modeRow("live", "live", s.live));
if (!rows.length) {
box.hidden = true;
box.innerHTML = "";
return;
}
box.hidden = false;
box.innerHTML =
'<div class="overview-mode-wrap">' +
'<div class="overview-mode-head"><strong>实时汇总</strong><span class="muted">仅统计在线机资金与仓位</span></div>' +
'<table class="overview-mode-table">' +
"<thead><tr>" +
"<th>模式</th><th>在线</th><th>离线</th><th>仓位</th><th>浮盈</th><th>权利金</th><th>余额</th>" +
"</tr></thead><tbody>" +
rows.join("") +
"</tbody></table></div>";
}
function modeSortKey(mode) {
const m = String(mode || "").toLowerCase();
if (m === "sim") return 0;
if (m === "live") return 1;
return 2;
}
function sortInstancesByMode(list) {
return (list || []).slice().sort(function (a, b) {
const ma = modeSortKey((((a || {}).snapshot || {}).instance || {}).mode);
const mb = modeSortKey((((b || {}).snapshot || {}).instance || {}).mode);
if (ma !== mb) return ma - mb;
const ia = Number((a && a.id) || 0);
const ib = Number((b && b.id) || 0);
return ia - ib;
});
}
function latestPulledAt(list) {
let best = "";
(list || []).forEach(function (row) {
const t = String((row && row.pulled_at) || "").trim();
if (t && t !== "—" && t > best) best = t;
});
return best;
}
let overviewVersion = 0;
let overviewLoading = false;
let overviewEventSource = null;
let overviewReconnectTimer = null;
async function refresh(opts) {
opts = opts || {};
const silent = !!opts.silent;
const status = document.getElementById("overview-status");
const grid = document.getElementById("overview-grid");
if (!grid) return;
if (overviewLoading) return;
overviewLoading = true;
if (status && !silent) status.textContent = "读取缓存…";
try {
// 只读中控热缓存;后台常驻并行 pull,不在此请求内打实例
const r = await fetch("/api/overview", { credentials: "same-origin" });
const d = await r.json();
if (!d.ok) throw new Error(d.msg || d.error || "失败");
overviewVersion = Number(d.overview_version) || overviewVersion;
const list = sortInstancesByMode(d.instances || []);
renderOverviewSummary(list);
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint(list);
if (!list.length) {
grid.innerHTML =
d.pulling || !d.updated_at
? '<p class="muted">后台首次拉取中…</p>'
: '<p class="muted">暂无启用实例,请先到「实例」添加.</p>';
} else if (isHubPhone()) {
grid.innerHTML =
'<div class="hub-phone-cards">' + list.map(renderPhoneCard).join("") + "</div>";
bindOverviewActions(grid);
} else {
grid.innerHTML =
'<div class="overview-table-wrap"><table class="overview-table">' +
"<thead><tr>" +
"<th>名称</th><th>状态</th><th>模式</th><th>浮盈</th><th>权利金</th><th>资金占比</th><th>持仓</th><th>出场</th><th>买一/流动性</th><th>操作</th>" +
"</tr></thead><tbody>" +
list.map(renderRow).join("") +
"</tbody></table></div>";
bindOverviewActions(grid);
}
if (status) {
const s = summarizeOverview(list);
const pulled = latestPulledAt(list) || d.updated_at || "";
const sec = Number(d.poll_interval_sec) || 10;
const live = overviewEventSource && overviewEventSource.readyState === 1;
status.textContent =
(live ? "SSE 已连接 · " : "") +
"缓存 " +
list.length +
" 台 · 在线 " +
s.all.online +
" · 离线 " +
s.all.offline +
(pulled ? " · " + pulled : "") +
(d.pulling ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉") +
(d.error ? " · " + d.error : "");
}
} catch (e) {
if (status) status.textContent = e.message || "加载失败";
const box = document.getElementById("overview-summary");
if (box) {
box.hidden = true;
box.innerHTML = "";
}
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint([]);
} finally {
overviewLoading = false;
}
}
function closeOverviewStream() {
if (overviewEventSource) {
overviewEventSource.close();
overviewEventSource = null;
}
if (overviewReconnectTimer) {
clearTimeout(overviewReconnectTimer);
overviewReconnectTimer = null;
}
}
function connectOverviewStream() {
const grid = document.getElementById("overview-grid");
if (!grid || typeof EventSource === "undefined") return;
closeOverviewStream();
overviewEventSource = new EventSource("/api/overview/stream");
overviewEventSource.addEventListener("overview", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.overview_version) || 0;
if (ver && ver !== overviewVersion) {
refresh({ silent: true });
} else if (st.pulling) {
const status = document.getElementById("overview-status");
if (status) status.textContent = "后台并行拉取中…";
}
} catch (_) {}
});
overviewEventSource.onerror = function () {
closeOverviewStream();
const status = document.getElementById("overview-status");
if (status) status.textContent = "SSE 断开,8s 后重连…";
overviewReconnectTimer = setTimeout(function () {
connectOverviewStream();
refresh({ silent: true });
}, 8000);
};
}
async function requestOverviewRefresh() {
try {
await fetch("/api/overview/refresh", {
method: "POST",
credentials: "same-origin",
});
} catch (_) {}
refresh({ silent: true });
}
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const LUNAR_MONTHS = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
const LUNAR_INFO = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,
0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0,
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6,
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0,
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06b20, 0x1a6c4, 0x0aae0,
0x0a2e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4,
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0,
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160,
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252,
0x0d520,
];
function lunarLeapMonth(year) {
return LUNAR_INFO[year - 1900] & 0xf;
}
function lunarLeapDays(year) {
if (!lunarLeapMonth(year)) return 0;
return LUNAR_INFO[year - 1900] & 0x10000 ? 30 : 29;
}
function lunarMonthDays(year, month) {
return LUNAR_INFO[year - 1900] & (0x10000 >> month) ? 30 : 29;
}
function lunarYearDays(year) {
let sum = 348;
for (let i = 0x8000; i > 0x8; i >>= 1) {
sum += LUNAR_INFO[year - 1900] & i ? 1 : 0;
}
return sum + lunarLeapDays(year);
}
function lunarDayName(day) {
const n = ["日", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
if (day === 10) return "初十";
if (day === 20) return "二十";
if (day === 30) return "三十";
if (day < 11) return "初" + n[day];
if (day < 20) return "十" + n[day - 10];
return "廿" + n[day - 20];
}
function solarToLunarText(year, month, day) {
if (year < 1900 || year > 2100) return "";
let offset = Math.round(
(Date.UTC(year, month - 1, day) - Date.UTC(1900, 0, 31)) / 86400000
);
let i;
let temp = 0;
for (i = 1900; i < 2101 && offset > 0; i++) {
temp = lunarYearDays(i);
offset -= temp;
}
if (offset < 0) {
offset += temp;
i--;
}
const lunarYear = i;
const leap = lunarLeapMonth(lunarYear);
let isLeap = false;
for (i = 1; i < 13 && offset > 0; i++) {
if (leap > 0 && i === leap + 1 && !isLeap) {
i--;
isLeap = true;
temp = lunarLeapDays(lunarYear);
} else {
temp = lunarMonthDays(lunarYear, i);
}
if (isLeap && i === leap + 1) isLeap = false;
offset -= temp;
}
if (offset === 0 && leap > 0 && i === leap + 1) {
if (isLeap) {
isLeap = false;
} else {
isLeap = true;
i--;
}
}
if (offset < 0) {
offset += temp;
i--;
}
const monthName = (isLeap ? "闰" : "") + LUNAR_MONTHS[i - 1] + "月";
return "农历" + monthName + lunarDayName(offset + 1);
}
function beijingParts() {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(new Date());
const get = function (type) {
const hit = parts.find(function (p) {
return p.type === type;
});
return hit ? hit.value : "";
};
const y = Number(get("year"));
const m = Number(get("month"));
const d = Number(get("day"));
const weekdayIndex = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
return {
time:
get("year") +
"-" +
get("month") +
"-" +
get("day") +
" " +
get("hour") +
":" +
get("minute") +
":" +
get("second"),
weekdayIndex: weekdayIndex,
weekday: WEEKDAYS[weekdayIndex] || "",
lunar: solarToLunarText(y, m, d),
};
} catch (e) {
const now = new Date();
const utc = now.getTime() + now.getTimezoneOffset() * 60000;
const bj = new Date(utc + 8 * 3600000);
const p = function (n) {
return String(n).padStart(2, "0");
};
const y = bj.getFullYear();
const m = bj.getMonth() + 1;
const d = bj.getDate();
return {
time:
y +
"-" +
p(m) +
"-" +
p(d) +
" " +
p(bj.getHours()) +
":" +
p(bj.getMinutes()) +
":" +
p(bj.getSeconds()),
weekdayIndex: bj.getDay(),
weekday: WEEKDAYS[bj.getDay()] || "",
lunar: solarToLunarText(y, m, d),
};
}
}
function tickBeijingClock() {
const el = document.getElementById("overview-bj-time");
if (!el) return;
const parts = beijingParts();
el.textContent = parts.time;
el.setAttribute("datetime", parts.time.replace(" ", "T") + "+08:00");
const weekend = parts.weekdayIndex === 0 || parts.weekdayIndex === 6;
const clock = document.getElementById("overview-bj-clock");
if (clock) clock.classList.toggle("is-weekend", weekend);
const meta = document.getElementById("overview-bj-meta");
if (meta) {
meta.innerHTML =
(parts.weekday ? '<span class="overview-bj-weekday">' + parts.weekday + "</span>" : "") +
(parts.lunar ? '<span class="overview-bj-lunar">' + parts.lunar + "</span>" : "");
}
}
function boot() {
const btn = document.getElementById("btn-refresh");
if (btn) btn.addEventListener("click", function () {
requestOverviewRefresh();
});
tickBeijingClock();
setInterval(tickBeijingClock, 1000);
// 先进缓存秒开,再挂 SSE;不再前端定时现场拉实例
refresh();
connectOverviewStream();
}
global.HubOverview = {
boot: boot,
refresh: refresh,
requestRefresh: requestOverviewRefresh,
};
})(window);