stock-ai-agent/frontend/console.html
2026-04-22 11:03:24 +08:00

1700 lines
62 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统总控台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #08111a;
--panel: rgba(11, 21, 32, 0.86);
--panel-strong: rgba(8, 18, 28, 0.96);
--panel-soft: rgba(16, 28, 41, 0.72);
--line: rgba(128, 169, 202, 0.18);
--line-strong: rgba(128, 169, 202, 0.32);
--text: #edf6ff;
--muted: #8ea6bc;
--cold: #7ec8ff;
--accent: #9cff57;
--warn: #ffb84d;
--danger: #ff6f61;
--good: #30d158;
--shadow: 0 20px 80px rgba(0, 0, 0, 0.38);
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(64, 127, 255, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(156, 255, 87, 0.12), transparent 22%),
linear-gradient(180deg, #071018 0%, #08111a 48%, #050b11 100%);
color: var(--text);
font-family: "IBM Plex Sans", sans-serif;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: linear-gradient(180deg, rgba(0,0,0,0.45), rgba(0,0,0,0.9));
}
.console-shell {
width: min(1600px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 40px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.9fr);
gap: 18px;
margin-bottom: 18px;
}
.hero-card,
.panel,
.platform-card,
.stream-card,
.signal-card {
position: relative;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.hero-card::after,
.panel::after,
.platform-card::after,
.stream-card::after,
.signal-card::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(135deg, rgba(255,255,255,0.04), transparent 24%, transparent 70%, rgba(158, 214, 255, 0.05));
}
.hero-main {
padding: 26px 28px 22px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
color: var(--cold);
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.eyebrow::before {
content: "";
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 18px rgba(156, 255, 87, 0.72);
}
.hero-title {
margin: 0;
font-size: clamp(34px, 4.8vw, 56px);
line-height: 1;
font-weight: 700;
letter-spacing: -0.04em;
}
.hero-title strong {
color: var(--cold);
font-weight: 700;
}
.hero-subtitle {
margin: 14px 0 24px;
max-width: 780px;
color: var(--muted);
font-size: 15px;
line-height: 1.7;
}
.hero-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.hero-metric {
padding: 16px;
border-radius: 18px;
background: var(--panel-soft);
border: 1px solid rgba(126, 200, 255, 0.08);
}
.metric-label {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.metric-value {
font-family: "IBM Plex Mono", monospace;
font-size: clamp(22px, 3vw, 30px);
font-weight: 600;
}
.metric-value.good {
color: var(--good);
}
.metric-value.danger {
color: var(--danger);
}
.hero-side {
padding: 22px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 16px;
background: var(--panel-strong);
}
.status-stack {
display: grid;
gap: 12px;
}
.status-pill {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255,255,255,0.025);
border: 1px solid rgba(255,255,255,0.06);
}
.status-pill .name {
font-size: 14px;
color: var(--muted);
}
.status-pill .value {
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
color: var(--text);
}
.status-pill.halted .value {
color: var(--danger);
}
.hero-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.action-btn {
appearance: none;
border: none;
cursor: pointer;
border-radius: 14px;
padding: 12px 16px;
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
}
.action-btn:hover {
transform: translateY(-1px);
}
.action-primary {
background: linear-gradient(135deg, #9cff57, #63e6be);
color: #071018;
}
.action-secondary {
background: rgba(126, 200, 255, 0.12);
color: var(--cold);
border: 1px solid rgba(126, 200, 255, 0.22);
}
.layout {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
}
.left-stack,
.right-stack {
display: grid;
gap: 18px;
}
.panel {
padding: 22px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.panel-title {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
}
.panel-sub {
color: var(--muted);
font-size: 12px;
font-family: "IBM Plex Mono", monospace;
}
.signal-grid,
.platform-grid {
display: grid;
gap: 14px;
}
.signal-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.signal-card {
padding: 18px;
}
.signal-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.signal-symbol {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.03em;
}
.signal-meta {
color: var(--muted);
font-size: 12px;
font-family: "IBM Plex Mono", monospace;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 74px;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
border: 1px solid transparent;
}
.badge.buy {
background: rgba(48, 209, 88, 0.12);
color: var(--good);
border-color: rgba(48, 209, 88, 0.2);
}
.badge.sell {
background: rgba(255, 111, 97, 0.12);
color: var(--danger);
border-color: rgba(255, 111, 97, 0.22);
}
.badge.wait {
background: rgba(255, 184, 77, 0.12);
color: var(--warn);
border-color: rgba(255, 184, 77, 0.22);
}
.signal-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.stat-chip {
padding: 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
}
.decision-chip {
border: 1px solid rgba(255,255,255,0.06);
transition: border-color 0.2s ease, background 0.2s ease;
}
.decision-chip.success {
border-color: rgba(48, 209, 88, 0.18);
background: rgba(48, 209, 88, 0.08);
}
.decision-chip.warning {
border-color: rgba(255, 184, 77, 0.18);
background: rgba(255, 184, 77, 0.08);
}
.decision-chip.error {
border-color: rgba(255, 111, 97, 0.18);
background: rgba(255, 111, 97, 0.08);
}
.decision-chip.hold {
border-color: rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04);
}
.stat-chip .label {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 5px;
}
.stat-chip .value {
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
}
.platform-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.platform-card {
padding: 18px;
}
.platform-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
margin-bottom: 14px;
}
.platform-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.platform-state {
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
color: var(--muted);
}
.risk-band {
margin: 12px 0 14px;
height: 8px;
border-radius: 999px;
background: rgba(255,255,255,0.06);
overflow: hidden;
}
.risk-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--good), var(--warn), var(--danger));
}
.platform-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.platform-stat {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.03);
}
.platform-stat .label {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 4px;
}
.platform-stat .value {
font-family: "IBM Plex Mono", monospace;
font-size: 14px;
}
.stream-card {
padding: 18px;
}
.stream-list {
display: grid;
gap: 10px;
}
.event-list {
display: grid;
gap: 10px;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.filter-chip {
appearance: none;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.03);
color: var(--muted);
cursor: pointer;
border-radius: 999px;
padding: 8px 10px;
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.filter-chip.active {
border-color: rgba(126, 200, 255, 0.28);
color: var(--cold);
background: rgba(126, 200, 255, 0.10);
}
.event-item {
display: grid;
grid-template-columns: 120px 110px 1fr;
gap: 12px;
align-items: start;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.event-time {
color: var(--muted);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
}
.event-tag {
display: inline-flex;
justify-content: center;
align-items: center;
min-height: 28px;
padding: 6px 8px;
border-radius: 10px;
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(126, 200, 255, 0.12);
color: var(--cold);
}
.event-tag.success {
background: rgba(48, 209, 88, 0.12);
color: var(--good);
}
.event-tag.error {
background: rgba(255, 111, 97, 0.12);
color: var(--danger);
}
.event-tag.warning {
background: rgba(255, 184, 77, 0.12);
color: var(--warn);
}
.event-tag.hold {
background: rgba(255,255,255,0.08);
color: var(--muted);
}
.event-body {
font-size: 13px;
line-height: 1.55;
}
.event-body strong {
color: var(--text);
}
.event-summary {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.event-inline-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.06);
color: var(--muted);
font-size: 10px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.event-details {
margin-top: 8px;
border-top: 1px dashed rgba(255,255,255,0.08);
padding-top: 8px;
}
.event-details summary {
cursor: pointer;
list-style: none;
color: var(--cold);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.event-details summary::-webkit-details-marker {
display: none;
}
.event-details pre {
margin: 10px 0 0;
padding: 10px 12px;
border-radius: 10px;
background: rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.06);
overflow: auto;
color: #cfe1f2;
font-size: 11px;
line-height: 1.55;
font-family: "IBM Plex Mono", monospace;
white-space: pre-wrap;
word-break: break-word;
}
.stream-item {
display: grid;
grid-template-columns: 124px 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.stream-item .time {
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
}
.stream-item .headline {
font-size: 14px;
line-height: 1.45;
}
.stream-item .grade {
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
color: var(--cold);
}
.halt-list {
display: grid;
gap: 10px;
}
.halt-item {
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.05);
background: rgba(255,255,255,0.025);
}
.halt-item.active {
border-color: rgba(255, 111, 97, 0.2);
background: rgba(255, 111, 97, 0.08);
}
.halt-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.halt-name {
font-size: 14px;
font-weight: 600;
}
.halt-state {
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
color: var(--muted);
}
.halt-reason {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.halt-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.mini-btn {
appearance: none;
border: 1px solid rgba(126, 200, 255, 0.22);
background: rgba(126, 200, 255, 0.12);
color: var(--cold);
cursor: pointer;
border-radius: 10px;
padding: 8px 10px;
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mini-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.footer-note {
margin-top: 16px;
color: var(--muted);
font-size: 12px;
font-family: "IBM Plex Mono", monospace;
}
.ops-grid {
display: grid;
gap: 18px;
grid-template-columns: 0.9fr 1.1fr;
margin-top: 18px;
}
.attention-list,
.unified-list {
display: grid;
gap: 10px;
}
.attention-item,
.unified-item {
padding: 14px 16px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.attention-item.danger {
border-color: rgba(255, 111, 97, 0.2);
background: rgba(255, 111, 97, 0.08);
}
.attention-item.warning {
border-color: rgba(255, 184, 77, 0.2);
background: rgba(255, 184, 77, 0.08);
}
.attention-item.info {
border-color: rgba(126, 200, 255, 0.2);
background: rgba(126, 200, 255, 0.08);
}
.attention-title,
.unified-title {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 6px;
}
.attention-title strong,
.unified-title strong {
font-size: 14px;
}
.attention-time,
.unified-time {
color: var(--muted);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
}
.attention-detail,
.unified-detail {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.table-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.toolbar-chip {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(126, 200, 255, 0.08);
color: var(--cold);
border: 1px solid rgba(126, 200, 255, 0.16);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.unified-section {
margin-top: 18px;
}
.unified-table {
overflow-x: auto;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.05);
background: rgba(255,255,255,0.02);
}
.unified-table table {
min-width: 980px;
}
.platform-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92px;
padding: 5px 10px;
border-radius: 999px;
font-size: 10px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--cold);
background: rgba(126, 200, 255, 0.1);
border: 1px solid rgba(126, 200, 255, 0.2);
}
.side-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 60px;
padding: 4px 10px;
border-radius: 999px;
font-size: 10px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.side-pill.long {
color: var(--good);
background: rgba(48, 209, 88, 0.12);
border: 1px solid rgba(48, 209, 88, 0.2);
}
.side-pill.short {
color: var(--danger);
background: rgba(255, 111, 97, 0.12);
border: 1px solid rgba(255, 111, 97, 0.2);
}
.inline-mono {
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
}
.loading,
.error-box,
.empty-box {
padding: 24px;
border-radius: 18px;
text-align: center;
color: var(--muted);
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
}
.error-box {
color: #ffd0cb;
background: rgba(255, 111, 97, 0.08);
border-color: rgba(255, 111, 97, 0.2);
}
@media (max-width: 1240px) {
.hero,
.layout {
grid-template-columns: 1fr;
}
.platform-grid,
.signal-grid,
.ops-grid {
grid-template-columns: 1fr;
}
.hero-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.console-shell {
width: min(100vw - 20px, 100%);
padding-top: 12px;
}
.hero-main,
.hero-side,
.panel,
.platform-card,
.signal-card,
.stream-card {
padding: 18px;
}
.hero-metrics,
.platform-stats,
.signal-stats {
grid-template-columns: 1fr;
}
.stream-item {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="console-shell">
<section class="hero">
<div class="hero-card hero-main">
<div class="eyebrow">System Console</div>
<h1 class="hero-title">交易系统 <strong>总控台</strong></h1>
<p class="hero-subtitle">
统一观察信号流、执行层、三端账户风险和平台熔断状态。
这个页面的目标不是展示“历史”,而是让你在一屏内判断系统现在是不是健康、哪里堵住了、哪里需要人工接管。
</p>
<div class="hero-metrics" id="heroMetrics">
<div class="hero-metric"><div class="metric-label">运行 Agent</div><div class="metric-value">-</div></div>
<div class="hero-metric"><div class="metric-label">30 分钟信号</div><div class="metric-value">-</div></div>
<div class="hero-metric"><div class="metric-label">活跃持仓</div><div class="metric-value">-</div></div>
<div class="hero-metric"><div class="metric-label">停机平台</div><div class="metric-value">-</div></div>
</div>
</div>
<div class="hero-card hero-side">
<div>
<div class="panel-header" style="margin-bottom: 14px;">
<h2 class="panel-title">运行态快照</h2>
<div class="panel-sub" id="lastUpdated">等待刷新</div>
</div>
<div class="status-stack" id="runSnapshot">
<div class="status-pill"><span class="name">系统启动</span><span class="value">-</span></div>
<div class="status-pill"><span class="name">Crypto Agent</span><span class="value">-</span></div>
<div class="status-pill"><span class="name">轮询模式</span><span class="value">-</span></div>
<div class="status-pill"><span class="name">监控标的</span><span class="value">-</span></div>
</div>
</div>
<div class="hero-actions">
<button class="action-btn action-primary" id="refreshBtn">立即刷新</button>
<button class="action-btn action-secondary" id="toggleAutoRefreshBtn">自动刷新: 开</button>
</div>
</div>
</section>
<div id="feedback"></div>
<section class="layout">
<div class="left-stack">
<section class="panel">
<div class="panel-header">
<div>
<h2 class="panel-title">平台执行概览</h2>
<div class="panel-sub">资金、杠杆、持仓、挂单、回撤阈值</div>
</div>
</div>
<div class="platform-grid" id="platformGrid">
<div class="loading">正在加载平台状态...</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2 class="panel-title">管理待处理事项</h2>
<div class="panel-sub">风险、停机、异常、待成交提醒</div>
</div>
</div>
<div class="attention-list" id="attentionList">
<div class="loading">正在汇总待处理事项...</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2 class="panel-title">最近信号流</h2>
<div class="panel-sub">数据库最新信号与等级/置信度</div>
</div>
</div>
<div class="stream-list" id="signalStream">
<div class="loading">正在读取信号...</div>
</div>
</section>
</div>
<div class="right-stack">
<section class="panel">
<div class="panel-header">
<div>
<h2 class="panel-title">Crypto Agent 状态</h2>
<div class="panel-sub">最近信号、平台停机、执行层状态</div>
</div>
</div>
<div class="signal-grid" id="agentSignals">
<div class="loading">正在读取 Agent 状态...</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2 class="panel-title">最近决策预览</h2>
<div class="panel-sub">最近一轮各平台准备执行的动作</div>
</div>
</div>
<div class="signal-grid" id="decisionPreview">
<div class="loading">正在读取决策预览...</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2 class="panel-title">执行事件流</h2>
<div class="panel-sub">最近执行结果、未执行原因、平台异常</div>
</div>
</div>
<div class="filter-row" id="eventFilters">
<button class="filter-chip active" data-filter="all">All</button>
<button class="filter-chip" data-filter="error">Error</button>
<button class="filter-chip" data-filter="warning">Warning</button>
<button class="filter-chip" data-filter="hold">Hold</button>
<button class="filter-chip" data-filter="success">Success</button>
</div>
<div class="event-list" id="eventStream">
<div class="loading">正在读取执行事件...</div>
</div>
</section>
<section class="stream-card">
<div class="panel-header">
<div>
<h2 class="panel-title">平台停机 / 熔断</h2>
<div class="panel-sub">出现风险时,这里是你第一眼要看的位置</div>
</div>
</div>
<div class="halt-list" id="haltList">
<div class="loading">正在读取平台停机状态...</div>
</div>
<div class="footer-note">建议挂在大屏或副屏,默认每 15 秒刷新。</div>
</section>
</div>
</section>
<section class="ops-grid">
<section class="panel unified-section">
<div class="panel-header">
<div>
<h2 class="panel-title">统一持仓视图</h2>
<div class="panel-sub">三端持仓合并,优先看风险与盈亏</div>
</div>
</div>
<div class="table-toolbar" id="positionsToolbar"></div>
<div class="unified-table" id="positionsTable">
<div class="loading">正在整理跨平台持仓...</div>
</div>
</section>
<section class="panel unified-section">
<div class="panel-header">
<div>
<h2 class="panel-title">统一挂单视图</h2>
<div class="panel-sub">入场单、保护单、资金占用一屏观察</div>
</div>
</div>
<div class="table-toolbar" id="ordersToolbar"></div>
<div class="unified-table" id="ordersTable">
<div class="loading">正在整理跨平台挂单...</div>
</div>
</section>
</section>
</div>
<script>
let autoRefresh = true;
let timer = null;
let currentEventFilter = 'all';
function formatNumber(value, digits = 2) {
const num = Number(value || 0);
return Number.isFinite(num) ? num.toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}) : '-';
}
function formatPercent(value, digits = 1) {
const num = Number(value || 0);
return Number.isFinite(num) ? `${num.toFixed(digits)}%` : '-';
}
function formatMoney(value) {
const num = Number(value || 0);
return Number.isFinite(num) ? `$${formatNumber(num, 2)}` : '-';
}
function formatTime(value) {
if (!value) return '-';
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
} catch {
return String(value);
}
}
function relativeTime(value) {
if (!value) return '-';
const target = new Date(value);
if (Number.isNaN(target.getTime())) return String(value);
const diff = Date.now() - target.getTime();
const sec = Math.max(0, Math.floor(diff / 1000));
if (sec < 60) return `${sec}s 前`;
if (sec < 3600) return `${Math.floor(sec / 60)}m 前`;
if (sec < 86400) return `${Math.floor(sec / 3600)}h 前`;
return `${Math.floor(sec / 86400)}d 前`;
}
function statusClassFromAction(action) {
if (action === 'buy') return 'buy';
if (action === 'sell') return 'sell';
return 'wait';
}
function riskFillStyle(current, max) {
if (!max || max <= 0) return 0;
return Math.max(0, Math.min(100, (current / max) * 100));
}
function sumPlatformPositions(platforms) {
return ['paper', 'bitget', 'hyperliquid']
.map((key) => platforms?.[key]?.positions?.count || 0)
.reduce((sum, count) => sum + count, 0);
}
function countHalted(platformHalts) {
return Object.values(platformHalts || {}).filter((item) => item && item.halted).length;
}
function setFeedback(message, isError = false) {
const el = document.getElementById('feedback');
if (!message) {
el.innerHTML = '';
return;
}
el.innerHTML = `<div class="${isError ? 'error-box' : 'empty-box'}" style="margin-bottom: 18px;">${message}</div>`;
}
function renderHero(data) {
const heroMetrics = document.getElementById('heroMetrics');
const system = data.system || {};
const signals = data.signals || {};
const platforms = data.platforms || {};
const platformHalts = data.crypto_agent?.platform_halts || {};
const haltedCount = countHalted(platformHalts);
heroMetrics.innerHTML = `
<div class="hero-metric">
<div class="metric-label">运行 Agent</div>
<div class="metric-value ${system.error_agents > 0 ? 'danger' : 'good'}">${system.running_agents || 0}/${system.total_agents || 0}</div>
</div>
<div class="hero-metric">
<div class="metric-label">30 分钟信号</div>
<div class="metric-value">${signals.recent_30m_count || 0}</div>
</div>
<div class="hero-metric">
<div class="metric-label">活跃持仓</div>
<div class="metric-value">${sumPlatformPositions(platforms)}</div>
</div>
<div class="hero-metric">
<div class="metric-label">停机平台</div>
<div class="metric-value ${haltedCount > 0 ? 'danger' : 'good'}">${haltedCount}</div>
</div>
`;
const snapshot = document.getElementById('runSnapshot');
const cryptoAgent = data.crypto_agent || {};
snapshot.innerHTML = `
<div class="status-pill">
<span class="name">系统启动</span>
<span class="value">${formatTime(system.system_start_time)}</span>
</div>
<div class="status-pill ${cryptoAgent.running ? '' : 'halted'}">
<span class="name">Crypto Agent</span>
<span class="value">${cryptoAgent.running ? 'RUNNING' : 'STOPPED'}</span>
</div>
<div class="status-pill">
<span class="name">轮询模式</span>
<span class="value">${cryptoAgent.mode || '-'}</span>
</div>
<div class="status-pill">
<span class="name">监控标的</span>
<span class="value">${(cryptoAgent.symbols || []).length} symbols</span>
</div>
`;
document.getElementById('lastUpdated').textContent = `刷新时间 ${formatTime(data.generated_at)}`;
}
function renderPlatforms(platforms, platformHalts) {
const container = document.getElementById('platformGrid');
const entries = [
{ key: 'paper', title: '模拟盘', subtitle: '执行基准 / 策略验证' },
{ key: 'bitget', title: 'Bitget', subtitle: 'U 本位实盘' },
{ key: 'hyperliquid', title: 'Hyperliquid', subtitle: '链上实盘' },
];
container.innerHTML = entries.map(({ key, title, subtitle }) => {
const item = platforms?.[key] || { enabled: false };
const halt = platformHalts?.[key === 'paper' ? 'PaperTrading' : key === 'bitget' ? 'Bitget' : 'Hyperliquid'] || {};
const enabled = item.enabled !== false;
const risk = item.risk || {};
const account = item.account || {};
const orders = item.orders || {};
const positions = item.positions || {};
const currentLeverage = risk.current_leverage || account.current_total_leverage || 0;
const maxLeverage = risk.max_leverage || account.max_total_leverage || 0;
const fill = riskFillStyle(currentLeverage, maxLeverage);
return `
<article class="platform-card">
<div class="platform-top">
<div>
<div class="panel-sub">${subtitle}</div>
<h3 class="platform-title">${title}</h3>
</div>
<div class="platform-state">${!enabled ? 'DISABLED' : halt.halted ? 'HALTED' : 'ONLINE'}</div>
</div>
<div class="risk-band"><div class="risk-fill" style="width:${fill}%"></div></div>
<div class="platform-stats">
<div class="platform-stat">
<span class="label">权益</span>
<span class="value">${formatMoney(account.current_balance || account.account_value)}</span>
</div>
<div class="platform-stat">
<span class="label">可用</span>
<span class="value">${formatMoney(account.available || account.available_balance)}</span>
</div>
<div class="platform-stat">
<span class="label">持仓</span>
<span class="value">${positions.count || 0}</span>
</div>
<div class="platform-stat">
<span class="label">挂单</span>
<span class="value">${orders.count || 0}</span>
</div>
<div class="platform-stat">
<span class="label">开仓单 / TP-SL</span>
<span class="value">${orders.entry_orders || orders.pending_count || 0} / ${orders.tp_sl_orders || 0}</span>
</div>
<div class="platform-stat">
<span class="label">总杠杆</span>
<span class="value">${formatNumber(currentLeverage, 2)}x / ${formatNumber(maxLeverage, 2)}x</span>
</div>
<div class="platform-stat">
<span class="label">回撤</span>
<span class="value" style="color:${(risk.drawdown_percent || risk.drawdown || 0) >= (risk.circuit_breaker_threshold || 25) ? 'var(--danger)' : 'var(--text)'};">
${formatPercent(risk.drawdown_percent || risk.drawdown || 0)}
</span>
</div>
</div>
</article>
`;
}).join('');
}
function renderSignalStream(signals) {
const container = document.getElementById('signalStream');
if (!signals || signals.length === 0) {
container.innerHTML = '<div class="empty-box">最近没有可展示信号</div>';
return;
}
container.innerHTML = signals.map((signal) => `
<div class="stream-item">
<div class="time">${relativeTime(signal.created_at)}<br>${formatTime(signal.created_at)}</div>
<div class="headline">
<strong>${signal.symbol || '-'}</strong>
${signal.action === 'buy' ? '做多' : signal.action === 'sell' ? '做空' : '观望'}
<span style="color: var(--muted);">| ${signal.signal_type || '-'} | ${signal.timeframe || signal.type || '-'}</span>
</div>
<div class="grade">${signal.grade || '-'} / ${formatPercent(signal.confidence || 0, 1)}</div>
</div>
`).join('');
}
function renderAgentSignals(cryptoAgent, signalStats) {
const container = document.getElementById('agentSignals');
const lastSignals = cryptoAgent?.last_signals || {};
const entries = Object.entries(lastSignals);
const cards = [];
cards.push(`
<article class="signal-card">
<div class="signal-head">
<div>
<div class="signal-symbol">7D 信号统计</div>
<div class="signal-meta">crypto / stock 聚合</div>
</div>
<span class="badge wait">stats</span>
</div>
<div class="signal-stats">
<div class="stat-chip"><span class="label">Crypto</span><span class="value">${signalStats?.stats_7d?.crypto?.total || 0}</span></div>
<div class="stat-chip"><span class="label">Stock</span><span class="value">${signalStats?.stats_7d?.stock?.total || 0}</span></div>
<div class="stat-chip"><span class="label">Total</span><span class="value">${signalStats?.stats_7d?.total || 0}</span></div>
</div>
</article>
`);
if (entries.length === 0) {
cards.push('<div class="empty-box">Crypto Agent 暂无最近信号缓存</div>');
} else {
entries.slice(0, 5).forEach(([symbol, sig]) => {
cards.push(`
<article class="signal-card">
<div class="signal-head">
<div>
<div class="signal-symbol">${symbol}</div>
<div class="signal-meta">${sig.type || '-'} / ${sig.grade || '-'}</div>
</div>
<span class="badge ${statusClassFromAction(sig.action)}">${sig.action || 'wait'}</span>
</div>
<div class="signal-stats">
<div class="stat-chip"><span class="label">动作</span><span class="value">${sig.action || '-'}</span></div>
<div class="stat-chip"><span class="label">置信度</span><span class="value">${formatPercent(sig.confidence || 0, 1)}</span></div>
<div class="stat-chip"><span class="label">等级</span><span class="value">${sig.grade || '-'}</span></div>
</div>
</article>
`);
});
}
container.innerHTML = cards.join('');
}
function summarizeDecision(decision) {
if (!decision) return { label: '-', detail: '无数据', tone: 'hold' };
const decisionType = decision.decision || decision.action || 'HOLD';
let tone = 'hold';
if (['OPEN', 'ADD', 'CLOSE'].includes(decisionType)) {
tone = 'success';
} else if (['CANCEL_PENDING', 'FLIP', 'ROLL', 'CLOSE_OPPOSITE', 'CANCEL_AND_OPEN'].includes(decisionType)) {
tone = 'warning';
} else if (['ERROR', 'FAILED'].includes(decisionType)) {
tone = 'error';
}
return {
label: decisionType,
detail: decision.reason || decision.reasoning || '无说明',
tone,
};
}
function renderDecisionPreview(previewMap) {
const container = document.getElementById('decisionPreview');
const entries = Object.entries(previewMap || {});
if (entries.length === 0) {
container.innerHTML = '<div class="empty-box">暂无最近决策预览</div>';
return;
}
const cards = [];
entries.slice(0, 4).forEach(([symbol, preview]) => {
const paper = summarizeDecision(preview.paper);
const hyperliquid = summarizeDecision(preview.hyperliquid);
const bitget = summarizeDecision(preview.bitget);
cards.push(`
<article class="signal-card">
<div class="signal-head">
<div>
<div class="signal-symbol">${symbol}</div>
<div class="signal-meta">${formatTime(preview.timestamp)} | 现价 ${formatMoney(preview.current_price)}</div>
</div>
<span class="badge wait">preview</span>
</div>
<div class="signal-stats">
<div class="stat-chip decision-chip ${paper.tone}"><span class="label">模拟盘</span><span class="value">${paper.label}</span></div>
<div class="stat-chip decision-chip ${hyperliquid.tone}"><span class="label">Hyperliquid</span><span class="value">${hyperliquid.label}</span></div>
<div class="stat-chip decision-chip ${bitget.tone}"><span class="label">Bitget</span><span class="value">${bitget.label}</span></div>
</div>
<div style="margin-top: 12px; color: var(--muted); font-size: 12px; line-height: 1.6;">
模拟盘: ${paper.detail}<br>
Hyperliquid: ${hyperliquid.detail}<br>
Bitget: ${bitget.detail}
</div>
</article>
`);
});
container.innerHTML = cards.join('');
}
function renderHalts(platformHalts) {
const container = document.getElementById('haltList');
const entries = [
['PaperTrading', '模拟盘'],
['Bitget', 'Bitget'],
['Hyperliquid', 'Hyperliquid'],
];
container.innerHTML = entries.map(([key, label]) => {
const halt = platformHalts?.[key] || {};
const active = !!halt.halted;
return `
<div class="halt-item ${active ? 'active' : ''}">
<div class="halt-top">
<div class="halt-name">${label}</div>
<div class="halt-state">${active ? 'HALTED' : 'ACTIVE'}</div>
</div>
<div class="halt-reason">
${active
? `${halt.reason || '已触发平台停机'}<br>回撤: ${formatPercent(halt.drawdown_pct || 0)} | 触发时间: ${formatTime(halt.halted_at)}`
: '运行正常,未触发平台停机或熔断。'}
</div>
${active ? `
<div class="halt-actions">
<button class="mini-btn" data-platform="${key}">恢复执行</button>
</div>
` : ''}
</div>
`;
}).join('');
container.querySelectorAll('[data-platform]').forEach((button) => {
button.addEventListener('click', async () => {
const platform = button.getAttribute('data-platform');
await resumePlatform(platform, button);
});
});
}
function renderExecutionEvents(events) {
const container = document.getElementById('eventStream');
const filtered = (events || []).filter((event) => currentEventFilter === 'all' || event.status === currentEventFilter);
if (!filtered || filtered.length === 0) {
container.innerHTML = '<div class="empty-box">最近还没有执行事件</div>';
return;
}
container.innerHTML = filtered.map((event) => `
<div class="event-item">
<div class="event-time">${relativeTime(event.timestamp)}<br>${formatTime(event.timestamp)}</div>
<div class="event-tag ${event.status || 'hold'}">${event.platform || '-'}<br>${event.event_type || '-'}</div>
<div class="event-body">
<div class="event-summary">
<strong>${event.symbol || '-'}</strong>
${event.decision ? `<span class="event-inline-badge">${event.decision}</span>` : ''}
${event.action ? `<span class="event-inline-badge">${event.action}</span>` : ''}
</div>
<span style="color: var(--muted);">${event.reason || '无说明'}</span>
${(event.reason || '').length > 90 ? `
<details class="event-details">
<summary>查看完整详情</summary>
<pre>${String(event.reason || '无说明')}</pre>
</details>
` : ''}
</div>
</div>
`).join('');
}
function renderAttentionItems(items) {
const container = document.getElementById('attentionList');
if (!items || items.length === 0) {
container.innerHTML = '<div class="empty-box">当前没有需要人工处理的事项</div>';
return;
}
container.innerHTML = items.map((item) => `
<div class="attention-item ${item.severity || 'info'}">
<div class="attention-title">
<strong>${item.title || '-'}</strong>
<span class="attention-time">${item.timestamp ? relativeTime(item.timestamp) : 'now'}</span>
</div>
<div class="attention-detail">${item.detail || '-'}</div>
</div>
`).join('');
}
function renderUnifiedPositions(positions) {
const toolbar = document.getElementById('positionsToolbar');
const container = document.getElementById('positionsTable');
const total = positions || [];
const platformCounts = ['paper', 'bitget', 'hyperliquid'].map((platform) => {
const count = total.filter((item) => item.platform === platform).length;
return `<span class="toolbar-chip">${platform}: ${count}</span>`;
}).join('');
toolbar.innerHTML = `${platformCounts}<span class="toolbar-chip">total: ${total.length}</span>`;
if (!total.length) {
container.innerHTML = '<div class="empty-box">当前没有跨平台持仓</div>';
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>平台</th>
<th>交易对</th>
<th>方向</th>
<th>入场 / 现价</th>
<th>仓位 / 杠杆</th>
<th>止盈 / 止损</th>
<th>未实现盈亏</th>
<th>盈亏比例</th>
<th>时间</th>
</tr>
</thead>
<tbody>
${total.map((item) => `
<tr>
<td><span class="platform-pill">${item.platform}</span></td>
<td><strong>${item.symbol || '-'}</strong></td>
<td><span class="side-pill ${item.side === 'long' ? 'long' : 'short'}">${item.side === 'long' ? 'long' : 'short'}</span></td>
<td class="inline-mono">${formatMoney(item.entry_price)} / ${formatMoney(item.mark_price)}</td>
<td class="inline-mono">${formatNumber(item.size, 4)} / ${formatNumber(item.leverage, 1)}x</td>
<td class="inline-mono">${item.take_profit ? formatMoney(item.take_profit) : '-'} / ${item.stop_loss ? formatMoney(item.stop_loss) : '-'}</td>
<td style="color:${(item.unrealized_pnl || 0) >= 0 ? 'var(--good)' : 'var(--danger)'}">${formatMoney(item.unrealized_pnl)}</td>
<td style="color:${(item.pnl_percent || 0) >= 0 ? 'var(--good)' : 'var(--danger)'}">${formatPercent(item.pnl_percent, 2)}</td>
<td class="inline-mono">${item.opened_at ? relativeTime(item.opened_at) : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function renderUnifiedOrders(orders) {
const toolbar = document.getElementById('ordersToolbar');
const container = document.getElementById('ordersTable');
const total = orders || [];
const entryCount = total.filter((item) => item.category === 'entry').length;
const protectionCount = total.filter((item) => item.category === 'tp_sl').length;
toolbar.innerHTML = `
<span class="toolbar-chip">entry: ${entryCount}</span>
<span class="toolbar-chip">tp/sl: ${protectionCount}</span>
<span class="toolbar-chip">total: ${total.length}</span>
`;
if (!total.length) {
container.innerHTML = '<div class="empty-box">当前没有跨平台挂单</div>';
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>平台</th>
<th>交易对</th>
<th>方向</th>
<th>类别</th>
<th>价格</th>
<th>数量 / 杠杆</th>
<th>信号</th>
<th>时间</th>
</tr>
</thead>
<tbody>
${total.map((item) => `
<tr>
<td><span class="platform-pill">${item.platform}</span></td>
<td><strong>${item.symbol || '-'}</strong></td>
<td><span class="side-pill ${item.side === 'long' ? 'long' : 'short'}">${item.side === 'long' ? 'long' : 'short'}</span></td>
<td class="inline-mono">${item.category === 'tp_sl' ? 'TP/SL' : 'ENTRY'}</td>
<td class="inline-mono">${formatMoney(item.price)}</td>
<td class="inline-mono">${formatNumber(item.size, 4)} / ${item.leverage ? `${formatNumber(item.leverage, 1)}x` : '-'}</td>
<td class="inline-mono">${item.signal_grade || '-'} ${item.signal_type || ''} ${item.confidence ? `/ ${formatPercent(item.confidence, 1)}` : ''}</td>
<td class="inline-mono">${item.created_at ? relativeTime(item.created_at) : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function resumePlatform(platform, button) {
const platformMap = {
PaperTrading: 'PaperTrading',
Bitget: 'Bitget',
Hyperliquid: 'Hyperliquid',
};
const normalized = platformMap[platform] || platform;
try {
button.disabled = true;
button.textContent = '恢复中...';
const response = await fetch('/api/trading/platform-halts/resume', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ platform: normalized }),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.detail || result.message || '恢复失败');
}
setFeedback(`${normalized} 已恢复执行`);
await loadConsole();
} catch (error) {
setFeedback(`恢复平台失败: ${error.message}`, true);
button.disabled = false;
button.textContent = '恢复执行';
}
}
async function loadConsole() {
try {
setFeedback('');
const response = await fetch('/api/system/console');
const result = await response.json();
if (result.status !== 'success') {
throw new Error(result.detail || '总控台数据加载失败');
}
const data = result.data || {};
renderHero(data);
renderPlatforms(data.platforms, data.crypto_agent?.platform_halts);
renderSignalStream(data.signals?.latest || []);
renderAgentSignals(data.crypto_agent, data.signals);
renderDecisionPreview(data.crypto_agent?.last_execution_preview || {});
renderHalts(data.crypto_agent?.platform_halts || {});
renderExecutionEvents(data.execution_events || []);
renderAttentionItems(data.management?.attention_items || []);
renderUnifiedPositions(data.management?.positions || []);
renderUnifiedOrders(data.management?.orders || []);
} catch (error) {
console.error(error);
setFeedback(`总控台加载失败: ${error.message}`, true);
}
}
function applyAutoRefreshState() {
const btn = document.getElementById('toggleAutoRefreshBtn');
btn.textContent = `自动刷新: ${autoRefresh ? '开' : '关'}`;
if (timer) {
clearInterval(timer);
timer = null;
}
if (autoRefresh) {
timer = setInterval(loadConsole, 15000);
}
}
document.getElementById('refreshBtn').addEventListener('click', loadConsole);
document.getElementById('toggleAutoRefreshBtn').addEventListener('click', () => {
autoRefresh = !autoRefresh;
applyAutoRefreshState();
});
document.getElementById('eventFilters').querySelectorAll('[data-filter]').forEach((button) => {
button.addEventListener('click', () => {
currentEventFilter = button.getAttribute('data-filter');
document.getElementById('eventFilters').querySelectorAll('[data-filter]').forEach((item) => {
item.classList.toggle('active', item === button);
});
loadConsole();
});
});
applyAutoRefreshState();
loadConsole();
</script>
</body>
</html>