b5cba83df4
Co-authored-by: Cursor <cursoragent@cursor.com>
451 lines
25 KiB
HTML
451 lines
25 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>比特骆驼行情采集分析</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0c1117; --panel: #151b24; --text: #e8eef5; --muted: #8b9aab;
|
||
--accent: #3d8fd1; --ok: #3ecf8e; --warn: #e6a23c; --bad: #e85d5d;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; min-height: 100vh; color: var(--text);
|
||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
background: radial-gradient(1200px 600px at 10% -10%, #1a2a3d 0%, transparent 55%), var(--bg);
|
||
}
|
||
header { padding: 1.5rem 2rem 0.75rem; border-bottom: 1px solid #243041; }
|
||
.brand { font-size: 1.35rem; font-weight: 700; }
|
||
.sub { color: var(--muted); margin-top: 0.35rem; font-size: 0.9rem; }
|
||
.nav { display: flex; gap: 1rem; padding: 0.75rem 2rem; }
|
||
.nav a { color: var(--muted); text-decoration: none; cursor: pointer; }
|
||
.nav a.active, .nav a:hover { color: var(--accent); }
|
||
main { padding: 1rem 2rem 3rem; max-width: 980px; }
|
||
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
|
||
.tile { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem 1.1rem; }
|
||
.label { color: var(--muted); font-size: 0.8rem; }
|
||
.value { margin-top: 0.4rem; font-size: 1.5rem; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||
.hint { margin-top: 0.35rem; color: var(--muted); font-size: 0.78rem; }
|
||
.status-ok { color: var(--ok); } .status-bad { color: var(--bad); } .status-warn { color: var(--warn); }
|
||
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin: 0.5rem 0 1rem; }
|
||
.seg { display: inline-flex; background: var(--panel); border: 1px solid #243041; border-radius: 8px; overflow: hidden; }
|
||
.seg button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.45rem 0.85rem; cursor: pointer; }
|
||
.seg button.active { background: #1e2a3a; color: var(--text); }
|
||
.date-field { color: var(--muted); font-size: 0.85rem; display: inline-flex; gap: 0.5rem; align-items: center; }
|
||
.date-field input { background: var(--panel); border: 1px solid #243041; color: var(--text); border-radius: 6px; padding: 0.35rem 0.5rem; }
|
||
.chart-wrap { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 0.75rem 0.5rem; margin-top: 0.5rem; }
|
||
.chart-title { padding: 0 0.75rem 0.25rem; color: var(--muted); font-size: 0.85rem; }
|
||
.chart-svg { width: 100%; height: auto; display: block; }
|
||
.hidden { display: none; }
|
||
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; }
|
||
.center-page { display: flex; justify-content: center; align-items: flex-start; min-height: 50vh; padding: 1.5rem 0 3rem; }
|
||
.settings-card { width: 100%; max-width: 420px; }
|
||
.settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.75rem; }
|
||
.field { display: block; margin-bottom: 1rem; }
|
||
.field-input { width: 100%; margin-top: 0.4rem; padding: 0.55rem 0.65rem; border-radius: 6px; border: 1px solid #243041; background: #0c1117; color: var(--text); }
|
||
.btn-primary { margin-top: 0.5rem; padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: var(--accent); color: #fff; cursor: pointer; }
|
||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.form-err { color: var(--bad); font-size: 0.85rem; }
|
||
.form-ok { color: var(--ok); font-size: 0.85rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="brand">比特骆驼行情采集分析</div>
|
||
<div class="sub">只读采集 · 杠杆 = 指数 ÷ 卖一 · Asia/Shanghai</div>
|
||
</header>
|
||
<div class="nav">
|
||
<a id="nav-dash" class="active" data-page="dash">总览</a>
|
||
<a id="nav-ops" data-page="ops">作战地图</a>
|
||
<a id="nav-settings" data-page="settings">系统设置</a>
|
||
<a id="nav-logout" class="hidden">退出</a>
|
||
</div>
|
||
<main>
|
||
<section id="page-login" class="hidden">
|
||
<div class="center-page">
|
||
<div class="tile settings-card">
|
||
<div class="settings-title">登录</div>
|
||
<label class="field"><span class="label">用户名</span>
|
||
<input id="loginUser" class="field-input" value="admin" autocomplete="username" />
|
||
</label>
|
||
<label class="field"><span class="label">密码</span>
|
||
<input id="loginPass" type="password" class="field-input" autocomplete="current-password" />
|
||
</label>
|
||
<p id="loginErr" class="form-err"></p>
|
||
<button id="loginBtn" type="button" class="btn-primary">登录</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section id="page-dash">
|
||
<div class="row">
|
||
<div class="tile"><div class="label">采集状态</div><div class="value" id="status">…</div><div class="hint" id="lag">—</div></div>
|
||
<div class="tile"><div class="label">ATM Call 杠杆</div><div class="value" id="callLev">—</div><div class="hint" id="callMeta">—</div></div>
|
||
<div class="tile"><div class="label">ATM Put 杠杆</div><div class="value" id="putLev">—</div><div class="hint" id="putMeta">—</div></div>
|
||
<div class="tile"><div class="label">指数</div><div class="value" id="indexPx">—</div><div class="hint" id="expiry">—</div></div>
|
||
</div>
|
||
<pre id="raw" style="margin-top:1rem">加载中…</pre>
|
||
</section>
|
||
<section id="page-ops" class="hidden">
|
||
<div class="toolbar">
|
||
<div class="seg" id="rangeSeg">
|
||
<button type="button" data-range="day" class="active">日</button>
|
||
<button type="button" data-range="week">周</button>
|
||
<button type="button" data-range="month">月</button>
|
||
</div>
|
||
<div class="seg" id="sideSeg">
|
||
<button type="button" data-side="both" class="active">双边</button>
|
||
<button type="button" data-side="C">Call</button>
|
||
<button type="button" data-side="P">Put</button>
|
||
</div>
|
||
<label class="date-field">锚点日 <input type="date" id="anchorDate" /></label>
|
||
</div>
|
||
<div class="row">
|
||
<div class="tile"><div class="label">范围</div><div class="value" id="opsRange" style="font-size:1.05rem">—</div><div class="hint" id="opsSamples">—</div></div>
|
||
<div class="tile"><div class="label">达标线</div><div class="value" id="opsMin">—</div><div class="hint">杠杆 = 指数 ÷ 卖一</div></div>
|
||
<div class="tile"><div class="label">波动样本</div><div class="value" id="opsMoveN">—</div><div class="hint" id="opsPending">—</div></div>
|
||
</div>
|
||
<div class="chart-wrap">
|
||
<div class="chart-title" id="chartTitle">上图 · 时段杠杆均值</div>
|
||
<svg id="levChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
|
||
</div>
|
||
<div class="toolbar" style="margin-top:1rem">
|
||
<div class="seg" id="moveModeSeg">
|
||
<button type="button" data-mode="abs" class="active">绝对波动</button>
|
||
<button type="button" data-mode="signed">带符号</button>
|
||
</div>
|
||
<span id="pendingBadge" style="color:var(--warn);font-size:0.85rem"></span>
|
||
</div>
|
||
<div class="chart-wrap">
|
||
<div class="chart-title" id="moveTitle">下图 · 时段→到期波动</div>
|
||
<svg id="moveChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
|
||
</div>
|
||
<p id="moveHint" style="color:var(--muted);font-size:0.85rem;margin-top:0.75rem"></p>
|
||
</section>
|
||
<section id="page-settings" class="hidden">
|
||
<div class="center-page">
|
||
<form id="settingsForm" class="tile settings-card">
|
||
<div class="settings-title">系统设置</div>
|
||
<p id="settingsAuthHint" class="hint" style="margin-bottom:1rem"></p>
|
||
<label class="field"><span class="label">用户名</span>
|
||
<input id="setUsername" class="field-input" autocomplete="username" />
|
||
</label>
|
||
<label class="field"><span class="label">当前密码</span>
|
||
<input id="setCurPass" type="password" class="field-input" autocomplete="current-password" />
|
||
</label>
|
||
<label class="field"><span class="label">新密码(留空则不修改)</span>
|
||
<input id="setNewPass" type="password" class="field-input" autocomplete="new-password" />
|
||
</label>
|
||
<label class="field"><span class="label">确认新密码</span>
|
||
<input id="setNewPass2" type="password" class="field-input" autocomplete="new-password" />
|
||
</label>
|
||
<p id="settingsErr" class="form-err"></p>
|
||
<p id="settingsOk" class="form-ok"></p>
|
||
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存</button>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const TOKEN_KEY = "mi_token";
|
||
const state = { page: "dash", range: "day", side: "both", moveMode: "abs", lastMov: null, authRequired: false, loadedUsername: "admin" };
|
||
function getToken() { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } }
|
||
function setToken(t) { try { if (t) localStorage.setItem(TOKEN_KEY, t); else localStorage.removeItem(TOKEN_KEY); } catch {} }
|
||
function authHeaders() {
|
||
const t = getToken();
|
||
return t ? { Authorization: "Bearer " + t } : {};
|
||
}
|
||
async function apiFetch(url, init) {
|
||
const headers = Object.assign({}, (init && init.headers) || {}, authHeaders());
|
||
const r = await fetch(url, Object.assign({}, init || {}, { headers, credentials: "include" }));
|
||
if (r.status === 401) { setToken(null); showLogin(); }
|
||
return r;
|
||
}
|
||
function fmt(n, d=1) {
|
||
if (n == null || Number.isNaN(n)) return "—";
|
||
return Number(n).toFixed(d);
|
||
}
|
||
function todayYmd() {
|
||
const d = new Date();
|
||
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
|
||
}
|
||
document.getElementById("anchorDate").value = todayYmd();
|
||
|
||
function showLogin() {
|
||
document.getElementById("page-login").classList.remove("hidden");
|
||
document.getElementById("page-dash").classList.add("hidden");
|
||
document.getElementById("page-ops").classList.add("hidden");
|
||
document.getElementById("page-settings").classList.add("hidden");
|
||
document.getElementById("nav-logout").classList.add("hidden");
|
||
}
|
||
function hideLogin() {
|
||
document.getElementById("page-login").classList.add("hidden");
|
||
document.getElementById("nav-logout").classList.toggle("hidden", !state.authRequired);
|
||
}
|
||
|
||
function showPage(p) {
|
||
if (state.authRequired && !getToken() && !document.cookie.includes("mi_token=")) {
|
||
showLogin();
|
||
return;
|
||
}
|
||
state.page = p;
|
||
hideLogin();
|
||
document.getElementById("page-dash").classList.toggle("hidden", p !== "dash");
|
||
document.getElementById("page-ops").classList.toggle("hidden", p !== "ops");
|
||
document.getElementById("page-settings").classList.toggle("hidden", p !== "settings");
|
||
document.getElementById("nav-dash").classList.toggle("active", p === "dash");
|
||
document.getElementById("nav-ops").classList.toggle("active", p === "ops");
|
||
document.getElementById("nav-settings").classList.toggle("active", p === "settings");
|
||
if (p === "ops") location.hash = "ops-map";
|
||
else if (p === "settings") location.hash = "settings";
|
||
else location.hash = "";
|
||
if (p === "ops") loadOps();
|
||
if (p === "dash") refreshDash();
|
||
if (p === "settings") loadSettings();
|
||
}
|
||
if (location.pathname === "/ops-map" || location.hash === "#ops-map") state.page = "ops";
|
||
else if (location.pathname === "/settings" || location.hash === "#settings") state.page = "settings";
|
||
document.querySelectorAll(".nav a[data-page]").forEach(a => a.addEventListener("click", () => showPage(a.dataset.page)));
|
||
document.getElementById("nav-logout").addEventListener("click", async () => {
|
||
setToken(null);
|
||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||
showLogin();
|
||
});
|
||
document.getElementById("loginBtn").addEventListener("click", async () => {
|
||
const username = document.getElementById("loginUser").value.trim();
|
||
const password = document.getElementById("loginPass").value;
|
||
document.getElementById("loginErr").textContent = "";
|
||
try {
|
||
const r = await fetch("/api/auth/login", {
|
||
method: "POST", credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
if (!r.ok) throw new Error("用户名或密码错误");
|
||
const body = await r.json();
|
||
if (body.token) setToken(body.token);
|
||
showPage(state.page || "dash");
|
||
} catch (e) {
|
||
document.getElementById("loginErr").textContent = String(e.message || e);
|
||
}
|
||
});
|
||
|
||
async function loadSettings() {
|
||
document.getElementById("settingsErr").textContent = "";
|
||
document.getElementById("settingsOk").textContent = "";
|
||
try {
|
||
const d = await apiFetch("/api/settings/account").then(r => {
|
||
if (!r.ok) throw new Error("settings " + r.status);
|
||
return r.json();
|
||
});
|
||
document.getElementById("setUsername").value = d.username || "admin";
|
||
state.loadedUsername = d.username || "admin";
|
||
const hint = document.getElementById("settingsAuthHint");
|
||
const disabled = !d.auth_required;
|
||
hint.textContent = disabled ? "当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。" : "";
|
||
["setUsername","setCurPass","setNewPass","setNewPass2","settingsSaveBtn"].forEach(id => {
|
||
document.getElementById(id).disabled = disabled;
|
||
});
|
||
} catch (e) {
|
||
document.getElementById("settingsErr").textContent = String(e);
|
||
}
|
||
}
|
||
|
||
document.getElementById("settingsForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
document.getElementById("settingsErr").textContent = "";
|
||
document.getElementById("settingsOk").textContent = "";
|
||
const cur = document.getElementById("setCurPass").value;
|
||
const nu = document.getElementById("setUsername").value.trim();
|
||
const np = document.getElementById("setNewPass").value;
|
||
const np2 = document.getElementById("setNewPass2").value;
|
||
if (np && np !== np2) {
|
||
document.getElementById("settingsErr").textContent = "两次输入的新密码不一致";
|
||
return;
|
||
}
|
||
const body = { current_password: cur };
|
||
if (nu !== state.loadedUsername) body.new_username = nu;
|
||
if (np) body.new_password = np;
|
||
try {
|
||
const r = await apiFetch("/api/settings/account", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.detail || "save failed");
|
||
if (d.relogin_required) {
|
||
setToken(null);
|
||
document.getElementById("settingsOk").textContent = d.message || "已保存,请重新登录";
|
||
setTimeout(showLogin, 800);
|
||
return;
|
||
}
|
||
document.getElementById("settingsOk").textContent = d.message || "已保存";
|
||
document.getElementById("setCurPass").value = "";
|
||
document.getElementById("setNewPass").value = "";
|
||
document.getElementById("setNewPass2").value = "";
|
||
} catch (err) {
|
||
document.getElementById("settingsErr").textContent = String(err.message || err);
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => {
|
||
state.range = b.dataset.range;
|
||
document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b));
|
||
loadOps();
|
||
}));
|
||
document.querySelectorAll("#sideSeg button").forEach(b => b.addEventListener("click", () => {
|
||
state.side = b.dataset.side;
|
||
document.querySelectorAll("#sideSeg button").forEach(x => x.classList.toggle("active", x === b));
|
||
loadOps();
|
||
}));
|
||
document.querySelectorAll("#moveModeSeg button").forEach(b => b.addEventListener("click", () => {
|
||
state.moveMode = b.dataset.mode;
|
||
document.querySelectorAll("#moveModeSeg button").forEach(x => x.classList.toggle("active", x === b));
|
||
if (state.lastMov) drawMoveChart(state.lastMov.buckets || [], state.moveMode);
|
||
}));
|
||
document.getElementById("anchorDate").addEventListener("change", loadOps);
|
||
|
||
function drawChart(buckets, minLev) {
|
||
const svg = document.getElementById("levChart");
|
||
const W=880,H=260,padL=44,padR=12,padT=24,padB=36;
|
||
const innerW=W-padL-padR, innerH=H-padT-padB;
|
||
const vals = buckets.map(b => b.mean || 0);
|
||
const maxV = Math.max(minLev * 1.2, ...vals, 1);
|
||
const barW = innerW / Math.max(buckets.length, 1);
|
||
const yMin = padT + innerH * (1 - minLev / maxV);
|
||
let html = "";
|
||
html += `<line x1="${padL}" y1="${yMin}" x2="${W-padR}" y2="${yMin}" stroke="#e6a23c" stroke-dasharray="4 4"/>`;
|
||
html += `<text x="${W-padR-4}" y="${yMin-4}" fill="#e6a23c" font-size="10" text-anchor="end">min ${minLev}</text>`;
|
||
buckets.forEach((b,i) => {
|
||
const v = b.mean || 0;
|
||
const h = b.n > 0 ? (v / maxV) * innerH : 0;
|
||
const x = padL + i * barW + barW * 0.15;
|
||
const y = padT + innerH - h;
|
||
const w = barW * 0.7;
|
||
const fill = b.n === 0 ? "#243041" : (v >= minLev ? "#3ecf8e" : "#3d8fd1");
|
||
html += `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(h, b.n>0?2:0)}" fill="${fill}" rx="2"><title>${b.label}: mean=${fmt(b.mean)} n=${b.n}</title></rect>`;
|
||
if (i % 2 === 0) html += `<text x="${x+w/2}" y="${H-10}" fill="#8b9aab" font-size="9" text-anchor="middle">${String(b.label).replace(":00","")}</text>`;
|
||
});
|
||
html += `<line x1="${padL}" y1="${padT}" x2="${padL}" y2="${padT+innerH}" stroke="#243041"/>`;
|
||
html += `<line x1="${padL}" y1="${padT+innerH}" x2="${W-padR}" y2="${padT+innerH}" stroke="#243041"/>`;
|
||
html += `<text x="4" y="${padT+8}" fill="#8b9aab" font-size="10">${maxV.toFixed(0)}</text>`;
|
||
html += `<text x="4" y="${padT+innerH}" fill="#8b9aab" font-size="10">0</text>`;
|
||
svg.innerHTML = html;
|
||
}
|
||
|
||
function drawMoveChart(buckets, mode) {
|
||
const svg = document.getElementById("moveChart");
|
||
const W=880,H=260,padL=44,padR=12,padT=24,padB=36;
|
||
const innerW=W-padL-padR, innerH=H-padT-padB;
|
||
const vals = buckets.map(b => mode === "abs" ? (b.mean_abs||0) : (b.mean_signed||0));
|
||
const maxAbs = Math.max(...vals.map(v => Math.abs(v)), 1);
|
||
const y0 = mode === "signed" ? padT + innerH/2 : padT + innerH;
|
||
const scale = mode === "signed" ? (innerH/2)/maxAbs : innerH/maxAbs;
|
||
const barW = innerW / Math.max(buckets.length, 1);
|
||
let html = "";
|
||
if (mode === "signed") html += `<line x1="${padL}" y1="${y0}" x2="${W-padR}" y2="${y0}" stroke="#243041"/>`;
|
||
buckets.forEach((b,i) => {
|
||
const v = mode === "abs" ? (b.mean_abs||0) : (b.mean_signed||0);
|
||
const h = b.n > 0 ? Math.abs(v)*scale : 0;
|
||
const x = padL + i*barW + barW*0.15;
|
||
const y = mode === "signed" ? (v >= 0 ? y0 - h : y0) : y0 - h;
|
||
const w = barW*0.7;
|
||
const fill = b.n === 0 ? "#243041" : (mode === "abs" ? "#9b7bff" : (v >= 0 ? "#3ecf8e" : "#e85d5d"));
|
||
html += `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(h, b.n>0?2:0)}" fill="${fill}" rx="2"><title>${b.label}: abs=${fmt(b.mean_abs)} signed=${fmt(b.mean_signed)} n=${b.n}</title></rect>`;
|
||
if (i % 2 === 0) html += `<text x="${x+w/2}" y="${H-10}" fill="#8b9aab" font-size="9" text-anchor="middle">${String(b.label).replace(":00","")}</text>`;
|
||
});
|
||
html += `<line x1="${padL}" y1="${padT}" x2="${padL}" y2="${padT+innerH}" stroke="#243041"/>`;
|
||
html += `<line x1="${padL}" y1="${padT+innerH}" x2="${W-padR}" y2="${padT+innerH}" stroke="#243041"/>`;
|
||
html += `<text x="4" y="${padT+8}" fill="#8b9aab" font-size="10">${maxAbs.toFixed(0)}</text>`;
|
||
html += `<text x="4" y="${padT+innerH}" fill="#8b9aab" font-size="10">${mode==="signed"?"-"+maxAbs.toFixed(0):"0"}</text>`;
|
||
svg.innerHTML = html;
|
||
}
|
||
|
||
async function loadOps() {
|
||
const date = document.getElementById("anchorDate").value;
|
||
const q = new URLSearchParams({ range: state.range, side: state.side, bucket_minutes: "60", date });
|
||
try {
|
||
const d = await apiFetch("/api/stats/ops-map?" + q).then(r => {
|
||
if (!r.ok) throw new Error("ops-map " + r.status);
|
||
return r.json();
|
||
});
|
||
const lev = d.leverage || {};
|
||
document.getElementById("opsRange").textContent = (lev.start_ymd || "—") + " → " + (lev.end_ymd || "—");
|
||
document.getElementById("opsSamples").textContent = "杠杆样本 " + (lev.sample_count ?? 0);
|
||
document.getElementById("opsMin").textContent = lev.min_leverage ?? "—";
|
||
const titleMap = { day: "日", week: "近7日", month: "近30日" };
|
||
document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + ")";
|
||
drawChart(lev.buckets || [], lev.min_leverage || 100);
|
||
const mov = d.move_points || {};
|
||
state.lastMov = mov;
|
||
document.getElementById("opsMoveN").textContent = mov.settled_count ?? 0;
|
||
document.getElementById("opsPending").textContent = mov.pending_expiry
|
||
? ("pending " + (mov.pending_count ?? 0) + "(未到期已排除)")
|
||
: "全部已结算";
|
||
document.getElementById("pendingBadge").textContent = mov.pending_expiry ? "pending_expiry=true" : "";
|
||
document.getElementById("moveTitle").textContent =
|
||
"下图 · 时段→到期波动(" + (titleMap[state.range]||"") + " · " + state.moveMode + ")";
|
||
drawMoveChart(mov.buckets || [], state.moveMode);
|
||
document.getElementById("moveHint").textContent = mov.message || "";
|
||
} catch (e) {
|
||
document.getElementById("opsRange").textContent = "加载失败";
|
||
document.getElementById("moveHint").textContent = String(e);
|
||
}
|
||
}
|
||
|
||
async function refreshDash() {
|
||
try {
|
||
const [h, mRes] = await Promise.all([
|
||
fetch("/health").then(r => r.json()),
|
||
apiFetch("/api/meta/latest"),
|
||
]);
|
||
if (!mRes.ok) throw new Error("latest " + mRes.status);
|
||
const m = await mRes.json();
|
||
const lag = h.collector_lag_ms;
|
||
const ok = h.ok && (lag == null || lag < 120000) && (h.consecutive_failures || 0) < 5;
|
||
const st = document.getElementById("status");
|
||
st.textContent = ok ? "正常" : "异常/等待";
|
||
st.className = "value " + (ok ? "status-ok" : "status-warn");
|
||
document.getElementById("lag").textContent =
|
||
lag == null ? "尚无采样" : `延迟 ${Math.round(lag/1000)}s · 样本 ${h.option_quotes||0}`;
|
||
const call = m.call, put = m.put;
|
||
document.getElementById("callLev").textContent = fmt(call && call.leverage, 1);
|
||
document.getElementById("putLev").textContent = fmt(put && put.leverage, 1);
|
||
document.getElementById("callMeta").textContent = call ? `${call.inst_id} · ask ${fmt(call.ask, 4)}` : "—";
|
||
document.getElementById("putMeta").textContent = put ? `${put.inst_id} · ask ${fmt(put.ask, 4)}` : "—";
|
||
const meta = (m.heartbeat && m.heartbeat.meta) || {};
|
||
document.getElementById("indexPx").textContent = fmt(meta.index_px || (call && call.index_px), 2);
|
||
document.getElementById("expiry").textContent = meta.expiry_ymd
|
||
? `到期 ${meta.expiry_ymd} · 行权 ${meta.strike||"—"}` : "—";
|
||
document.getElementById("raw").textContent = JSON.stringify({ health: h, latest: m }, null, 2);
|
||
} catch (e) {
|
||
document.getElementById("status").textContent = "请求失败";
|
||
document.getElementById("status").className = "value status-bad";
|
||
document.getElementById("raw").textContent = String(e);
|
||
}
|
||
}
|
||
async function boot() {
|
||
try {
|
||
const st = await fetch("/api/auth/status").then(r => r.json());
|
||
state.authRequired = !!st.auth_required;
|
||
if (state.authRequired && !getToken()) {
|
||
showLogin();
|
||
return;
|
||
}
|
||
showPage(state.page || "dash");
|
||
} catch (e) {
|
||
document.getElementById("raw").textContent = String(e);
|
||
showPage("dash");
|
||
}
|
||
}
|
||
boot();
|
||
setInterval(() => {
|
||
if (state.page === "dash" && document.getElementById("page-login").classList.contains("hidden")) {
|
||
refreshDash();
|
||
}
|
||
}, 10000);
|
||
</script>
|
||
</body>
|
||
</html>
|