Files
market_intel/web/dist/index.html
T
2026-08-02 09:23:08 +08:00

666 lines
37 KiB
HTML
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.
<!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);
}
.layout { max-width: 980px; margin: 0 auto; padding: 1.5rem 1.5rem 3rem; }
header { padding-bottom: 0.75rem; border-bottom: 1px solid #243041; margin-bottom: 0.75rem; }
.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; flex-wrap: wrap; margin-bottom: 1.25rem; }
.nav a { color: var(--muted); text-decoration: none; cursor: pointer; }
.nav a.active, .nav a:hover { color: var(--accent); }
main { padding: 0; }
.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%; }
.login-card { width: 100%; max-width: 420px; }
.settings-form { max-width: 520px; }
.settings-title { font-size: 1.15rem; font-weight: 650; margin: 0.25rem 0 0.85rem; }
.settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; }
.settings-tabs { display: flex; border-bottom: 1px solid #243041; margin: -0.25rem -0.15rem 1.1rem; }
.settings-tabs button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.65rem 0.95rem; cursor: pointer; font-size: 0.92rem; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.settings-tabs button.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
.auto-box { background: #0c1117; border: 1px solid #243041; border-radius: 8px; padding: 0.85rem 0.95rem; margin-bottom: 1rem; }
.check-row { display: flex; align-items: center; gap: 0.55rem; margin-bottom: 0.75rem; color: var(--text); font-size: 0.92rem; }
.check-row input { width: auto; accent-color: var(--accent); }
.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; }
.btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
.btn-danger { padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: #c45656; color: #fff; cursor: pointer; }
.btn-danger: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>
<div class="layout">
<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 login-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">
<div class="tile settings-card">
<div class="settings-tabs">
<button type="button" id="tabAccount" class="active" data-tab="account">账户密码</button>
<button type="button" id="tabBackup" data-tab="backup">数据备份与恢复</button>
</div>
<div id="panelAccount" class="settings-form">
<form id="settingsForm">
<div class="settings-title">账户密码</div>
<p id="settingsAuthHint" class="settings-hint"></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>
<div id="panelBackup" class="settings-form hidden">
<div class="settings-title">数据备份与恢复</div>
<p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span><br/>列表仅显示最近 5 条。</p>
<p id="backupDirErr" class="form-err hidden"></p>
<div class="auto-box">
<label class="check-row">
<input type="checkbox" id="autoEnabled" checked />
<span>启用自动备份</span>
</label>
<label class="field"><span class="label">备份间隔(小时)</span>
<input id="autoInterval" type="number" min="1" max="168" value="24" class="field-input" />
</label>
<p class="settings-hint" style="margin-bottom:0" id="autoMeta">最近备份:—</p>
</div>
<label class="field"><span class="label">当前密码(操作需验证)</span>
<input id="backupPass" type="password" class="field-input" autocomplete="current-password" />
</label>
<label class="field"><span class="label">选择备份(最近 5 条)</span>
<select id="backupSelect" class="field-input"><option value="">尚无备份</option></select>
</label>
<p id="backupErr" class="form-err"></p>
<p id="backupOk" class="form-ok"></p>
<div class="btn-row">
<button type="button" class="btn-primary" id="autoSaveBtn">保存自动备份</button>
<button type="button" class="btn-primary" id="backupBtn">立即备份</button>
<button type="button" class="btn-primary" id="downloadBtn">下载备份</button>
<button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<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);
}
});
function fmtBytes(n) {
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
return (n / 1048576).toFixed(2) + " MB";
}
function fmtBackupTime(ms) {
try { return new Date(ms).toLocaleString("zh-CN", { hour12: false }); } catch { return "—"; }
}
function renderBackupSelect(backups) {
const sel = document.getElementById("backupSelect");
sel.innerHTML = "";
if (!backups || !backups.length) {
sel.innerHTML = '<option value="">尚无备份</option>';
return;
}
backups.forEach(b => {
const opt = document.createElement("option");
opt.value = b.name;
opt.textContent = b.name + " · " + fmtBytes(b.size_bytes) + " · " + fmtBackupTime(b.modified_ms);
sel.appendChild(opt);
});
}
function showSettingsTab(tab) {
const isAccount = tab === "account";
document.getElementById("panelAccount").classList.toggle("hidden", !isAccount);
document.getElementById("panelBackup").classList.toggle("hidden", isAccount);
document.getElementById("tabAccount").classList.toggle("active", isAccount);
document.getElementById("tabBackup").classList.toggle("active", !isAccount);
}
document.getElementById("tabAccount").addEventListener("click", () => showSettingsTab("account"));
document.getElementById("tabBackup").addEventListener("click", () => showSettingsTab("backup"));
async function loadBackupInfo() {
document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = "";
const r = await apiFetch("/api/settings/backup");
if (!r.ok) throw new Error("backup info " + r.status);
const info = await r.json();
state.backupWritable = !!info.writable;
document.getElementById("backupDir").textContent = info.backup_dir || "/root/market_intel_backups";
const dirErr = document.getElementById("backupDirErr");
if (!info.writable) {
dirErr.textContent = "备份目录不可写,请检查 /root/market_intel_backups 权限与 Docker 挂载";
dirErr.classList.remove("hidden");
} else {
dirErr.classList.add("hidden");
}
document.getElementById("autoEnabled").checked = !!info.auto_enabled;
document.getElementById("autoInterval").value = info.interval_hours || 24;
let meta = "最近备份:" + (info.latest_backup_ms ? fmtBackupTime(info.latest_backup_ms) : "—");
if (info.auto_enabled) meta += " · 下次约:" + (info.next_due_ms ? fmtBackupTime(info.next_due_ms) : "即将");
document.getElementById("autoMeta").textContent = meta;
renderBackupSelect(info.backups || []);
const noAuth = !state.authRequired;
const noWrite = !info.writable;
document.getElementById("backupPass").disabled = noAuth;
document.getElementById("backupSelect").disabled = noAuth;
document.getElementById("autoEnabled").disabled = noAuth;
document.getElementById("autoInterval").disabled = noAuth || !info.auto_enabled;
document.getElementById("autoSaveBtn").disabled = noAuth;
document.getElementById("backupBtn").disabled = noAuth || noWrite;
document.getElementById("downloadBtn").disabled = noAuth;
document.getElementById("restoreBtn").disabled = noAuth || noWrite;
}
document.getElementById("autoEnabled").addEventListener("change", () => {
document.getElementById("autoInterval").disabled = !document.getElementById("autoEnabled").checked || !state.authRequired;
});
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;
});
await loadBackupInfo();
} 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.getElementById("autoSaveBtn").addEventListener("click", async () => {
const pwd = document.getElementById("backupPass").value;
document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = "";
if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; }
try {
const r = await apiFetch("/api/settings/backup/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_password: pwd,
enabled: document.getElementById("autoEnabled").checked,
interval_hours: Number(document.getElementById("autoInterval").value) || 24,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || "save failed");
document.getElementById("backupOk").textContent = d.message || "自动备份设置已保存";
await loadBackupInfo();
} catch (err) {
document.getElementById("backupErr").textContent = String(err.message || err);
}
});
document.getElementById("backupBtn").addEventListener("click", async () => {
const pwd = document.getElementById("backupPass").value;
document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = "";
if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; }
try {
const r = await apiFetch("/api/settings/backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: pwd }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || "backup failed");
document.getElementById("backupOk").textContent = (d.message || "备份完成") + "" + d.name;
document.getElementById("backupPass").value = "";
await loadBackupInfo();
} catch (err) {
document.getElementById("backupErr").textContent = String(err.message || err);
}
});
document.getElementById("downloadBtn").addEventListener("click", async () => {
const name = document.getElementById("backupSelect").value;
document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = "";
if (!name) { document.getElementById("backupErr").textContent = "请选择要下载的备份"; return; }
try {
const r = await apiFetch("/api/settings/backup/download/" + encodeURIComponent(name));
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || "download failed");
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
document.getElementById("backupOk").textContent = "已开始下载:" + name;
} catch (err) {
document.getElementById("backupErr").textContent = String(err.message || err);
}
});
document.getElementById("restoreBtn").addEventListener("click", async () => {
const pwd = document.getElementById("backupPass").value;
const name = document.getElementById("backupSelect").value;
document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = "";
if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; }
if (!name) { document.getElementById("backupErr").textContent = "请选择要恢复的备份"; return; }
if (!confirm("确认从 " + name + " 恢复数据库?当前数据将被覆盖。")) return;
try {
const r = await apiFetch("/api/settings/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: pwd, backup_name: name }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || "restore failed");
document.getElementById("backupOk").textContent = d.message || "恢复完成";
document.getElementById("backupPass").value = "";
} catch (err) {
document.getElementById("backupErr").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>