Files
dekun 270e114658 feat: show ops-map summaries as detailed period tables
Replace paragraph text with day-hit stats (days/days_hit/pct) so leverage and move charts list each hour with counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:12:23 +08:00

873 lines
46 KiB
HTML
Raw Permalink 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, .date-field select { 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; }
.chart-summary {
margin: 0.65rem 0 0;
padding: 0.75rem 0.9rem;
background: #0c1117;
border: 1px solid #243041;
border-radius: 8px;
color: var(--muted);
font-size: 0.88rem;
line-height: 1.55;
}
.chart-summary-table { padding: 0.65rem 0.75rem 0.75rem; }
.summary-note { margin-bottom: 0.55rem; color: var(--muted); font-size: 0.82rem; }
.summary-table-wrap { overflow-x: auto; }
.summary-table { width: 100%; border-collapse: collapse; font-size: 0.84rem; color: var(--text); }
.summary-table th, .summary-table td {
padding: 0.4rem 0.55rem; text-align: right; border-bottom: 1px solid #1c2734; white-space: nowrap;
}
.summary-table th:first-child, .summary-table td:first-child { text-align: left; }
.summary-table thead th { color: var(--muted); font-weight: 600; border-bottom-color: #2a3a4d; }
.summary-table tbody tr:last-child td { border-bottom: none; }
.summary-table tbody tr:hover td { background: rgba(255,255,255,0.03); }
.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="" autocomplete="username" placeholder="请输入用户名" />
</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>
<label class="date-field">距离到期 ≥
<select id="minHours" aria-label="距离到期最小小时数"></select>
</label>
<label class="date-field">达标线 ≥
<select id="minLeverage" aria-label="杠杆达标线"></select>
</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="chart-summary" id="levSummary"></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>
<div class="chart-summary" id="moveSummary"></div>
</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", minHours: 12, minLeverage: 100, 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 initMinHours() {
const sel = document.getElementById("minHours");
for (let h = 8; h <= 48; h += 2) {
const opt = document.createElement("option");
opt.value = String(h);
opt.textContent = h + "h";
if (h === state.minHours) opt.selected = true;
sel.appendChild(opt);
}
})();
(function initMinLeverage() {
const sel = document.getElementById("minLeverage");
for (let v = 90; v <= 300; v += 10) {
const opt = document.createElement("option");
opt.value = String(v);
opt.textContent = String(v);
if (v === state.minLeverage) opt.selected = true;
sel.appendChild(opt);
}
})();
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);
renderMoveTable(
document.getElementById("moveSummary"),
state.range, state.lastMov.buckets || [], {
pendingExpiry: !!state.lastMov.pending_expiry,
mode: state.moveMode,
apiMessage: state.lastMov.message || null,
}
);
}
}));
document.getElementById("anchorDate").addEventListener("change", loadOps);
document.getElementById("minHours").addEventListener("change", () => {
state.minHours = Number(document.getElementById("minHours").value) || 12;
loadOps();
});
document.getElementById("minLeverage").addEventListener("change", () => {
state.minLeverage = Number(document.getElementById("minLeverage").value) || 100;
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;
}
function fmtSummaryPct(p) { return Math.round(p * 100) + "%"; }
function fmtSummaryNum(n, d) {
d = d == null ? 1 : d;
return Number(n).toFixed(d);
}
function hitRate(b) {
if (b.pct_days_hit != null) return b.pct_days_hit;
return b.pct_ge_min;
}
function dayCount(b) {
if (typeof b.days === "number") return b.days;
return (b.n || 0) > 0 ? 1 : 0;
}
function daysHitCount(b, minLeverage) {
if (typeof b.days_hit === "number") return b.days_hit;
return (b.mean != null && b.mean >= minLeverage && (b.n || 0) > 0) ? 1 : 0;
}
function renderEmptySummary(el, message) {
el.className = "chart-summary";
el.textContent = message;
}
function renderLevTable(el, range, buckets, minLeverage) {
const active = (buckets || []).filter(b => (b.n || 0) > 0);
if (!active.length) {
renderEmptySummary(el, "所选范围内暂无杠杆样本,采集后将显示各时段分布。");
return;
}
const rows = active.slice().map(b => ({
label: b.label,
days: dayCount(b),
daysHit: daysHitCount(b, minLeverage),
pct: hitRate(b),
mean: b.mean,
n: b.n,
})).sort((a, b) => {
const dp = (b.pct ?? -1) - (a.pct ?? -1);
if (dp !== 0) return dp;
const dh = b.daysHit - a.daysHit;
if (dh !== 0) return dh;
return (b.mean || 0) - (a.mean || 0);
});
const windowName = range === "day" ? "今日" : (range === "week" ? "本周" : "本月");
const note = range === "day"
? (windowName + "各时段明细(达标 = 该时段均值 ≥ " + minLeverage + "")
: (windowName + "各时段明细(达标 = 当日该时段均值 ≥ " + minLeverage + ";概率 = 达标天数 ÷ 一共几天)");
let html = '<div class="summary-note">' + note + '</div><div class="summary-table-wrap"><table class="summary-table"><thead><tr>';
html += "<th>时段</th><th>一共几天</th><th>达标几天</th><th>达标概率</th><th>杠杆均值</th><th>样本数</th>";
html += "</tr></thead><tbody>";
rows.forEach(r => {
html += "<tr><td>" + r.label + "</td><td>" + r.days + "</td><td>" + r.daysHit + "</td><td>" +
(r.pct != null ? fmtSummaryPct(r.pct) : "—") + "</td><td>" +
(r.mean != null ? fmtSummaryNum(r.mean) : "—") + "</td><td>" + r.n + "</td></tr>";
});
html += "</tbody></table></div>";
el.className = "chart-summary chart-summary-table";
el.innerHTML = html;
}
function renderMoveTable(el, range, buckets, opts) {
opts = opts || {};
if (opts.pendingExpiry) {
const extra = opts.apiMessage ? (" " + opts.apiMessage) : "";
renderEmptySummary(el, ("未到期,暂无法统计时段→到期波动;结算后将按日/周/月给出各钟点明细。" + extra).trim());
return;
}
const mode = opts.mode || "abs";
const active = (buckets || []).filter(b => {
if ((b.n || 0) <= 0) return false;
return mode === "abs" ? b.mean_abs != null : b.mean_signed != null;
});
if (!active.length) {
renderEmptySummary(el, "所选范围内暂无已结算波动样本。");
return;
}
const windowName = range === "day" ? "今日" : (range === "week" ? "本周" : "本月");
const rows = active.slice().map(b => ({
label: b.label,
days: typeof b.days === "number" ? b.days : ((b.n || 0) > 0 ? 1 : 0),
n: b.n,
meanAbs: b.mean_abs,
meanSigned: b.mean_signed,
})).sort((a, b) => {
if (mode === "abs") return Math.abs(b.meanAbs || 0) - Math.abs(a.meanAbs || 0);
return Math.abs(b.meanSigned || 0) - Math.abs(a.meanSigned || 0);
});
const note = mode === "abs"
? (windowName + "各时段→到期绝对波动明细")
: (windowName + "各时段→到期带符号波动明细");
const colA = mode === "abs" ? "绝对波动均值" : "带符号均值";
const colB = mode === "abs" ? "带符号均值" : "绝对波动均值";
let html = '<div class="summary-note">' + note + '</div><div class="summary-table-wrap"><table class="summary-table"><thead><tr>';
html += "<th>时段</th><th>一共几天</th><th>样本数</th><th>" + colA + "</th><th>" + colB + "</th>";
html += "</tr></thead><tbody>";
rows.forEach(r => {
const a = mode === "abs"
? (r.meanAbs != null ? fmtSummaryNum(r.meanAbs, 2) : "—")
: (r.meanSigned != null ? fmtSummaryNum(r.meanSigned, 2) : "—");
const b = mode === "abs"
? (r.meanSigned != null ? fmtSummaryNum(r.meanSigned, 2) : "—")
: (r.meanAbs != null ? fmtSummaryNum(r.meanAbs, 2) : "—");
html += "<tr><td>" + r.label + "</td><td>" + r.days + "</td><td>" + r.n + "</td><td>" + a + "</td><td>" + b + "</td></tr>";
});
html += "</tbody></table></div>";
el.className = "chart-summary chart-summary-table";
el.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,
min_hours: String(state.minHours || 12),
min_leverage: String(state.minLeverage || 100),
});
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 || "—");
const filt = d.expiry_filter || lev.expiry_filter || {};
const rawN = filt.raw_sample_count;
const keptN = filt.filtered_sample_count != null ? filt.filtered_sample_count : (lev.sample_count ?? 0);
let sampleHint = "杠杆样本 " + keptN;
if (rawN != null) {
const hMin = filt.raw_hours_min;
const hMax = filt.raw_hours_max;
const span = (hMin != null && hMax != null)
? (" · 样本距到期约 " + Math.floor(hMin) + "" + Math.ceil(hMax) + "h")
: "";
if (rawN === keptN) {
sampleHint = "杠杆样本 " + keptN + "(≥" + (state.minHours || 12) + "h 未减少)" + span;
} else {
sampleHint = "杠杆样本 " + keptN + "/" + rawN + "(≥" + (state.minHours || 12) + "h" + span;
}
}
document.getElementById("opsSamples").textContent = sampleHint;
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);
renderLevTable(
document.getElementById("levSummary"),
state.range, 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);
renderMoveTable(
document.getElementById("moveSummary"),
state.range, mov.buckets || [], {
pendingExpiry: !!mov.pending_expiry,
mode: state.moveMode,
apiMessage: mov.message || null,
}
);
} catch (e) {
document.getElementById("opsRange").textContent = "加载失败";
renderEmptySummary(document.getElementById("levSummary"), String(e));
renderEmptySummary(document.getElementById("moveSummary"), 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>