coinpush / app /core /dashboard.py
zt p
Add core asset candlestick chart
a4999bf
Raw History Blame Contribute Delete
86.7 kB
def render_dashboard():
"""Single-file operational dashboard rendered by FastAPI."""
return """
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/icons/icon.svg">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#111827">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="监控平台">
<title>监控平台</title>
<style>
:root {
color-scheme: light;
--bg: #f3f5f8;
--panel: #ffffff;
--soft: #f7f8fa;
--soft-2: #eef1f5;
--text: #111827;
--muted: #667085;
--line: #d9dee7;
--ok: #067647;
--ok-bg: #ecfdf3;
--warn: #b54708;
--warn-bg: #fffaeb;
--err: #b42318;
--err-bg: #fef3f2;
--run: #175cd3;
--run-bg: #eff6ff;
--focus: #2563eb;
--ink: #111827;
--amber: #f59e0b;
--teal: #0f766e;
--rose: #be123c;
--shadow: 0 18px 45px rgba(17, 24, 39, .10);
--shadow-soft: 0 1px 2px rgba(17, 24, 39, .06);
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
linear-gradient(180deg, #fbfcfd 0%, var(--bg) 18rem);
color: var(--text);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, .94);
backdrop-filter: blur(14px);
position: sticky;
top: 0;
z-index: 5;
}
h1 { margin: 0; font-size: 19px; letter-spacing: 0; }
h2, h3 { letter-spacing: 0; }
main {
display: flex;
flex-direction: column;
padding: 20px 24px 40px;
max-width: 1540px;
margin: 0 auto;
}
.app-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.app-subtitle { color: var(--muted); font-size: 12px; }
section {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 18px;
overflow: hidden;
box-shadow: var(--shadow-soft);
}
section > .section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #ffffff 0%, var(--soft) 100%);
}
section h2 { margin: 0; font-size: 15px; font-weight: 750; }
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.metric {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
min-height: 82px;
box-shadow: var(--shadow-soft);
}
.metric span { color: var(--muted); display: block; font-size: 12px; }
.metric strong { display: block; font-size: 24px; margin-top: 5px; letter-spacing: 0; }
.market-hero {
background: #111827;
color: #f9fafb;
border-color: #111827;
box-shadow: var(--shadow);
}
.market-hero > .section-head {
background: transparent;
border-color: rgba(255, 255, 255, .12);
color: #f9fafb;
}
.market-hero .muted { color: #cbd5e1; }
.core-market {
display: grid;
grid-template-columns: minmax(280px, 1.15fr) minmax(320px, 1.85fr);
gap: 0;
}
.core-price-panel {
padding: 22px;
border-right: 1px solid rgba(255, 255, 255, .12);
min-width: 0;
}
.asset-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.asset-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, .16);
background: rgba(255, 255, 255, .08);
color: #f9fafb;
padding: 4px 10px;
font-size: 12px;
font-weight: 750;
}
.asset-source {
color: #cbd5e1;
font-size: 12px;
overflow-wrap: anywhere;
}
.core-price {
font-size: clamp(34px, 5vw, 56px);
line-height: 1;
font-weight: 820;
letter-spacing: 0;
margin: 6px 0 12px;
}
.price-subline {
display: flex;
gap: 8px;
flex-wrap: wrap;
color: #cbd5e1;
font-size: 12px;
}
.change-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-weight: 750;
background: rgba(255, 255, 255, .08);
color: #e5e7eb;
}
.change-chip.up { color: #bbf7d0; background: rgba(22, 163, 74, .16); }
.change-chip.down { color: #fecdd3; background: rgba(225, 29, 72, .16); }
.market-detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
background: rgba(255, 255, 255, .10);
min-width: 0;
}
.market-tile {
background: rgba(255, 255, 255, .055);
padding: 16px;
min-height: 102px;
min-width: 0;
}
.market-tile span {
display: block;
color: #cbd5e1;
font-size: 12px;
margin-bottom: 6px;
}
.market-tile strong {
display: block;
color: #fff;
font-size: 21px;
line-height: 1.18;
overflow-wrap: anywhere;
}
.market-tile small {
display: block;
color: #cbd5e1;
margin-top: 7px;
font-size: 12px;
overflow-wrap: anywhere;
}
.core-chart {
padding: 18px 20px 20px;
border-top: 1px solid rgba(255, 255, 255, .12);
}
.core-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.core-chart-title h3 {
margin: 0;
font-size: 16px;
font-weight: 750;
}
.core-chart-title small {
display: block;
margin-top: 4px;
color: #cbd5e1;
font-size: 12px;
}
.chart-controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.chart-pill {
min-height: 30px;
padding: 5px 10px;
border: 1px solid rgba(255, 255, 255, .16);
border-radius: 999px;
background: rgba(255, 255, 255, .07);
color: #e5e7eb;
cursor: pointer;
box-shadow: none;
}
.chart-pill:hover { border-color: rgba(255, 255, 255, .30); background: rgba(255, 255, 255, .11); }
.chart-pill.active {
border-color: rgba(45, 212, 191, .55);
background: rgba(45, 212, 191, .14);
color: #99f6e4;
font-weight: 750;
}
.chart-shell {
position: relative;
height: 330px;
border: 1px solid rgba(255, 255, 255, .10);
border-radius: 8px;
background: linear-gradient(180deg, rgba(255,255,255,.045) 0%, rgba(255,255,255,.015) 100%);
}
#coreCandleChart { display: block; width: 100%; height: 100%; cursor: crosshair; }
.chart-tooltip {
position: absolute;
z-index: 3;
display: none;
min-width: 168px;
padding: 9px 10px;
border: 1px solid rgba(255, 255, 255, .16);
border-radius: 7px;
background: rgba(17, 24, 39, .95);
color: #f9fafb;
font-size: 12px;
line-height: 1.45;
pointer-events: none;
box-shadow: 0 10px 24px rgba(0, 0, 0, .28);
}
.chart-message {
min-height: 20px;
margin-top: 9px;
color: #cbd5e1;
font-size: 12px;
}
.chart-message.error { color: #fda4af; }
.chart-tooltip { white-space: pre-line; }
.btc-reference {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
margin-top: 18px;
padding: 13px 14px;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 8px;
background: rgba(255, 255, 255, .06);
}
.btc-reference span {
color: #cbd5e1;
font-size: 12px;
display: block;
}
.btc-reference strong {
color: #fff;
font-size: 20px;
white-space: nowrap;
}
.strategy-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 14px;
}
.strategy-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
min-height: 118px;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.strategy-card span {
color: var(--muted);
display: block;
font-size: 12px;
}
.strategy-card strong {
display: block;
font-size: 28px;
margin: 4px 0 8px;
}
.strategy-meta {
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.table-shell {
max-height: 560px;
overflow: auto;
scrollbar-gutter: stable;
}
.table-shell.compact { max-height: 360px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
th, td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
overflow-wrap: normal;
}
th {
color: var(--muted);
font-size: 12px;
font-weight: 650;
background: var(--soft);
position: sticky;
top: 0;
z-index: 1;
}
tbody tr { background: #fff; }
tbody tr:hover { background: #fbfdff; }
tr:last-child td { border-bottom: 0; }
.status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
border-radius: 999px;
padding: 3px 9px;
font-size: 12px;
font-weight: 750;
border: 1px solid transparent;
}
.status.ok { color: var(--ok); background: var(--ok-bg); border-color: #bbf7d0; }
.status.error { color: var(--err); background: var(--err-bg); border-color: #fecaca; }
.status.running { color: var(--run); background: var(--run-bg); border-color: #bfdbfe; }
.status.unknown { color: var(--warn); background: var(--warn-bg); border-color: #fde68a; }
.status.paused, .status.disabled { color: var(--muted); background: #f2f4f7; border-color: #e4e7ec; }
.monitor-name { font-weight: 750; }
.monitor-id { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; }
.type-badge {
display: inline-flex;
margin-top: 7px;
color: #475467;
background: #f2f4f7;
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
}
.state-stack { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.failure-note { color: var(--muted); font-size: 12px; }
.muted { color: var(--muted); }
.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
button {
border: 1px solid var(--line);
background: #fff;
border-radius: 7px;
padding: 7px 10px;
cursor: pointer;
min-height: 34px;
box-shadow: var(--shadow-soft);
}
button:hover { border-color: #aeb8c6; background: #fbfdff; }
button.primary { background: #111827; color: #fff; border-color: #111827; }
button.primary:hover { background: #1f2937; }
button.danger { color: var(--err); }
button.small { padding: 5px 8px; min-height: 30px; }
.icon-button {
width: 36px;
min-width: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 1;
}
pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
details summary { cursor: pointer; color: var(--run); font-size: 12px; margin-top: 8px; }
.value-card {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 100%;
}
.value-summary {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-width: 0;
}
.kv-chip {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
border: 1px solid #e4e7ec;
border-radius: 999px;
background: #f8fafc;
color: #344054;
padding: 3px 8px;
font-size: 12px;
line-height: 1.35;
}
.kv-chip b { color: var(--text); font-weight: 750; }
.value-json {
max-height: 180px;
overflow: auto;
padding: 9px;
border: 1px solid var(--line);
border-radius: 7px;
background: #0f172a;
color: #e5e7eb;
overflow-wrap: anywhere;
}
.error-box {
border: 1px solid #fecaca;
border-radius: 7px;
background: var(--err-bg);
color: var(--err);
padding: 8px 10px;
max-height: 130px;
overflow: auto;
overflow-wrap: anywhere;
}
.stale-note {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.log-snippet {
max-height: 120px;
overflow: auto;
padding: 8px;
border-radius: 7px;
background: #f8fafc;
border: 1px solid #e4e7ec;
overflow-wrap: anywhere;
}
textarea {
width: 100%;
min-height: 320px;
border: 1px solid var(--line);
border-radius: 7px;
padding: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
resize: vertical;
outline: none;
}
input, select {
width: 100%;
min-height: 34px;
border: 1px solid var(--line);
border-radius: 7px;
padding: 7px 8px;
background: #fff;
color: var(--text);
outline: none;
}
input:focus, select:focus, textarea:focus { border-color: var(--focus); box-shadow: 0 0 0 2px rgba(37, 99, 235, .12); }
label {
display: block;
color: var(--muted);
font-size: 12px;
margin-bottom: 5px;
}
.switch-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
}
.switch-row.pause-cell { min-height: 0; margin: 0; }
.switch-row input { width: auto; min-height: auto; }
.message { color: var(--muted); }
.message.error { color: var(--err); }
.message.ok { color: var(--ok); }
.hint {
color: var(--muted);
font-size: 12px;
margin: 8px 0 0;
}
.config-shell {
display: grid;
grid-template-columns: 230px minmax(0, 1fr);
min-height: 0;
max-height: 720px;
}
.config-section { order: 20; }
.health-section { order: 2; }
.alerts-section { order: 3; }
.events-section { order: 4; }
.config-nav {
border-right: 1px solid var(--line);
background: var(--soft);
padding: 10px;
max-height: 720px;
overflow: auto;
}
.nav-button {
display: block;
width: 100%;
text-align: left;
margin-bottom: 6px;
background: transparent;
}
.nav-button.active {
background: #eaf1ff;
border-color: #bfd2ff;
color: #123f8c;
font-weight: 700;
}
.config-panel {
padding: 14px;
max-height: 720px;
overflow: auto;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.panel-title h3 { margin: 0; font-size: 16px; }
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field {
min-width: 0;
background: #fff;
}
.group {
border: 1px solid var(--line);
border-radius: 6px;
margin-bottom: 14px;
overflow: hidden;
}
.group h4 {
margin: 0;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: var(--soft);
font-size: 13px;
}
.group-body { padding: 12px; }
.inline-table input, .inline-table select { min-height: 30px; padding: 5px 7px; }
.tab-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px;
background: #fff;
cursor: pointer;
}
.pill.active {
border-color: #bfd2ff;
background: #eaf1ff;
color: #123f8c;
font-weight: 700;
}
.hidden { display: none; }
.section-actions { padding: 10px 12px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.compact-json { padding: 12px; }
@media (max-width: 1100px) {
.summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.core-market { grid-template-columns: 1fr; }
.core-price-panel { border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .12); }
.market-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.strategy-grid { grid-template-columns: 1fr; }
.core-chart { padding: 16px; }
.chart-shell { height: 300px; }
.config-shell { grid-template-columns: 1fr; }
.config-nav { border-right: 0; border-bottom: 1px solid var(--line); display: flex; gap: 8px; overflow-x: auto; }
.config-panel { max-height: 680px; }
.nav-button { min-width: 130px; margin-bottom: 0; }
.form-grid, .form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.table-shell { max-height: 520px; }
}
@media (max-width: 720px) {
header { align-items: flex-start; flex-direction: column; }
main { padding: 14px; }
.summary, .form-grid, .form-grid.two { grid-template-columns: 1fr; }
.market-detail-grid { grid-template-columns: 1fr; }
.core-price-panel { padding: 18px; }
.market-tile { min-height: 86px; }
.core-chart { padding: 14px; }
.chart-shell { height: 250px; }
.btc-reference { grid-template-columns: 1fr; }
.btc-reference strong { white-space: normal; }
.strategy-grid { grid-template-columns: 1fr; }
.table-shell { max-height: 620px; overflow: auto; }
.table-shell.compact { max-height: 360px; }
table, thead, tbody, th, td, tr { display: block; width: 100%; }
thead { display: none; }
tbody tr {
border-bottom: 1px solid var(--line);
padding: 10px 12px;
}
td {
border-bottom: 0;
padding: 6px 0;
}
td[data-label]::before {
content: attr(data-label);
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 3px;
}
}
</style>
</head>
<body>
<header>
<div class="app-title">
<h1>监控平台</h1>
<div class="app-subtitle">价格、财务指标和脚本健康统一监控</div>
</div>
<div class="toolbar">
<span id="updated" class="muted">等待刷新</span>
<button class="icon-button" onclick="loadStatus()" title="刷新状态" aria-label="刷新状态">↻</button>
<button class="icon-button primary" onclick="saveFormConfig()" title="保存参数" aria-label="保存参数">✓</button>
<span id="topMessage" class="message"></span>
</div>
</header>
<main>
<div class="summary">
<div class="metric"><span>平台状态</span><strong id="platformState">--</strong></div>
<div class="metric"><span>监控项</span><strong id="monitorCount">--</strong></div>
<div class="metric"><span>异常项</span><strong id="errorCount">--</strong></div>
<div class="metric"><span>累计告警</span><strong id="alertCount">--</strong></div>
</div>
<section class="market-hero">
<div class="section-head">
<h2>核心资产</h2>
<span id="coreUpdated" class="muted">等待行情</span>
</div>
<div class="core-market">
<div class="core-price-panel">
<div class="asset-line">
<span id="coreName" class="asset-badge">GRAM</span>
<span id="coreSource" class="asset-source">Binance</span>
</div>
<div id="corePrice" class="core-price">--</div>
<div class="price-subline">
<span id="coreChange1m" class="change-chip">1m --</span>
<span id="coreChange5m" class="change-chip">5m --</span>
<span id="coreChange1h" class="change-chip">1h --</span>
</div>
<div class="btc-reference">
<div>
<span>BTC 参照价格</span>
<small id="btcMeta" class="muted">等待数据</small>
</div>
<strong id="btcPrice">--</strong>
</div>
</div>
<div class="market-detail-grid">
<div class="market-tile">
<span>聚合 24h 成交额</span>
<strong id="coreVolume24h">--</strong>
<small id="coreVolume5m">近 5m --</small>
</div>
<div class="market-tile">
<span>24h 区间</span>
<strong id="coreRange24h">--</strong>
<small id="coreRsi">RSI --</small>
</div>
<div class="market-tile">
<span>资金费率</span>
<strong id="coreFunding">--</strong>
<small id="coreFuturesSymbol">合约未启用</small>
</div>
<div class="market-tile">
<span>聚合合约持仓量</span>
<strong id="coreOpenInterest">--</strong>
<small id="coreOpenInterestChange">变化 --</small>
</div>
<div class="market-tile">
<span>资金异动</span>
<strong id="coreFlow">--</strong>
<small id="coreFlowMeta">等待样本</small>
</div>
<div class="market-tile">
<span>数据覆盖</span>
<strong id="coreCoverage">--</strong>
<small id="coreCoverageMeta">等待来源</small>
</div>
</div>
</div>
<div class="core-chart">
<div class="core-chart-head">
<div class="core-chart-title">
<h3 id="coreChartTitle">核心资产 K线</h3>
<small id="coreChartMeta">等待行情数据</small>
</div>
<div class="chart-controls" id="coreChartControls">
<button class="chart-pill" data-chart-interval="1m">1m</button>
<button class="chart-pill" data-chart-interval="15m">15m</button>
<button class="chart-pill active" data-chart-interval="1h">1h</button>
<button class="chart-pill" data-chart-interval="4h">4h</button>
<button class="chart-pill" data-chart-interval="1d">1d</button>
</div>
</div>
<div class="chart-shell">
<canvas id="coreCandleChart"></canvas>
<div id="coreChartTooltip" class="chart-tooltip"></div>
</div>
<div id="coreChartMessage" class="chart-message">正在加载K线...</div>
</div>
</section>
<section>
<div class="section-head">
<h2>Strategy 财务监控</h2>
<span class="muted">仅解析最新 8-K;获取失败会在这里和每日健康报告中显示</span>
</div>
<div class="strategy-grid">
<div class="strategy-card">
<span>BTC 持仓</span>
<strong id="strategyBtcValue">--</strong>
<div id="strategyBtcMeta" class="strategy-meta">等待数据</div>
</div>
<div class="strategy-card">
<span>USD Reserve / 现金储备</span>
<strong id="strategyCashValue">--</strong>
<div id="strategyCashMeta" class="strategy-meta">等待数据</div>
</div>
</div>
</section>
<section class="config-section">
<div class="section-head">
<h2>参数设置</h2>
<div class="toolbar">
<button onclick="loadConfig()">重新加载</button>
<button onclick="calibrateSelected()">校准当前币种</button>
<button onclick="calibrateAll()">校准全部</button>
</div>
</div>
<div class="config-shell">
<aside class="config-nav">
<button class="nav-button active" data-panel="global" onclick="showPanel('global')">全局设置</button>
<button class="nav-button" data-panel="coins" onclick="showPanel('coins')">币种参数</button>
<button class="nav-button" data-panel="exchanges" onclick="showPanel('exchanges')">跨所差价</button>
<button class="nav-button" data-panel="advanced" onclick="showPanel('advanced')">高级 JSON</button>
</aside>
<div class="config-panel">
<div id="panel-global">
<div class="panel-title">
<h3>全局设置</h3>
<span class="muted">影响所有监控项</span>
</div>
<div class="form-grid">
<div class="field">
<label for="global_poll_interval_sec">轮询间隔(秒)</label>
<input id="global_poll_interval_sec" type="number" min="5" step="1">
</div>
<div class="field">
<label for="global_daily_report_hour">日报小时(北京时间)</label>
<input id="global_daily_report_hour" type="number" min="0" max="23" step="1">
</div>
<div class="field">
<label for="global_alert_sensitivity">通知灵敏度</label>
<input id="global_alert_sensitivity" type="number" min="0.1" step="0.1">
</div>
<div class="field">
<label for="global_alert_strictness">阈值严格度</label>
<input id="global_alert_strictness" type="number" min="0.5" step="0.1">
</div>
<div class="field">
<label for="global_calibration_mode">校准模式</label>
<select id="global_calibration_mode">
<option value="auto">自动校准</option>
<option value="manual">手动阈值</option>
</select>
</div>
<div class="field">
<label for="global_auto_calibrate_hours">自动校准间隔(小时)</label>
<input id="global_auto_calibrate_hours" type="number" min="0" step="0.5">
</div>
<div class="field">
<label>下跌优先推送</label>
<div class="switch-row">
<input id="global_down_priority" type="checkbox">
<span>启用</span>
</div>
</div>
</div>
</div>
<div id="panel-coins" class="hidden">
<div class="panel-title">
<h3>币种参数</h3>
<div class="toolbar">
<select id="coinSelector" onchange="renderCoinForm()"></select>
<button onclick="addCoin()">新增币种</button>
<button onclick="addAlphaCoin()">新增 Alpha</button>
<button class="danger" onclick="deleteCoin()">删除当前</button>
</div>
</div>
<div class="group">
<h4>基础信息</h4>
<div class="group-body">
<div class="form-grid">
<div class="field">
<label for="coin_symbol">交易标的</label>
<input id="coin_symbol" data-coin-key="symbol">
</div>
<div class="field">
<label for="coin_futures_symbol">合约交易对</label>
<input id="coin_futures_symbol" data-coin-key="futures_symbol">
</div>
<div class="field">
<label for="coin_data_source">数据来源</label>
<select id="coin_data_source" data-coin-key="data_source" onchange="handleCoinSourceChange()">
<option value="binance">Binance 现货</option>
<option value="binance_futures">Binance 合约</option>
<option value="binance_alpha">Binance Alpha</option>
<option value="okx">OKX</option>
</select>
</div>
<div class="field">
<label>合约监控</label>
<div class="switch-row">
<input id="coin_enable_futures" type="checkbox" data-coin-key="enable_futures">
<span>启用</span>
</div>
</div>
<div class="field">
<label>首页核心展示</label>
<div class="switch-row">
<input id="coin_core_display" type="checkbox" data-coin-key="core_display">
<span>设为核心</span>
</div>
</div>
<div class="field">
<label>股票类休市判断</label>
<div class="switch-row">
<input id="coin_market_close" type="checkbox" data-coin-key="market_close">
<span>启用</span>
</div>
</div>
<div class="field">
<label for="coin_market_close_static_secs">休市静止秒数</label>
<input id="coin_market_close_static_secs" type="number" min="60" step="60" data-coin-key="market_close_static_secs">
</div>
</div>
<p class="hint">Binance Alpha 标的可填 DATAIP、ALPHA_1011 或 ALPHA_1011USDT;如该币也有 U 本位合约,可填写合约交易对并打开合约监控。</p>
</div>
</div>
<div class="group">
<h4>关键价位</h4>
<div class="group-body">
<div class="form-grid">
<div class="field"><label>成本价</label><input type="number" step="0.0001" data-coin-key="cost_price"></div>
<div class="field"><label>目标价</label><input type="number" step="0.0001" data-coin-key="target_price"></div>
<div class="field"><label>止损价</label><input type="number" step="0.0001" data-coin-key="stop_price"></div>
<div class="field"><label>接近关键价阈值 %</label><input type="number" step="0.1" data-coin-key="near_key_pct"></div>
<div class="field"><label>接近成本阈值 %</label><input type="number" step="0.1" data-coin-key="near_cost_pct"></div>
</div>
</div>
</div>
<div class="group">
<h4>快速涨跌阈值 %</h4>
<div class="group-body">
<div class="form-grid">
<div class="field"><label>1 分钟</label><input type="number" step="0.01" data-price-window="1m"></div>
<div class="field"><label>5 分钟</label><input type="number" step="0.01" data-price-window="5m"></div>
<div class="field"><label>15 分钟</label><input type="number" step="0.01" data-price-window="15m"></div>
<div class="field"><label>1 小时</label><input type="number" step="0.01" data-price-window="1h"></div>
</div>
</div>
</div>
<div class="group">
<h4>突破、成交量、振幅</h4>
<div class="group-body">
<div class="form-grid">
<div class="field"><label>突破 24h 高低 %</label><input type="number" step="0.1" data-coin-key="break_24h_pct"></div>
<div class="field"><label>突破维持秒数</label><input type="number" step="1" data-coin-key="break_24h_hold_sec"></div>
<div class="field"><label>突破 7 日高低 %</label><input type="number" step="0.1" data-coin-key="break_7d_pct"></div>
<div class="field"><label>放量倍数</label><input type="number" step="0.1" data-coin-key="vol_anomaly_mult"></div>
<div class="field"><label>严重放量倍数</label><input type="number" step="0.1" data-coin-key="vol_severe_mult"></div>
<div class="field"><label>5m 振幅 %</label><input type="number" step="0.1" data-coin-key="amp_5m"></div>
<div class="field"><label>15m 振幅 %</label><input type="number" step="0.1" data-coin-key="amp_15m"></div>
<div class="field"><label>1h 振幅 %</label><input type="number" step="0.1" data-coin-key="amp_1h"></div>
</div>
</div>
</div>
<div class="group">
<h4>盘口、大额成交、技术指标</h4>
<div class="group-body">
<div class="form-grid">
<div class="field"><label>单笔大额 USDT</label><input type="number" step="1000" data-coin-key="big_trade_usdt"></div>
<div class="field"><label>5m 累计大额 USDT</label><input type="number" step="1000" data-coin-key="cum_trade_5m_usdt"></div>
<div class="field"><label>买卖价差 %</label><input type="number" step="0.01" data-coin-key="spread_pct"></div>
<div class="field"><label>严重价差 %</label><input type="number" step="0.01" data-coin-key="spread_severe_pct"></div>
<div class="field"><label>盘口深度 %</label><input type="number" step="0.1" data-coin-key="imbalance_depth_pct"></div>
<div class="field"><label>盘口失衡倍数</label><input type="number" step="0.1" data-coin-key="imbalance_ratio"></div>
<div class="field"><label>买卖墙深度 %</label><input type="number" step="0.1" data-coin-key="wall_depth_pct"></div>
<div class="field"><label>买卖墙倍数</label><input type="number" step="0.1" data-coin-key="wall_mult"></div>
<div class="field"><label>买卖墙最小 USDT</label><input type="number" step="1000" data-coin-key="wall_min_usdt"></div>
<div class="field"><label>墙方向主导倍数</label><input type="number" step="0.1" data-coin-key="wall_dominance"></div>
<div class="field"><label>墙消失阈值 %</label><input type="number" step="1" data-coin-key="wall_vanish_pct"></div>
<div class="field"><label>偏离均线 %</label><input type="number" step="0.1" data-coin-key="ma_deviation_pct"></div>
<div class="field"><label>连续 K 线根数</label><input type="number" step="1" data-coin-key="consecutive_klines"></div>
<div class="field"><label>RSI 高位</label><input type="number" step="1" data-coin-key="rsi_high"></div>
<div class="field"><label>RSI 低位</label><input type="number" step="1" data-coin-key="rsi_low"></div>
</div>
</div>
</div>
<div class="group">
<h4>合约指标</h4>
<div class="group-body">
<div class="form-grid">
<div class="field"><label>资金费率高位 %</label><input type="number" step="0.001" data-coin-key="funding_high"></div>
<div class="field"><label>资金费率过热 %</label><input type="number" step="0.001" data-coin-key="funding_hot"></div>
<div class="field"><label>资金费率负位 %</label><input type="number" step="0.001" data-coin-key="funding_neg"></div>
<div class="field"><label>持仓量 15m %</label><input type="number" step="0.1" data-coin-key="oi_15m"></div>
<div class="field"><label>持仓量 1h %</label><input type="number" step="0.1" data-coin-key="oi_1h"></div>
<div class="field"><label>持仓量 4h %</label><input type="number" step="0.1" data-coin-key="oi_4h"></div>
<div class="field"><label>5m 强平 USDT</label><input type="number" step="1000" data-coin-key="liq_5m_usdt"></div>
<div class="field"><label>严重强平 USDT</label><input type="number" step="1000" data-coin-key="liq_severe_usdt"></div>
</div>
</div>
</div>
</div>
<div id="panel-exchanges" class="hidden">
<div class="panel-title">
<h3>跨交易所差价</h3>
<button onclick="addSpreadPair()">新增差价币种</button>
</div>
<div class="form-grid">
<div class="field">
<label>启用 Bitget</label>
<div class="switch-row"><input id="ex_enable_bitget" type="checkbox"><span>启用</span></div>
</div>
<div class="field">
<label>启用 OKX</label>
<div class="switch-row"><input id="ex_enable_okx" type="checkbox"><span>启用</span></div>
</div>
<div class="field">
<label>启用差价提醒</label>
<div class="switch-row"><input id="spread_enable" type="checkbox"><span>启用</span></div>
</div>
<div class="field">
<label>默认差价阈值 %</label>
<input id="spread_threshold_pct" type="number" step="0.1">
</div>
</div>
<table class="inline-table">
<thead>
<tr>
<th style="width: 13%">名称</th>
<th>Binance</th>
<th>Bitget</th>
<th>OKX</th>
<th style="width: 14%">阈值 %</th>
<th style="width: 70px">操作</th>
</tr>
</thead>
<tbody id="spreadPairRows"></tbody>
</table>
</div>
<div id="panel-advanced" class="hidden">
<div class="panel-title">
<h3>高级 JSON</h3>
<span class="muted">适合批量修改或复制配置</span>
</div>
<textarea id="configText" spellcheck="false"></textarea>
<div class="section-actions">
<button onclick="syncFormToJson()">从表单同步到 JSON</button>
<button onclick="applyJsonToForm()">从 JSON 应用到表单</button>
<button class="primary" onclick="saveJsonConfig()">保存 JSON</button>
<span id="configMessage" class="message"></span>
</div>
</div>
</div>
</div>
</section>
<section class="health-section">
<div class="section-head"><h2>监控健康状态</h2></div>
<div class="table-shell">
<table class="monitor-table">
<thead>
<tr>
<th style="width: 26%">监控项</th>
<th style="width: 13%">状态</th>
<th style="width: 17%">最近成功</th>
<th style="width: 17%">下次运行</th>
<th>最近值 / 错误</th>
</tr>
</thead>
<tbody id="monitorRows"></tbody>
</table>
</div>
</section>
<section class="alerts-section">
<div class="section-head"><h2>最近告警</h2></div>
<div class="table-shell compact">
<table>
<thead>
<tr>
<th style="width: 17%">时间</th>
<th style="width: 18%">监控项</th>
<th style="width: 12%">类别</th>
<th style="width: 10%">发送</th>
<th>内容</th>
</tr>
</thead>
<tbody id="alertRows"></tbody>
</table>
</div>
</section>
<section class="events-section">
<div class="section-head"><h2>运行事件</h2></div>
<div class="table-shell compact">
<table>
<thead>
<tr>
<th style="width: 17%">时间</th>
<th style="width: 18%">监控项</th>
<th style="width: 10%">级别</th>
<th>消息</th>
</tr>
</thead>
<tbody id="eventRows"></tbody>
</table>
</div>
</section>
</main>
<script>
let currentConfig = null;
let currentCoin = null;
let activePanel = "global";
let chartInterval = "1h";
let chartCoin = null;
let chartCandles = [];
let chartLayout = null;
let chartRequestToken = 0;
let uiEvents = [];
const globalFields = [
["poll_interval_sec", "number"],
["daily_report_hour", "number"],
["alert_sensitivity", "number"],
["alert_strictness", "number"],
["calibration_mode", "string"],
["auto_calibrate_hours", "number"],
["down_priority", "boolean"]
];
function safeText(value) {
if (value === null || value === undefined || value === "") return "--";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function escapeHtml(value) {
return safeText(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function row(cells, labels = []) {
return "<tr>" + cells.map((cell, index) => {
const label = labels[index] ? ` data-label="${escapeHtml(labels[index])}"` : "";
return "<td" + label + ">" + cell + "</td>";
}).join("") + "</tr>";
}
function parseJsonField(value) {
if (!value) return null;
try {
return typeof value === "string" ? JSON.parse(value) : value;
} catch (_err) {
return null;
}
}
function isPaused(monitor) {
return Boolean(Number(monitor?.paused || 0));
}
function isEnabled(monitor) {
return Boolean(Number(monitor?.enabled ?? 1));
}
function formatBtc(value) {
const n = Number(value);
return Number.isFinite(n) ? n.toLocaleString("en-US", {maximumFractionDigits: 0}) + " BTC" : "--";
}
function formatUsd(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
const sign = n < 0 ? "-" : "";
const abs = Math.abs(n);
if (abs >= 1_000_000_000) return sign + "$" + (abs / 1_000_000_000).toFixed(2) + "B";
if (abs >= 1_000_000) return sign + "$" + (abs / 1_000_000).toFixed(2) + "M";
return sign + "$" + abs.toLocaleString("en-US", {maximumFractionDigits: 0});
}
function formatNumber(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
return n.toLocaleString("en-US", {maximumFractionDigits: digits});
}
function formatPrice(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
const digits = Math.abs(n) >= 100 ? 2 : 6;
return n.toLocaleString("en-US", {maximumFractionDigits: digits});
}
function formatPercent(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
return n.toFixed(digits) + "%";
}
function formatSignedPercent(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return "--";
return (n > 0 ? "+" : "") + n.toFixed(digits) + "%";
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function sourceLabel(source) {
return {
binance: "Binance 现货",
binance_futures: "Binance 合约",
binance_alpha: "Binance Alpha",
okx: "OKX"
}[source] || source || "--";
}
function setChangeChip(id, label, value) {
const el = document.getElementById(id);
if (!el) return;
const n = Number(value);
el.textContent = label + " " + (Number.isFinite(n) ? formatSignedPercent(n, 2) : "--");
el.classList.toggle("up", Number.isFinite(n) && n > 0);
el.classList.toggle("down", Number.isFinite(n) && n < 0);
}
function chartIntervalLabel(interval) {
return {
"1m": "1分钟",
"5m": "5分钟",
"15m": "15分钟",
"1h": "1小时",
"4h": "4小时",
"1d": "日线"
}[interval] || interval;
}
function chartTimeLabel(value, short = false) {
const date = new Date(Number(value));
if (Number.isNaN(date.getTime())) return "--";
if (chartInterval === "1d") {
return date.toLocaleDateString(undefined, {month: "2-digit", day: "2-digit"});
}
if (short) {
return date.toLocaleString(undefined, {month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false});
}
return date.toLocaleString(undefined, {hour12: false});
}
async function loadCoreChart() {
const core = currentConfig?.coins ? Object.values(currentConfig.coins).find((coin) => coin.core_display) : null;
const coin = core ? Object.keys(currentConfig.coins).find((name) => currentConfig.coins[name].core_display) : "GRAM";
if (!coin) return;
const token = ++chartRequestToken;
const message = document.getElementById("coreChartMessage");
if (message) {
message.textContent = "正在加载K线...";
message.className = "chart-message";
}
try {
const res = await fetch(`/api/crypto/${encodeURIComponent(coin)}/klines?interval=${encodeURIComponent(chartInterval)}&limit=160`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (token !== chartRequestToken) return;
chartCoin = coin;
chartCandles = (data.candles || []).map((item) => ({
time: Number(item.time),
open: Number(item.open),
high: Number(item.high),
low: Number(item.low),
close: Number(item.close),
volume: Number(item.volume),
quoteVolume: Number(item.quote_volume)
})).filter((item) => [item.time, item.open, item.high, item.low, item.close].every(Number.isFinite));
if (!chartCandles.length) throw new Error("K线数据为空");
setText("coreChartTitle", `${coin} K线`);
setText("coreChartMeta", `${data.symbol || "--"} · ${chartIntervalLabel(chartInterval)} · ${chartCandles.length} 根`);
message.textContent = "";
drawCoreCandleChart();
} catch (err) {
if (token === chartRequestToken) {
chartCandles = [];
drawCoreCandleChart();
if (message) {
message.textContent = "K线加载失败:" + err.message;
message.className = "chart-message error";
}
}
}
}
function drawCoreCandleChart() {
const canvas = document.getElementById("coreCandleChart");
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const width = Math.max(280, rect.width);
const height = Math.max(220, rect.height);
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const left = 62;
const right = 18;
const top = 16;
const xAxisHeight = 40;
const volumeHeight = Math.max(52, Math.round(height * 0.18));
const volumeTop = height - xAxisHeight - volumeHeight;
const priceBottom = volumeTop - 12;
const plotWidth = Math.max(20, width - left - right);
const plotHeight = Math.max(60, priceBottom - top);
const count = chartCandles.length;
chartLayout = {left, right, top, priceBottom, plotWidth, plotHeight};
if (!count) {
ctx.fillStyle = "#94a3b8";
ctx.font = "13px system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.textAlign = "center";
ctx.fillText("暂无K线数据", width / 2, height / 2);
return;
}
let minPrice = Math.min(...chartCandles.map((item) => item.low));
let maxPrice = Math.max(...chartCandles.map((item) => item.high));
if (minPrice === maxPrice) { minPrice -= 1; maxPrice += 1; }
const pricePadding = (maxPrice - minPrice) * 0.06;
minPrice -= pricePadding;
maxPrice += pricePadding;
const maxVolume = Math.max(...chartCandles.map((item) => item.volume), 0) * 1.05 || 1;
const slot = plotWidth / count;
const bodyWidth = Math.max(1.5, Math.min(16, slot * 0.66));
const priceY = (price) => top + (maxPrice - price) / (maxPrice - minPrice) * plotHeight;
const volumeY = (volume) => volumeTop + (1 - volume / maxVolume) * volumeHeight;
ctx.font = "11px system-ui, -apple-system, BlinkMacSystemFont, sans-serif";
ctx.strokeStyle = "rgba(255, 255, 255, .08)";
ctx.fillStyle = "#94a3b8";
ctx.lineWidth = 1;
for (let index = 0; index <= 4; index++) {
const price = minPrice + (maxPrice - minPrice) * (index / 4);
const y = priceY(price);
ctx.beginPath();
ctx.moveTo(left, y);
ctx.lineTo(width - right, y);
ctx.stroke();
ctx.textAlign = "right";
ctx.fillText(formatPrice(price), left - 8, y + 4);
}
ctx.fillStyle = "rgba(148, 163, 184, .38)";
chartCandles.forEach((item, index) => {
const x = left + slot * (index + 0.5);
const y = volumeY(item.volume);
ctx.fillRect(x - bodyWidth / 2, y, bodyWidth, volumeTop - y);
});
chartCandles.forEach((item, index) => {
const x = left + slot * (index + 0.5);
const up = item.close >= item.open;
const color = up ? "#34d399" : "#f87171";
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, priceY(item.high));
ctx.lineTo(x, priceY(item.low));
ctx.stroke();
const openY = priceY(item.open);
const closeY = priceY(item.close);
const bodyTop = Math.min(openY, closeY);
const bodyHeight = Math.max(1, Math.abs(closeY - openY));
ctx.fillRect(x - bodyWidth / 2, bodyTop, bodyWidth, bodyHeight);
});
const last = chartCandles[count - 1];
const lastY = priceY(last.close);
ctx.strokeStyle = "rgba(255, 255, 255, .38)";
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(left, lastY);
ctx.lineTo(width - right, lastY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#94a3b8";
ctx.textAlign = "center";
const labelCount = Math.min(5, count);
for (let index = 0; index < labelCount; index++) {
const sourceIndex = Math.round(index * (count - 1) / Math.max(1, labelCount - 1));
const x = left + slot * (sourceIndex + 0.5);
ctx.fillText(chartTimeLabel(chartCandles[sourceIndex].time, true), x, height - 15);
}
}
function showCoreChartTooltip(event) {
const tooltip = document.getElementById("coreChartTooltip");
const shell = event.currentTarget;
if (!tooltip || !chartLayout || !chartCandles.length) return;
const rect = shell.getBoundingClientRect();
const x = event.clientX - rect.left;
if (x < chartLayout.left || x > rect.width - chartLayout.right) {
tooltip.style.display = "none";
return;
}
const slot = chartLayout.plotWidth / chartCandles.length;
const index = Math.min(chartCandles.length - 1, Math.max(0, Math.floor((x - chartLayout.left) / slot)));
const item = chartCandles[index];
const previous = chartCandles[index - 1] || item;
const change = previous.close ? (item.close / previous.close - 1) * 100 : null;
tooltip.innerHTML = [
`<strong>${escapeHtml(chartTimeLabel(item.time))}</strong>`,
`开 ${escapeHtml(formatPrice(item.open))}`,
`高 ${escapeHtml(formatPrice(item.high))}`,
`低 ${escapeHtml(formatPrice(item.low))}`,
`收 ${escapeHtml(formatPrice(item.close))}`,
`涨跌 ${escapeHtml(formatSignedPercent(change, 2))}`,
`成交额 ${escapeHtml(formatUsd(item.quoteVolume))}`
].join("<br>");
tooltip.style.display = "block";
const tooltipWidth = tooltip.offsetWidth;
const tooltipHeight = tooltip.offsetHeight;
const tooltipLeft = x + tooltipWidth + 24 < rect.width ? x + 14 : x - tooltipWidth - 14;
const tooltipTop = Math.max(8, Math.min(event.clientY - rect.top - tooltipHeight / 2, rect.height - tooltipHeight - 8));
tooltip.style.left = tooltipLeft + "px";
tooltip.style.top = tooltipTop + "px";
}
function hideCoreChartTooltip() {
const tooltip = document.getElementById("coreChartTooltip");
if (tooltip) tooltip.style.display = "none";
}
function setCoreChartInterval(interval) {
if (!["1m", "5m", "15m", "1h", "4h", "1d"].includes(interval)) return;
chartInterval = interval;
document.querySelectorAll("#coreChartControls .chart-pill").forEach((button) => {
button.classList.toggle("active", button.dataset.chartInterval === interval);
});
loadCoreChart();
}
function bindCoreChart() {
document.querySelectorAll("#coreChartControls .chart-pill").forEach((button) => {
button.addEventListener("click", () => setCoreChartInterval(button.dataset.chartInterval));
});
const shell = document.querySelector(".chart-shell");
if (shell) {
shell.addEventListener("pointermove", showCoreChartTooltip);
shell.addEventListener("pointerleave", hideCoreChartTooltip);
}
window.addEventListener("resize", () => {
if (chartCandles.length) drawCoreCandleChart();
});
}
function formatMonitorMeta(monitor) {
if (!monitor) return "等待监控注册";
if (isPaused(monitor)) return "已暂停";
if (monitor.last_success_at) return "最近成功 " + formatDateTime(monitor.last_success_at);
if (monitor.last_error) return "错误:" + monitor.last_error;
return "等待首次成功";
}
function sourceNames(sources, limit = 3) {
const list = Array.isArray(sources) ? sources : [];
if (!list.length) return "--";
const names = list.slice(0, limit).map((item) => item.name).filter(Boolean);
const extra = list.length > limit ? ` +${list.length - limit}` : "";
return names.join(" / ") + extra;
}
function sourceCountLabel(count, total) {
const n = Number(count || 0);
return `${n}/${total}`;
}
function addUiEvent(message, level = "info") {
uiEvents.unshift({
created_at: new Date().toISOString(),
monitor_id: "dashboard",
level,
message
});
uiEvents = uiEvents.slice(0, 12);
}
function formatDateTime(value) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function statusText(status) {
return {
ok: "正常",
running: "运行中",
error: "异常",
unknown: "未知",
paused: "暂停",
disabled: "禁用"
}[status] || status || "--";
}
function chip(label, value) {
if (value === null || value === undefined || value === "") return "";
return `<span class="kv-chip"><span>${escapeHtml(label)}</span><b>${escapeHtml(value)}</b></span>`;
}
function chipValue(label, value, formatter = null) {
if (value === null || value === undefined || value === "") return "";
return chip(label, formatter ? formatter(value) : value);
}
function rawDetails(payload, raw, summary = "原始数据") {
if (!payload && !raw) return "";
const text = payload ? JSON.stringify(payload, null, 2) : String(raw);
return `<details><summary>${escapeHtml(summary)}</summary><pre class="value-json">${escapeHtml(text)}</pre></details>`;
}
function isCalibrationOnlyPayload(payload) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
const keys = Object.keys(payload);
return keys.length === 1 && payload.calibrated === true;
}
function firstScalarChips(payload) {
if (!payload || typeof payload !== "object") return [];
return Object.entries(payload)
.filter(([_key, value]) => value === null || ["string", "number", "boolean"].includes(typeof value))
.slice(0, 5)
.map(([key, value]) => chip(key, value === null ? "--" : value));
}
function monitorSummaryChips(monitor, payload) {
if (!payload || typeof payload !== "object") return [];
const type = monitor.type || "";
const id = monitor.id || "";
if (type === "crypto_coin") {
return [
chipValue("价格", payload.price, formatPrice),
chipValue("24h 高", payload.high24, formatPrice),
chipValue("24h 低", payload.low24, formatPrice),
chipValue("24h 成交额", payload.quoteVolume24h, formatUsd),
chipValue("RSI", payload.rsi, (value) => formatNumber(value, 1)),
chipValue("资金费率", payload.funding, (value) => formatPercent(value, 4))
].filter(Boolean);
}
if (type === "strategy_btc_holdings") {
return [
chipValue("持仓", payload.value, formatBtc),
chipValue("数据日", payload.end),
chipValue("提交", payload.filed),
chipValue("来源", payload.source)
].filter(Boolean);
}
if (type === "strategy_cash_reserve") {
return [
chipValue("现金", payload.value, formatUsd),
chipValue("数据日", payload.end),
chipValue("提交", payload.filed),
chipValue("来源", payload.source)
].filter(Boolean);
}
if (id === "strategy:service") {
const stats = payload.stats || {};
return [
chipValue("轮询", stats.loops),
chipValue("告警", stats.alerts),
chipValue("失败", stats.api_fails),
payload.btc ? chipValue("BTC", payload.btc.value, formatBtc) : "",
payload.cash ? chipValue("现金", payload.cash.value, formatUsd) : ""
].filter(Boolean);
}
if (id === "crypto:service") {
const stats = payload.stats || {};
return [
chipValue("状态", payload.status),
chipValue("轮询", stats.loops),
chipValue("告警", stats.alerts),
chipValue("接口失败", stats.api_fails)
].filter(Boolean);
}
if (id === "crypto:cross_exchange") {
return [chipValue("差价对", payload.pairs)].filter(Boolean);
}
return firstScalarChips(payload).filter(Boolean);
}
function renderMonitorValue(monitor) {
const payload = parseJsonField(monitor.last_value_json);
const raw = monitor.last_value_json;
const hasMarketPayload = !isCalibrationOnlyPayload(payload);
const displayPayload = hasMarketPayload ? payload : null;
const displayRaw = hasMarketPayload ? raw : null;
if (monitor.last_error) {
const staleNote = displayRaw && monitor.last_success_at
? `<div class="stale-note">下方为上次成功数据:${escapeHtml(formatDateTime(monitor.last_success_at))}</div>`
: "";
const details = rawDetails(displayPayload, displayRaw, "上次成功数据");
return (
`<div class="value-card">` +
`<div class="error-box">${escapeHtml(monitor.last_error)}</div>` +
staleNote +
details +
`</div>`
);
}
const details = rawDetails(displayPayload, displayRaw);
const chips = monitorSummaryChips(monitor, displayPayload);
if (!chips.length && !details) return "<span class='muted'>暂无数据</span>";
return (
`<div class="value-card">` +
`<div class="value-summary">${chips.join("") || "<span class='muted'>已记录数据</span>"}</div>` +
details +
`</div>`
);
}
function renderMonitorName(monitor) {
return (
`<div class="monitor-name">${escapeHtml(monitor.name)}</div>` +
`<div class="monitor-id">${escapeHtml(monitor.id)}</div>` +
`<span class="type-badge">${escapeHtml(monitor.type)}</span>`
);
}
function renderMonitorState(monitor, displayStatus, status) {
const failures = Number(monitor.consecutive_failures || 0);
return (
`<div class="state-stack">` +
`<span class="status ${status}">${escapeHtml(statusText(displayStatus))}</span>` +
`<span class="failure-note">连续失败 ${failures}</span>` +
pauseControl(monitor) +
`</div>`
);
}
function renderCoreMarket(summary) {
const core = summary?.core || {};
const coreSnap = core.snapshot || {};
const btcSnap = summary?.btc?.snapshot || {};
const coreMonitor = core.monitor || null;
const aggregate = core.aggregate || {};
const aggVolume = aggregate.volume24h || {};
const aggOi = aggregate.openInterest || {};
const aggFunding = aggregate.fundingRate || {};
const oiChange = coreSnap.openInterestChange || {};
const flowBits = [];
if (Number.isFinite(Number(coreSnap.volumeRatio5m))) {
flowBits.push("5m 量能 x" + formatNumber(coreSnap.volumeRatio5m, 1));
}
if (Number.isFinite(Number(coreSnap.liquidation5m))) {
flowBits.push("强平 " + formatUsd(coreSnap.liquidation5m));
}
if (Number.isFinite(Number(oiChange["15m"]))) {
flowBits.push("OI 15m " + formatSignedPercent(oiChange["15m"], 1));
}
setText("coreName", core.name || "GRAM");
setText("coreSource", `${sourceLabel(core.data_source)} · ${core.symbol || "--"}`);
setText("corePrice", coreSnap.price ? `${formatPrice(coreSnap.price)} USDT` : "--");
setChangeChip("coreChange1m", "1m", coreSnap.priceChange?.["1m"]);
setChangeChip("coreChange5m", "5m", coreSnap.priceChange?.["5m"]);
setChangeChip("coreChange1h", "1h", coreSnap.priceChange?.["1h"]);
setText("btcPrice", btcSnap.price ? `${formatPrice(btcSnap.price)} USDT` : "--");
setText("btcMeta", btcSnap.priceChange?.["1h"] !== undefined
? `1h ${formatSignedPercent(btcSnap.priceChange["1h"], 2)}`
: "等待数据");
setText("coreVolume24h", formatUsd(aggVolume.total || coreSnap.quoteVolume24h));
setText("coreVolume5m", aggVolume.sourceCount
? `${sourceCountLabel(aggVolume.sourceCount, 11)} 来源 · ${sourceNames(aggVolume.sources)}`
: `近 5m ${formatUsd(coreSnap.volume5m)} · ${coreSnap.volumeRatio5m ? "x" + formatNumber(coreSnap.volumeRatio5m, 1) : "--"}`);
setText("coreRange24h", coreSnap.high24 && coreSnap.low24
? `${formatPrice(coreSnap.low24)} - ${formatPrice(coreSnap.high24)}`
: "--");
setText("coreRsi", `Binance 24h 区间 · RSI ${formatNumber(coreSnap.rsi, 1)}`);
setText("coreFunding", aggFunding.value !== null && aggFunding.value !== undefined
? formatPercent(aggFunding.value, 4)
: "--");
setText("coreFuturesSymbol", core.enable_futures
? `${aggFunding.source || "Binance Futures"} · ${aggFunding.symbol || core.futures_symbol || "--"}`
: "合约未启用");
setText("coreOpenInterest", aggOi.total ? formatUsd(aggOi.total) : (coreSnap.openInterest ? formatNumber(coreSnap.openInterest, 2) : "--"));
setText("coreOpenInterestChange", aggOi.sourceCount
? `${sourceCountLabel(aggOi.sourceCount, 6)} 来源 · ${sourceNames(aggOi.sources)}`
: oiChange["15m"] !== undefined
? `15m ${formatSignedPercent(oiChange["15m"], 1)}`
: "等待持仓样本");
setText("coreFlow", flowBits[0] || "--");
setText("coreFlowMeta", flowBits.slice(1).join(";") || "等待资金异动样本");
const displayStatus = !isEnabled(coreMonitor) ? "disabled" : (isPaused(coreMonitor) ? "paused" : coreMonitor?.status);
const statusMeta = `${statusText(displayStatus)} · ${formatMonitorMeta(coreMonitor)}`;
setText("coreCoverage", `成交 ${aggVolume.sourceCount || 0} · OI ${aggOi.sourceCount || 0}`);
setText("coreCoverageMeta", `成交额覆盖 11 所;持仓覆盖 6 所`);
setText("coreUpdated", coreSnap.updatedAt ? `更新 ${formatDateTime(coreSnap.updatedAt)} · ${statusMeta}` : `等待行情 · ${statusMeta}`);
}
function renderStrategySummary(monitors) {
const byId = Object.fromEntries(monitors.map((m) => [m.id, m]));
renderStrategyMetric(byId["strategy:btc_holdings"], "strategyBtcValue", "strategyBtcMeta", formatBtc);
renderStrategyMetric(byId["strategy:cash_reserve"], "strategyCashValue", "strategyCashMeta", formatUsd);
}
function renderStrategyMetric(monitor, valueId, metaId, formatter) {
const valueEl = document.getElementById(valueId);
const metaEl = document.getElementById(metaId);
const payload = parseJsonField(monitor?.last_value_json);
if (!monitor) {
valueEl.textContent = "--";
metaEl.textContent = "等待数据";
return;
}
if (isPaused(monitor)) {
valueEl.textContent = "已暂停";
metaEl.textContent = "该监控项已手动暂停";
return;
}
if (monitor.status === "error") {
const lastValue = payload?.value ? `;上次值:${formatter(payload.value)}` : "";
const lastSuccess = monitor.last_success_at ? `;上次成功:${monitor.last_success_at}` : "";
valueEl.textContent = "获取失败";
metaEl.textContent = `错误:${monitor.last_error || "--"}${lastSuccess}${lastValue}`;
return;
}
valueEl.textContent = formatter(payload?.value);
metaEl.textContent = payload
? `日期:${payload.end || "--"};提交:${payload.filed || "--"};来源:${payload.source || "--"}`
: "等待数据";
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function setMessage(text, kind = "") {
for (const id of ["topMessage", "configMessage"]) {
const el = document.getElementById(id);
if (!el) continue;
el.textContent = text || "";
el.className = "message" + (kind ? " " + kind : "");
}
}
function numberOrZero(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function readNumberInput(el) {
if (el.value === "") return 0;
return numberOrZero(el.value);
}
function setInputValue(id, value) {
const el = document.getElementById(id);
if (!el) return;
if (el.type === "checkbox") {
el.checked = Boolean(value);
} else {
el.value = value ?? "";
}
}
function getInputValue(id, type = "string") {
const el = document.getElementById(id);
if (!el) return null;
if (type === "boolean") return Boolean(el.checked);
if (type === "number") return readNumberInput(el);
return el.value;
}
function showPanel(name) {
activePanel = name;
for (const panel of ["global", "coins", "exchanges", "advanced"]) {
document.getElementById("panel-" + panel).classList.toggle("hidden", panel !== name);
document.querySelector(`[data-panel="${panel}"]`).classList.toggle("active", panel === name);
}
if (name === "advanced") syncFormToJson();
}
async function loadStatus() {
const res = await fetch("/api/status");
const data = await res.json();
const monitors = data.monitors || [];
const alerts = data.alerts || [];
const events = [...uiEvents, ...(data.events || [])];
const errorCount = monitors.filter((m) => m.status === "error" && !isPaused(m)).length;
document.getElementById("platformState").textContent = data.started ? "运行中" : "未启动";
document.getElementById("monitorCount").textContent = monitors.length;
document.getElementById("errorCount").textContent = errorCount;
document.getElementById("alertCount").textContent = data.crypto?.stats?.alerts ?? alerts.length;
document.getElementById("updated").textContent = new Date().toLocaleString();
renderCoreMarket(data.crypto?.core_market);
const core = data.crypto?.core_market?.core || {};
if (core.name && core.name !== chartCoin) {
chartCoin = core.name;
loadCoreChart();
}
renderStrategySummary(monitors);
document.getElementById("monitorRows").innerHTML = monitors.map((m) => {
const displayStatus = !isEnabled(m) ? "disabled" : (isPaused(m) ? "paused" : m.status);
const status = escapeHtml(displayStatus);
return row([
renderMonitorName(m),
renderMonitorState(m, displayStatus, status),
escapeHtml(formatDateTime(m.last_success_at)),
escapeHtml(formatDateTime(m.next_run_at)),
renderMonitorValue(m)
], ["监控项", "状态", "最近成功", "下次运行", "最近值 / 错误"]);
}).join("");
document.getElementById("alertRows").innerHTML = alerts.map((a) => row([
escapeHtml(formatDateTime(a.created_at)),
escapeHtml(a.monitor_id),
escapeHtml(a.category),
a.delivered ? "成功" : "失败",
"<pre class='log-snippet'>" + escapeHtml(a.text) + "</pre>"
], ["时间", "监控项", "类别", "发送", "内容"])).join("");
document.getElementById("eventRows").innerHTML = events.map((e) => row([
escapeHtml(formatDateTime(e.created_at)),
escapeHtml(e.monitor_id),
escapeHtml(e.level),
escapeHtml(e.message)
], ["时间", "监控项", "级别", "消息"])).join("");
}
function pauseControl(monitor) {
const checked = isPaused(monitor) ? " checked" : "";
const disabled = isEnabled(monitor) ? "" : " disabled";
const label = !isEnabled(monitor) ? "配置禁用" : (isPaused(monitor) ? "暂停中" : "运行");
return (
`<label class="switch-row pause-cell">` +
`<input type="checkbox" data-monitor-id="${escapeHtml(monitor.id)}"${checked}${disabled} onchange="setMonitorPaused(this)">` +
`<span>${label}</span>` +
`</label>`
);
}
async function setMonitorPaused(input) {
const monitorId = input.dataset.monitorId;
const paused = input.checked;
input.disabled = true;
try {
const action = paused ? "pause" : "resume";
const res = await fetch("/api/monitors/" + encodeURIComponent(monitorId) + "/" + action, {method: "POST"});
if (!res.ok) throw new Error(await res.text());
setMessage(paused ? "监控项已暂停" : "监控项已恢复", "ok");
await loadStatus();
} catch (err) {
input.checked = !paused;
input.disabled = false;
setMessage("操作失败:" + err.message, "error");
}
}
async function loadConfig() {
const res = await fetch("/api/config");
currentConfig = await res.json();
const names = Object.keys(currentConfig.coins || {});
currentCoin = currentCoin && currentConfig.coins[currentCoin] ? currentCoin : names[0];
renderGlobalForm();
renderCoinSelector();
renderCoinForm();
renderExchangeForm();
syncFormToJson();
setMessage("");
addUiEvent("配置已加载");
await loadStatus();
}
function renderGlobalForm() {
const g = currentConfig.global || {};
for (const [key, type] of globalFields) {
setInputValue("global_" + key, g[key]);
}
}
function collectGlobalForm() {
currentConfig.global = currentConfig.global || {};
for (const [key, type] of globalFields) {
currentConfig.global[key] = getInputValue("global_" + key, type);
}
}
function renderCoinSelector() {
const selector = document.getElementById("coinSelector");
const names = Object.keys(currentConfig.coins || {});
selector.innerHTML = names.map((name) => {
const suffix = currentConfig.coins[name]?.core_display ? "(核心)" : "";
return `<option value="${escapeHtml(name)}">${escapeHtml(name + suffix)}</option>`;
}).join("");
if (currentCoin) selector.value = currentCoin;
}
function selectedCoinName() {
const selector = document.getElementById("coinSelector");
return selector.value || currentCoin;
}
function selectedCoin() {
const name = selectedCoinName();
return currentConfig.coins[name];
}
function renderCoinForm() {
const name = selectedCoinName();
currentCoin = name;
const coin = selectedCoin();
if (!coin) return;
document.querySelectorAll("[data-coin-key]").forEach((el) => {
const key = el.dataset.coinKey;
if (el.type === "checkbox") {
el.checked = Boolean(coin[key]);
} else {
el.value = coin[key] ?? "";
}
});
document.querySelectorAll("[data-price-window]").forEach((el) => {
const win = el.dataset.priceWindow;
const value = (coin.price_change || {})[win];
el.value = Array.isArray(value) ? value[0] : (value ?? "");
});
updateCoinSourceControls();
}
function collectCoinForm() {
const coin = selectedCoin();
if (!coin) return;
document.querySelectorAll("[data-coin-key]").forEach((el) => {
const key = el.dataset.coinKey;
if (el.type === "checkbox") {
coin[key] = Boolean(el.checked);
} else if (el.type === "number") {
coin[key] = readNumberInput(el);
} else {
coin[key] = el.value;
}
});
coin.price_change = coin.price_change || {};
document.querySelectorAll("[data-price-window]").forEach((el) => {
coin.price_change[el.dataset.priceWindow] = readNumberInput(el);
});
if (coin.core_display) {
for (const item of Object.values(currentConfig.coins || {})) {
item.core_display = false;
}
coin.core_display = true;
}
}
function handleCoinSourceChange() {
updateCoinSourceControls();
}
function updateCoinSourceControls() {
const source = document.getElementById("coin_data_source").value;
const futuresSymbol = document.getElementById("coin_futures_symbol");
const futuresEnabled = document.getElementById("coin_enable_futures");
const isAlpha = source === "binance_alpha";
futuresSymbol.disabled = false;
futuresEnabled.disabled = false;
futuresSymbol.placeholder = isAlpha ? "例如 TACUSDT;没有合约可留空" : "例如 BTCUSDT";
if (isAlpha && !futuresSymbol.value) {
const name = selectedCoinName();
if (name && !name.startsWith("ALPHA_")) futuresSymbol.value = name + "USDT";
}
}
function addCoin() {
collectFormConfig();
const name = prompt("请输入币种代号,例如 SOL");
if (!name) return;
const coinName = name.trim().toUpperCase();
if (!coinName) return;
if (currentConfig.coins[coinName]) {
setMessage("币种已存在", "error");
return;
}
const symbol = prompt("请输入交易标的,例如 SOLUSDT", coinName + "USDT");
if (!symbol) return;
const template = clone(currentConfig.coins.BTC || Object.values(currentConfig.coins)[0] || {});
template.symbol = symbol.trim().toUpperCase();
template.futures_symbol = template.symbol;
template.data_source = "binance";
template.enable_futures = false;
template.core_display = false;
currentConfig.coins[coinName] = template;
currentCoin = coinName;
renderCoinSelector();
renderCoinForm();
syncFormToJson();
setMessage("已新增币种,保存后生效", "ok");
}
function alphaCoinName(raw) {
let text = String(raw || "").trim().toUpperCase();
if (text.endsWith("USDT")) text = text.slice(0, -4);
return text.replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "ALPHA";
}
function alphaTemplate(symbol) {
const existingAlpha = Object.values(currentConfig.coins || {}).find((coin) => coin.data_source === "binance_alpha");
const template = clone(existingAlpha || currentConfig.coins.BTC || Object.values(currentConfig.coins)[0] || {});
const fallbackFuturesSymbol = alphaCoinName(symbol).startsWith("ALPHA_") ? "" : alphaCoinName(symbol) + "USDT";
Object.assign(template, {
symbol,
futures_symbol: fallbackFuturesSymbol,
data_source: "binance_alpha",
enable_futures: false,
core_display: false,
market_close: false,
price_change: { "1m": 1.20, "5m": 3.00, "15m": 6.00, "1h": 10.00 },
near_cost_pct: 2.0,
near_key_pct: 2.0,
break_24h_pct: 1.0,
break_24h_hold_sec: 180,
break_7d_pct: 3.0,
vol_anomaly_mult: 4.0,
vol_severe_mult: 10.0,
amp_5m: 4.0,
amp_15m: 8.0,
amp_1h: 14.0,
big_trade_usdt: 5000,
cum_trade_5m_usdt: 30000,
spread_pct: 0.30,
spread_severe_pct: 1.00,
imbalance_depth_pct: 2.0,
imbalance_ratio: 4.0,
wall_depth_pct: 3.0,
wall_mult: 5.0,
wall_vanish_pct: 75.0,
wall_min_usdt: 20000,
wall_dominance: 2.0,
ma_deviation_pct: 10.0,
consecutive_klines: 3,
rsi_high: 85.0,
rsi_low: 15.0
});
return template;
}
function addAlphaCoin() {
collectFormConfig();
const rawSymbol = prompt("请输入 Binance Alpha 标的,可填 DATAIP / ALPHA_1011 / ALPHA_1011USDT");
if (!rawSymbol) return;
const symbol = rawSymbol.trim().toUpperCase();
const coinName = alphaCoinName(symbol);
if (currentConfig.coins[coinName]) {
setMessage("Alpha 币种已存在:" + coinName, "error");
return;
}
currentConfig.coins[coinName] = alphaTemplate(symbol);
currentCoin = coinName;
renderCoinSelector();
renderCoinForm();
syncFormToJson();
setMessage("已新增 Binance Alpha 标的 " + coinName + ",保存后生效", "ok");
}
function deleteCoin() {
collectFormConfig();
const name = selectedCoinName();
const names = Object.keys(currentConfig.coins || {});
if (names.length <= 1) {
setMessage("至少保留一个币种", "error");
return;
}
if (!confirm("确认删除 " + name + " 吗?保存后生效。")) return;
const wasCore = Boolean(currentConfig.coins[name]?.core_display);
delete currentConfig.coins[name];
currentCoin = Object.keys(currentConfig.coins)[0];
if (wasCore && currentCoin) currentConfig.coins[currentCoin].core_display = true;
renderCoinSelector();
renderCoinForm();
syncFormToJson();
setMessage("已删除币种,保存后生效", "ok");
}
function renderExchangeForm() {
const ex = currentConfig.exchanges || {};
const spread = ex.spread || {};
setInputValue("ex_enable_bitget", ex.enable_bitget);
setInputValue("ex_enable_okx", ex.enable_okx);
setInputValue("spread_enable", spread.enable);
setInputValue("spread_threshold_pct", spread.threshold_pct);
renderSpreadPairs();
}
function renderSpreadPairs() {
const pairs = (((currentConfig.exchanges || {}).spread || {}).pairs || []);
const tbody = document.getElementById("spreadPairRows");
tbody.innerHTML = pairs.map((pair, index) => row([
`<input data-pair-index="${index}" data-pair-key="name" value="${escapeHtml(pair.name)}">`,
`<input data-pair-index="${index}" data-pair-key="binance" value="${escapeHtml(pair.binance)}">`,
`<input data-pair-index="${index}" data-pair-key="bitget" value="${escapeHtml(pair.bitget)}">`,
`<input data-pair-index="${index}" data-pair-key="okx" value="${escapeHtml(pair.okx)}">`,
`<input type="number" step="0.1" data-pair-index="${index}" data-pair-key="threshold_pct" value="${escapeHtml(pair.threshold_pct)}">`,
`<button class="small danger" onclick="deleteSpreadPair(${index})">删除</button>`
])).join("");
}
function collectExchangeForm() {
currentConfig.exchanges = currentConfig.exchanges || {};
currentConfig.exchanges.spread = currentConfig.exchanges.spread || {};
currentConfig.exchanges.enable_bitget = getInputValue("ex_enable_bitget", "boolean");
currentConfig.exchanges.enable_okx = getInputValue("ex_enable_okx", "boolean");
currentConfig.exchanges.spread.enable = getInputValue("spread_enable", "boolean");
currentConfig.exchanges.spread.threshold_pct = getInputValue("spread_threshold_pct", "number");
const pairs = currentConfig.exchanges.spread.pairs || [];
document.querySelectorAll("[data-pair-index]").forEach((el) => {
const index = Number(el.dataset.pairIndex);
const key = el.dataset.pairKey;
pairs[index] = pairs[index] || {};
pairs[index][key] = key === "threshold_pct" ? readNumberInput(el) : el.value.trim().toUpperCase();
});
currentConfig.exchanges.spread.pairs = pairs.filter((pair) => pair.name);
}
function addSpreadPair() {
collectFormConfig();
currentConfig.exchanges = currentConfig.exchanges || {};
currentConfig.exchanges.spread = currentConfig.exchanges.spread || {};
currentConfig.exchanges.spread.pairs = currentConfig.exchanges.spread.pairs || [];
currentConfig.exchanges.spread.pairs.push({
name: "NEW",
binance: "NEWUSDT",
bitget: "NEWUSDT",
okx: "NEW-USDT",
threshold_pct: currentConfig.exchanges.spread.threshold_pct || 3
});
renderSpreadPairs();
syncFormToJson();
setMessage("已新增差价币种,保存后生效", "ok");
}
function deleteSpreadPair(index) {
collectExchangeForm();
currentConfig.exchanges.spread.pairs.splice(index, 1);
renderSpreadPairs();
syncFormToJson();
}
function collectFormConfig() {
if (!currentConfig) return null;
collectGlobalForm();
collectCoinForm();
collectExchangeForm();
return currentConfig;
}
function syncFormToJson() {
if (!currentConfig) return;
collectFormConfig();
document.getElementById("configText").value = JSON.stringify(currentConfig, null, 2);
}
function applyJsonToForm() {
try {
currentConfig = JSON.parse(document.getElementById("configText").value);
const names = Object.keys(currentConfig.coins || {});
currentCoin = currentCoin && currentConfig.coins[currentCoin] ? currentCoin : names[0];
renderGlobalForm();
renderCoinSelector();
renderCoinForm();
renderExchangeForm();
setMessage("JSON 已应用到表单,保存后生效", "ok");
} catch (err) {
setMessage("JSON 格式错误:" + err.message, "error");
}
}
async function savePayload(payload) {
const res = await fetch("/api/config", {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
currentConfig = await res.json();
const names = Object.keys(currentConfig.coins || {});
currentCoin = currentCoin && currentConfig.coins[currentCoin] ? currentCoin : names[0];
renderGlobalForm();
renderCoinSelector();
renderCoinForm();
renderExchangeForm();
syncFormToJson();
await loadStatus();
}
async function saveFormConfig() {
try {
const payload = collectFormConfig();
await savePayload(payload);
setMessage("参数已保存并热更新", "ok");
} catch (err) {
setMessage("保存失败:" + err.message, "error");
}
}
async function saveJsonConfig() {
try {
const payload = JSON.parse(document.getElementById("configText").value);
await savePayload(payload);
setMessage("JSON 配置已保存", "ok");
} catch (err) {
setMessage("保存失败:" + err.message, "error");
}
}
async function calibrateAll() {
setMessage("正在校准全部币种,可能需要一些时间");
try {
await savePayload(collectFormConfig());
} catch (err) {
setMessage("校准前保存失败:" + err.message, "error");
return;
}
const res = await fetch("/api/calibrate", {method: "POST"});
if (res.ok) {
setMessage("全部币种校准完成", "ok");
await loadConfig();
await loadStatus();
} else {
setMessage("校准失败:" + await res.text(), "error");
}
}
async function calibrateSelected() {
const coin = selectedCoinName();
if (!coin) return;
currentCoin = coin;
setMessage("正在保存并校准 " + coin);
try {
await savePayload(collectFormConfig());
} catch (err) {
setMessage("校准前保存失败:" + err.message, "error");
return;
}
if (!currentConfig.coins || !currentConfig.coins[coin]) {
setMessage("校准失败:配置中没有 " + coin + ",请重新加载后再试", "error");
return;
}
const res = await fetch("/api/calibrate/" + encodeURIComponent(coin), {method: "POST"});
if (res.ok) {
setMessage(coin + " 校准完成", "ok");
await loadConfig();
await loadStatus();
} else {
setMessage("校准失败:" + await res.text(), "error");
}
}
loadStatus();
loadConfig();
bindCoreChart();
loadCoreChart();
setInterval(loadStatus, 15000);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js").catch(() => {});
});
}
</script>
</body>
</html>
"""