stock-ai-agent/frontend/console.html
2026-04-25 14:53:05 +08:00

3102 lines
128 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统总控台</title>
<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: 860px;
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);
position: relative;
overflow: hidden;
}
.hero-metric::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--cold), transparent);
opacity: 0.9;
}
.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);
}
.metric-note {
margin-top: 6px;
color: var(--muted);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
}
.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: minmax(0, 1.35fr) minmax(360px, 0.78fr);
gap: 18px;
}
.layout > :only-child {
grid-column: 1 / -1;
}
.left-stack,
.right-stack {
display: grid;
gap: 18px;
}
.section-label {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(126, 200, 255, 0.08);
border: 1px solid rgba(126, 200, 255, 0.16);
color: var(--cold);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-label::before {
content: "";
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 10px currentColor;
}
.panel {
padding: 22px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.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;
}
.priority-layout {
display: grid;
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr);
gap: 18px;
margin-top: 18px;
}
.priority-main,
.priority-side {
display: grid;
gap: 18px;
}
.ops-panel-body {
min-height: 320px;
}
.ops-pane {
display: grid;
gap: 14px;
}
.ops-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.ops-summary-card {
appearance: none;
width: 100%;
text-align: left;
cursor: pointer;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
color: var(--text);
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.ops-summary-card:hover {
transform: translateY(-1px);
border-color: rgba(126, 200, 255, 0.16);
}
.ops-summary-card.active {
border-color: rgba(126, 200, 255, 0.26);
background: rgba(126, 200, 255, 0.10);
}
.ops-summary-card.danger {
border-color: rgba(255, 111, 97, 0.22);
background: rgba(255, 111, 97, 0.08);
}
.ops-summary-card.warn {
border-color: rgba(255, 184, 77, 0.20);
background: rgba(255, 184, 77, 0.08);
}
.ops-summary-kicker {
color: var(--muted);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.ops-summary-headline {
font-family: "IBM Plex Mono", monospace;
font-size: 18px;
color: var(--text);
margin-bottom: 6px;
}
.ops-summary-headline.good {
color: var(--good);
}
.ops-summary-headline.warn {
color: var(--warn);
}
.ops-summary-headline.danger {
color: var(--danger);
}
.ops-summary-detail {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.ops-pane .analysis-log-list,
.ops-pane .halt-list {
margin-top: 2px;
}
.health-ribbon {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
}
.health-card {
padding: 14px 16px;
border-radius: 16px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.health-card .kicker {
color: var(--muted);
font-size: 11px;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.health-card .headline {
font-family: "IBM Plex Mono", monospace;
font-size: 14px;
color: var(--text);
}
.health-card .headline.good {
color: var(--good);
}
.health-card .headline.warn {
color: var(--warn);
}
.health-card .headline.danger {
color: var(--danger);
}
.health-card .detail {
margin-top: 6px;
color: var(--muted);
font-size: 11px;
line-height: 1.55;
font-family: "IBM Plex Mono", monospace;
}
.signal-grid,
.platform-grid {
display: grid;
gap: 14px;
}
.coord-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.coord-block {
padding: 14px;
border-radius: 18px;
background: rgba(255,255,255,0.025);
border: 1px solid rgba(255,255,255,0.06);
}
.coord-block .signal-grid {
grid-template-columns: 1fr;
}
.block-head {
margin-bottom: 12px;
}
.block-title {
margin: 10px 0 4px;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.02em;
}
.block-sub {
color: var(--muted);
font-size: 12px;
font-family: "IBM Plex Mono", monospace;
}
.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-grid.compact {
grid-template-columns: 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;
}
.dense-panel .panel-header {
margin-bottom: 12px;
}
.workspace-panel {
margin-top: 18px;
}
.workspace-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.workspace-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.workspace-tab {
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: 9px 12px;
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.workspace-tab.active {
color: var(--cold);
border-color: rgba(126, 200, 255, 0.28);
background: rgba(126, 200, 255, 0.12);
}
.workspace-tab.muted {
color: rgba(142, 166, 188, 0.72);
border-color: rgba(255,255,255,0.05);
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
margin-left: 6px;
padding: 0 6px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
color: inherit;
font-size: 10px;
line-height: 1;
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
.stream-list {
display: grid;
gap: 10px;
}
.workspace-stream {
min-height: 0;
}
.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);
}
.ghost-btn {
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 12px;
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.ghost-btn.active {
color: var(--cold);
border-color: rgba(126, 200, 255, 0.24);
background: rgba(126, 200, 255, 0.1);
}
.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-item.disabled {
border-color: rgba(255, 184, 77, 0.2);
background: rgba(255, 184, 77, 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;
gap: 8px;
flex-wrap: wrap;
}
.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;
}
.mini-btn.warn {
border-color: rgba(255, 184, 77, 0.24);
background: rgba(255, 184, 77, 0.12);
color: var(--warn);
}
.mini-btn.good {
border-color: rgba(48, 209, 88, 0.22);
background: rgba(48, 209, 88, 0.12);
color: var(--good);
}
.footer-note {
margin-top: 16px;
color: var(--muted);
font-size: 12px;
font-family: "IBM Plex Mono", monospace;
}
.heartbeat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.heartbeat-card {
padding: 12px;
border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.heartbeat-card .label {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 6px;
}
.heartbeat-card .value {
font-size: 13px;
font-family: "IBM Plex Mono", monospace;
color: var(--text);
}
.analysis-log-list {
display: grid;
gap: 10px;
}
.runtime-summary-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 12px;
margin-bottom: 14px;
}
.runtime-summary-card {
padding: 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.runtime-summary-title {
color: var(--muted);
font-size: 11px;
margin-bottom: 10px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.runtime-summary-main {
font-family: "IBM Plex Mono", monospace;
font-size: 16px;
color: var(--text);
margin-bottom: 8px;
}
.runtime-summary-meta {
display: grid;
gap: 8px;
}
.runtime-summary-row {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: var(--muted);
}
.runtime-summary-row strong {
color: var(--text);
font-weight: 500;
}
.blocked-list {
display: grid;
gap: 10px;
}
.blocked-item {
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 184, 77, 0.08);
border: 1px solid rgba(255, 184, 77, 0.16);
}
.blocked-item-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
align-items: center;
}
.blocked-item-title {
font-size: 13px;
color: var(--text);
font-weight: 600;
}
.blocked-item-meta {
color: var(--muted);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
}
.blocked-platforms {
display: grid;
gap: 6px;
margin-top: 10px;
}
.blocked-platform {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 12px;
color: var(--muted);
}
.blocked-platform strong {
color: var(--text);
min-width: 92px;
}
.target-grid,
.decision-account-grid {
display: grid;
gap: 10px;
margin-top: 12px;
}
.target-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.decision-account-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.target-mini-card,
.decision-account-card {
padding: 12px 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
}
.target-mini-card.halted,
.decision-account-card.error {
border-color: rgba(255, 111, 97, 0.22);
background: rgba(255, 111, 97, 0.08);
}
.target-mini-card.disabled {
border-color: rgba(255, 184, 77, 0.22);
background: rgba(255, 184, 77, 0.08);
}
.decision-account-card.warning {
border-color: rgba(255, 184, 77, 0.22);
background: rgba(255, 184, 77, 0.08);
}
.decision-account-card.success {
border-color: rgba(48, 209, 88, 0.20);
background: rgba(48, 209, 88, 0.08);
}
.target-mini-head,
.decision-account-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.target-mini-title,
.decision-account-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.target-mini-meta,
.decision-account-meta {
color: var(--muted);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
}
.target-mini-stats,
.decision-account-detail {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.target-mini-row {
display: flex;
justify-content: space-between;
gap: 10px;
}
.target-mini-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.lane-state-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.lane-state-item {
display: grid;
grid-template-columns: 76px 1fr;
gap: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
}
.lane-state-symbol {
font-family: "IBM Plex Mono", monospace;
color: var(--text);
}
.lane-state-detail {
color: var(--muted);
line-height: 1.5;
}
.analysis-log-item {
padding: 12px 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.analysis-log-item.error {
border-color: rgba(255, 111, 97, 0.2);
background: rgba(255, 111, 97, 0.08);
}
.analysis-log-item.warning,
.analysis-log-item.hold {
border-color: rgba(255, 184, 77, 0.2);
background: rgba(255, 184, 77, 0.08);
}
.analysis-log-item.success {
border-color: rgba(48, 209, 88, 0.18);
background: rgba(48, 209, 88, 0.08);
}
.analysis-log-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 6px;
}
.analysis-log-head strong {
font-size: 13px;
}
.analysis-log-meta {
color: var(--muted);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
}
.analysis-log-detail {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.ops-grid {
display: grid;
gap: 18px;
grid-template-columns: 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);
}
.empty-box.compact {
padding: 14px 16px;
text-align: left;
border-radius: 14px;
}
.empty-box.compact strong {
display: block;
color: var(--text);
font-size: 13px;
margin-bottom: 4px;
}
.empty-detail {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
@media (max-width: 1240px) {
.hero,
.layout,
.priority-layout {
grid-template-columns: 1fr;
}
.platform-grid,
.signal-grid,
.ops-grid,
.coord-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,
.heartbeat-grid,
.health-ribbon,
.ops-summary {
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>
<div class="section-label">Health Layer</div>
<h2 class="panel-title" style="margin-top: 12px;">系统健康总览</h2>
<div class="panel-sub">先判断系统活着没有,再判断风险和堵点</div>
</div>
</div>
<div class="health-ribbon" id="healthRibbon">
<div class="health-card"><div class="kicker">分析状态</div><div class="headline">-</div></div>
<div class="health-card"><div class="kicker">最近轮次</div><div class="headline">-</div></div>
<div class="health-card"><div class="kicker">人工处理</div><div class="headline">-</div></div>
</div>
</section>
<section class="priority-layout">
<div class="priority-main">
<section class="panel">
<div class="panel-header">
<div>
<div class="section-label">Risk Layer</div>
<h2 class="panel-title" style="margin-top: 12px;">管理待处理事项</h2>
<div class="panel-sub">把需要你判断和干预的事情放到最前面</div>
</div>
</div>
<div class="attention-list" id="attentionList">
<div class="loading">正在汇总待处理事项...</div>
</div>
</section>
</div>
<div class="priority-side">
<section class="panel dense-panel">
<div class="workspace-head">
<div>
<div class="section-label">Operations Layer</div>
<h2 class="panel-title" style="margin-top: 12px;">运行监控面板</h2>
<div class="panel-sub">把平台概览、分析心跳和停机熔断收进一个运行面板,避免首屏纵向堆叠</div>
</div>
<div class="panel-actions">
<button class="ghost-btn" id="toggleSensitiveBtn">敏感数据: 隐藏</button>
<div class="workspace-tabs" data-tab-group="ops">
<button class="workspace-tab active" data-tab="ops" data-target="opsPlatform">平台概览</button>
<button class="workspace-tab" data-tab="ops" data-target="opsRuntime">心跳日志</button>
<button class="workspace-tab" data-tab="ops" data-target="opsRisk">停机熔断</button>
</div>
</div>
</div>
<div class="ops-summary" id="opsSummary">
<button class="ops-summary-card active" data-ops-target="opsPlatform">
<div class="ops-summary-kicker">平台概览</div>
<div class="ops-summary-headline">-</div>
<div class="ops-summary-detail">正在汇总平台状态...</div>
</button>
<button class="ops-summary-card" data-ops-target="opsRuntime">
<div class="ops-summary-kicker">心跳日志</div>
<div class="ops-summary-headline">-</div>
<div class="ops-summary-detail">正在汇总分析状态...</div>
</button>
<button class="ops-summary-card" data-ops-target="opsRisk">
<div class="ops-summary-kicker">停机熔断</div>
<div class="ops-summary-headline">-</div>
<div class="ops-summary-detail">正在汇总风险状态...</div>
</button>
</div>
<div class="ops-panel-body">
<div class="tab-pane active ops-pane" data-tab-pane="ops" id="opsPlatform">
<div class="block-head">
<div class="section-label">Platform Layer</div>
<h3 class="block-title">平台执行概览</h3>
<div class="block-sub">资金、杠杆、持仓、挂单、回撤阈值</div>
</div>
<div class="platform-grid" id="platformGrid">
<div class="loading">正在加载平台状态...</div>
</div>
</div>
<div class="tab-pane ops-pane" data-tab-pane="ops" id="opsRuntime">
<div class="block-head">
<div class="section-label">Runtime Layer</div>
<h3 class="block-title">分析心跳与日志</h3>
<div class="block-sub">没有信号时,也能确认系统仍在正常扫盘</div>
</div>
<div class="heartbeat-grid" id="analysisHeartbeat">
<div class="heartbeat-card"><span class="label">最近心跳</span><span class="value">-</span></div>
<div class="heartbeat-card"><span class="label">最近轮次</span><span class="value">-</span></div>
<div class="heartbeat-card"><span class="label">当前进度</span><span class="value">-</span></div>
<div class="heartbeat-card"><span class="label">下一次运行</span><span class="value">-</span></div>
</div>
<div class="runtime-summary-grid">
<div class="runtime-summary-card" id="runtimeSummaryCard">
<div class="runtime-summary-title">运行摘要</div>
<div class="runtime-summary-main">正在整理分析状态...</div>
</div>
<div class="runtime-summary-card" id="guardianSummaryCard">
<div class="runtime-summary-title">执行监管器</div>
<div class="runtime-summary-main">正在整理执行监管状态...</div>
</div>
<div class="runtime-summary-card">
<div class="runtime-summary-title">最近阻塞原因</div>
<div class="blocked-list" id="blockedSummaryList">
<div class="loading">正在读取未落单汇总...</div>
</div>
</div>
</div>
<div class="analysis-log-list" id="guardianTargetList">
<div class="loading">正在读取执行监管目标...</div>
</div>
<div class="analysis-log-list" id="analysisLogList">
<div class="loading">正在读取分析日志...</div>
</div>
</div>
<div class="tab-pane ops-pane" data-tab-pane="ops" id="opsRisk">
<div class="block-head">
<div class="section-label">Risk Layer</div>
<h3 class="block-title">平台停机 / 熔断</h3>
<div class="block-sub">风险触发后,这里应当最先看到</div>
</div>
<div class="halt-list" id="haltList">
<div class="loading">正在读取平台停机状态...</div>
</div>
<div class="footer-note">建议挂在大屏或副屏,默认每 15 秒刷新。</div>
</div>
</div>
</section>
</div>
</section>
</div>
</section>
<section class="panel workspace-panel">
<div class="workspace-head">
<div>
<div class="section-label">Workspace Layer</div>
<h2 class="panel-title" style="margin-top: 12px;">交易工作区</h2>
<div class="panel-sub">次级明细放进 tab保留一个干净的管理视角</div>
</div>
<div class="workspace-tabs" data-tab-group="workspace">
<button class="workspace-tab active" data-tab="workspace" data-target="workspaceCoordination">协同</button>
<button class="workspace-tab" data-tab="workspace" data-target="workspaceSignals">信号流</button>
<button class="workspace-tab" data-tab="workspace" data-target="workspaceExecution">执行流</button>
</div>
</div>
<div class="tab-pane active" data-tab-pane="workspace" id="workspaceCoordination">
<div class="coord-grid">
<div class="coord-block">
<div class="block-head">
<div class="section-label">Agent Layer</div>
<h3 class="block-title">Crypto Agent 状态</h3>
<div class="block-sub">最近信号、平台停机、执行层状态</div>
</div>
<div class="signal-grid" id="agentSignals">
<div class="loading">正在读取 Agent 状态...</div>
</div>
</div>
<div class="coord-block">
<div class="block-head">
<div class="section-label">Decision Layer</div>
<h3 class="block-title">最近决策预览</h3>
<div class="block-sub">最近一轮各平台准备执行的动作</div>
</div>
<div class="signal-grid" id="decisionPreview">
<div class="loading">正在读取决策预览...</div>
</div>
</div>
</div>
</div>
<div class="tab-pane" data-tab-pane="workspace" id="workspaceSignals">
<div class="block-head">
<div class="section-label">Market Layer</div>
<h3 class="block-title">最近信号流</h3>
<div class="block-sub">数据库最新信号与等级、置信度、时间分布</div>
</div>
<div class="stream-list workspace-stream" id="signalStream">
<div class="loading">正在读取信号...</div>
</div>
</div>
<div class="tab-pane" data-tab-pane="workspace" id="workspaceExecution">
<div class="block-head">
<div class="section-label">Execution Layer</div>
<h3 class="block-title">执行事件流</h3>
<div class="block-sub">最近执行结果、未执行原因、平台异常</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 workspace-stream" id="eventStream">
<div class="loading">正在读取执行事件...</div>
</div>
</div>
</section>
<section class="panel workspace-panel unified-section">
<div class="workspace-head">
<div>
<div class="section-label">Asset Layer</div>
<h2 class="panel-title" style="margin-top: 12px;">资产与挂单</h2>
<div class="panel-sub">统一持仓和统一挂单改为切换查看,减少表格同时占屏</div>
</div>
<div class="workspace-tabs" data-tab-group="asset">
<button class="workspace-tab active" data-tab="asset" data-target="assetPositions">持仓</button>
<button class="workspace-tab" data-tab="asset" data-target="assetOrders">挂单</button>
</div>
</div>
<div class="tab-pane active" data-tab-pane="asset" id="assetPositions">
<div class="block-head">
<div class="section-label">Position Layer</div>
<h3 class="block-title">统一持仓视图</h3>
<div class="block-sub">三端持仓合并,优先看风险与盈亏</div>
</div>
<div class="table-toolbar" id="positionsToolbar"></div>
<div class="unified-table" id="positionsTable">
<div class="loading">正在整理跨平台持仓...</div>
</div>
</div>
<div class="tab-pane" data-tab-pane="asset" id="assetOrders">
<div class="block-head">
<div class="section-label">Order Layer</div>
<h3 class="block-title">统一挂单视图</h3>
<div class="block-sub">入场单、保护单、资金占用一屏观察</div>
</div>
<div class="table-toolbar" id="ordersToolbar"></div>
<div class="unified-table" id="ordersTable">
<div class="loading">正在整理跨平台挂单...</div>
</div>
</div>
</section>
</div>
<script>
let autoRefresh = true;
let timer = null;
let currentEventFilter = 'all';
let cachedExecutionEvents = [];
let cachedConsoleData = null;
let revealSensitiveData = false;
const SENSITIVE_VISIBILITY_KEY = 'console_sensitive_visible_v2';
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']
.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 compactEmpty(title, detail = '') {
return `
<div class="empty-box compact">
<strong>${title}</strong>
${detail ? `<div class="empty-detail">${detail}</div>` : ''}
</div>
`;
}
function loadSensitivePreference() {
try {
revealSensitiveData = window.localStorage.getItem(SENSITIVE_VISIBILITY_KEY) === '1';
} catch {
revealSensitiveData = false;
}
renderSensitiveToggle();
}
function renderSensitiveToggle() {
const button = document.getElementById('toggleSensitiveBtn');
if (!button) return;
button.textContent = `敏感数据: ${revealSensitiveData ? '显示' : '隐藏'}`;
button.classList.toggle('active', revealSensitiveData);
}
function toggleSensitiveData() {
revealSensitiveData = !revealSensitiveData;
try {
window.localStorage.setItem(SENSITIVE_VISIBILITY_KEY, revealSensitiveData ? '1' : '0');
} catch {
// ignore storage errors
}
renderSensitiveToggle();
if (cachedConsoleData) {
renderPlatforms(
cachedConsoleData.platforms,
cachedConsoleData.crypto_agent?.platform_halts,
cachedConsoleData.crypto_agent?.target_execution_controls || {}
);
}
}
function formatSensitiveMoney(value) {
return revealSensitiveData ? formatMoney(value) : '$••••';
}
function initTabs() {
document.querySelectorAll('[data-tab-group]').forEach((groupEl) => {
const group = groupEl.getAttribute('data-tab-group');
const buttons = groupEl.querySelectorAll('[data-tab]');
buttons.forEach((button) => {
button.addEventListener('click', () => {
const target = button.getAttribute('data-target');
setActiveTab(group, target);
});
});
});
document.querySelectorAll('[data-ops-target]').forEach((button) => {
button.addEventListener('click', () => {
setActiveTab('ops', button.getAttribute('data-ops-target'));
});
});
}
function setActiveTab(group, target) {
document.querySelectorAll(`[data-tab="${group}"]`).forEach((button) => {
button.classList.toggle('active', button.getAttribute('data-target') === target);
});
document.querySelectorAll(`[data-tab-pane="${group}"]`).forEach((pane) => {
pane.classList.toggle('active', pane.id === target);
});
if (group === 'ops') {
document.querySelectorAll('[data-ops-target]').forEach((card) => {
card.classList.toggle('active', card.getAttribute('data-ops-target') === target);
});
}
}
function getActiveTabTarget(group) {
return document.querySelector(`[data-tab="${group}"].active`)?.getAttribute('data-target') || null;
}
function updateTabButton(group, target, label, count, hasData) {
const button = document.querySelector(`[data-tab="${group}"][data-target="${target}"]`);
if (!button) return;
button.innerHTML = `${label}<span class="tab-count">${count}</span>`;
button.classList.toggle('muted', !hasData);
}
function renderOpsSummary(data) {
const container = document.getElementById('opsSummary');
if (!container) return;
const platforms = data.platforms || {};
const cryptoAgent = data.crypto_agent || {};
const monitor = cryptoAgent.analysis_monitor || {};
const halts = cryptoAgent.platform_halts || {};
const executionControls = cryptoAgent.target_execution_controls || {};
const enabledPlatforms = ['paper', 'bitget'].filter((key) => platforms?.[key]?.enabled !== false);
const haltedCount = countHalted(halts);
const disabledCount = Object.values(executionControls).filter((item) => item && item.enabled === false).length;
const runtimeTone = toneClassForHealth(cryptoAgent.running ? monitor.last_cycle_status || monitor.last_analysis_status : 'stopped');
const riskTone = haltedCount > 0 ? 'danger' : ((disabledCount > 0) || (data.management?.attention_items || []).some((item) => item.severity === 'danger' || item.severity === 'warning') ? 'warn' : 'good');
const platformTone = haltedCount > 0 || disabledCount > 0 ? 'warn' : 'good';
const platformHeadline = `${enabledPlatforms.length} 平台`;
const platformDetail = `${sumPlatformPositions(platforms)} 持仓 / ${['paper', 'bitget'].map((key) => platforms?.[key]?.orders?.count || 0).reduce((a, b) => a + b, 0)} 挂单`;
const runtimeHeadline = monitor.last_heartbeat_at ? relativeTime(monitor.last_heartbeat_at) : '无心跳';
const runtimeDetail = `状态 ${String(monitor.last_cycle_status || monitor.last_analysis_status || 'idle').toUpperCase()} / ${monitor.current_cycle_total ? `${monitor.current_cycle_index || 0}/${monitor.current_cycle_total}` : '待机'}`;
const riskHeadline = haltedCount > 0 ? `${haltedCount} 停机` : (disabledCount > 0 ? `${disabledCount} 关闭` : '无停机');
const attentionItems = data.management?.attention_items || [];
const riskDetail = haltedCount > 0
? '已有平台触发停机或熔断,建议优先查看。'
: disabledCount > 0
? `${disabledCount} 个执行目标被人工关闭自动交易。`
: attentionItems.length > 0
? `待处理 ${attentionItems.length} 项,建议检查风险与执行事件。`
: '当前没有明显风险阻塞。';
container.innerHTML = `
<button class="ops-summary-card ${platformTone}" data-ops-target="opsPlatform">
<div class="ops-summary-kicker">平台概览</div>
<div class="ops-summary-headline ${platformTone}">${platformHeadline}</div>
<div class="ops-summary-detail">${platformDetail}</div>
</button>
<button class="ops-summary-card ${runtimeTone}" data-ops-target="opsRuntime">
<div class="ops-summary-kicker">心跳日志</div>
<div class="ops-summary-headline ${runtimeTone}">${runtimeHeadline}</div>
<div class="ops-summary-detail">${runtimeDetail}</div>
</button>
<button class="ops-summary-card ${riskTone}" data-ops-target="opsRisk">
<div class="ops-summary-kicker">停机熔断</div>
<div class="ops-summary-headline ${riskTone}">${riskHeadline}</div>
<div class="ops-summary-detail">${riskDetail}</div>
</button>
`;
container.querySelectorAll('[data-ops-target]').forEach((button) => {
button.addEventListener('click', () => {
setActiveTab('ops', button.getAttribute('data-ops-target'));
});
});
setActiveTab('ops', getActiveTabTarget('ops') || 'opsPlatform');
}
function syncTabState(data) {
const recentSignals = data.signals?.latest || [];
const executionEvents = data.execution_events || [];
const positions = data.management?.positions || [];
const orders = data.management?.orders || [];
const platformCount = ['paper', 'bitget']
.filter((key) => data.platforms?.[key]?.enabled !== false)
.length;
const lastSignals = Object.keys(data.crypto_agent?.last_signals || {}).length;
const previews = Object.keys(data.crypto_agent?.last_execution_preview || {}).length;
const statsTotal = Number(data.signals?.stats_7d?.total || 0);
const coordinationCount = lastSignals + previews + (statsTotal > 0 ? 1 : 0);
const haltedCount = countHalted(data.crypto_agent?.platform_halts || {});
const disabledCount = Object.values(data.crypto_agent?.target_execution_controls || {}).filter((item) => item && item.enabled === false).length;
const runtimeCount = (data.crypto_agent?.recent_analysis_events || []).length + (data.crypto_agent?.analysis_monitor?.last_heartbeat_at ? 1 : 0);
updateTabButton('ops', 'opsPlatform', '平台概览', platformCount, platformCount > 0);
updateTabButton('ops', 'opsRuntime', '心跳日志', runtimeCount, runtimeCount > 0);
updateTabButton('ops', 'opsRisk', '停机熔断', haltedCount + disabledCount, haltedCount + disabledCount > 0);
updateTabButton('workspace', 'workspaceCoordination', '协同', coordinationCount, coordinationCount > 0);
updateTabButton('workspace', 'workspaceSignals', '信号流', recentSignals.length, recentSignals.length > 0);
updateTabButton('workspace', 'workspaceExecution', '执行流', executionEvents.length, executionEvents.length > 0);
updateTabButton('asset', 'assetPositions', '持仓', positions.length, positions.length > 0);
updateTabButton('asset', 'assetOrders', '挂单', orders.length, orders.length > 0);
const opsCurrent = getActiveTabTarget('ops');
const opsChoices = [
{ target: 'opsRisk', hasData: haltedCount + disabledCount > 0 },
{ target: 'opsRuntime', hasData: runtimeCount > 0 },
{ target: 'opsPlatform', hasData: platformCount > 0 },
];
if (!opsChoices.find((item) => item.target === opsCurrent && item.hasData)) {
setActiveTab('ops', opsChoices.find((item) => item.hasData)?.target || 'opsPlatform');
}
const workspaceCurrent = getActiveTabTarget('workspace');
const workspaceChoices = [
{ target: 'workspaceCoordination', hasData: coordinationCount > 0 },
{ target: 'workspaceExecution', hasData: executionEvents.length > 0 },
{ target: 'workspaceSignals', hasData: recentSignals.length > 0 },
];
if (!workspaceChoices.find((item) => item.target === workspaceCurrent && item.hasData)) {
setActiveTab('workspace', workspaceChoices.find((item) => item.hasData)?.target || 'workspaceCoordination');
}
const assetCurrent = getActiveTabTarget('asset');
const assetChoices = [
{ target: 'assetPositions', hasData: positions.length > 0 },
{ target: 'assetOrders', hasData: orders.length > 0 },
];
if (!assetChoices.find((item) => item.target === assetCurrent && item.hasData)) {
setActiveTab('asset', assetChoices.find((item) => item.hasData)?.target || 'assetPositions');
}
}
function toneClassForHealth(status) {
if (['error', 'failed', 'stopped'].includes(String(status || '').toLowerCase())) return 'danger';
if (['warning', 'halted', 'idle'].includes(String(status || '').toLowerCase())) return 'warn';
return 'good';
}
function renderHealthRibbon(data) {
const container = document.getElementById('healthRibbon');
const cryptoAgent = data.crypto_agent || {};
const monitor = cryptoAgent.analysis_monitor || {};
const attentionItems = data.management?.attention_items || [];
const dangerCount = attentionItems.filter((item) => item.severity === 'danger').length;
const warningCount = attentionItems.filter((item) => item.severity === 'warning').length;
const haltedCount = countHalted(cryptoAgent.platform_halts || {});
const cycleStatus = cryptoAgent.running
? String(monitor.last_cycle_status || 'waiting').toUpperCase()
: 'STOPPED';
const cycleTone = toneClassForHealth(cryptoAgent.running ? monitor.last_cycle_status : 'stopped');
const attentionTone = attentionItems.length === 0
? 'good'
: dangerCount > 0 || haltedCount > 0
? 'danger'
: 'warn';
const analysisTone = toneClassForHealth(monitor.last_analysis_status || 'idle');
const lastCycleText = monitor.last_cycle_completed_at
? `${relativeTime(monitor.last_cycle_completed_at)}`
: '尚无完成轮次';
const nextRunText = monitor.next_scheduled_run_at
? formatTime(monitor.next_scheduled_run_at)
: (monitor.current_cycle_total ? '轮次进行中' : '-');
container.innerHTML = `
<div class="health-card">
<div class="kicker">分析状态</div>
<div class="headline ${cycleTone}">${cycleStatus}</div>
<div class="detail">最近分析: ${(monitor.last_analysis_symbol || '-')} / ${(monitor.last_analysis_status || '-')}</div>
</div>
<div class="health-card">
<div class="kicker">最近轮次</div>
<div class="headline ${analysisTone}">${lastCycleText}</div>
<div class="detail">下一次运行: ${nextRunText}</div>
</div>
<div class="health-card">
<div class="kicker">人工处理</div>
<div class="headline ${attentionTone}">${attentionItems.length} 项</div>
<div class="detail">danger ${dangerCount} / warning ${warningCount} / halted ${haltedCount}</div>
</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 executionControls = data.crypto_agent?.target_execution_controls || {};
const haltedCount = countHalted(platformHalts);
const disabledCount = Object.values(executionControls).filter((item) => item && item.enabled === false).length;
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 class="metric-note">异常 ${system.error_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 class="metric-note">最新分析窗口</div>
</div>
<div class="hero-metric">
<div class="metric-label">活跃持仓</div>
<div class="metric-value">${sumPlatformPositions(platforms)}</div>
<div class="metric-note">模拟盘 + Bitget 合计</div>
</div>
<div class="hero-metric">
<div class="metric-label">停机 / 关闭</div>
<div class="metric-value ${haltedCount > 0 ? 'danger' : 'good'}">${haltedCount}</div>
<div class="metric-note">停机 ${haltedCount} / 关闭 ${disabledCount}</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, executionControls) {
const container = document.getElementById('platformGrid');
const entries = [
{ key: 'paper', title: '模拟盘', subtitle: '执行基准 / 策略验证' },
{ key: 'bitget', title: 'Bitget', subtitle: 'U 本位实盘' },
];
container.innerHTML = entries.map(({ key, title, subtitle }) => {
const item = platforms?.[key] || { enabled: false };
const haltKey = key === 'paper'
? 'PaperTrading'
: (Object.keys(platformHalts || {}).find((targetKey) => targetKey.startsWith('Bitget:')) || 'Bitget');
const bitgetHaltActive = key === 'bitget'
? Object.entries(platformHalts || {}).some(([targetKey, haltItem]) => targetKey.startsWith('Bitget:') && haltItem?.halted)
: false;
const halt = platformHalts?.[haltKey] || {};
const platformExecutionControl = key === 'paper'
? (executionControls?.[haltKey] || {})
: null;
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);
const accounts = Array.isArray(item.accounts) ? item.accounts : [];
const disabledBitgetCount = key === 'bitget'
? accounts.filter((accountItem) => executionControls?.[`Bitget:${accountItem.account_id}`]?.enabled === false).length
: 0;
const accountRows = key === 'bitget' && accounts.length
? `
<div class="target-grid">
${accounts.slice(0, 4).map((accountItem) => {
const accountTargetKey = `Bitget:${accountItem.account_id}`;
const accountHalt = platformHalts?.[accountTargetKey] || {};
const accountControl = executionControls?.[accountTargetKey] || {};
const accountLeverage = accountItem.risk?.current_leverage || 0;
return `
<div class="target-mini-card ${accountHalt.halted ? 'halted' : (accountControl.enabled === false ? 'disabled' : '')}">
<div class="target-mini-head">
<div class="target-mini-title">${accountItem.account_id}</div>
<div class="target-mini-meta">${accountHalt.halted ? 'HALTED' : (accountControl.enabled === false ? 'DISABLED' : 'ACTIVE')}</div>
</div>
<div class="target-mini-stats">
<div class="target-mini-row"><span>权益</span><strong>${formatSensitiveMoney(accountItem.account?.account_value || 0)}</strong></div>
<div class="target-mini-row"><span>可用 / 保证金</span><strong>${formatSensitiveMoney(accountItem.account?.available_balance || 0)} / ${formatSensitiveMoney(accountItem.account?.total_margin_used || 0)}</strong></div>
<div class="target-mini-row"><span>持仓 / 挂单</span><strong>${accountItem.positions?.count || 0} / ${accountItem.orders?.count || 0}</strong></div>
<div class="target-mini-row"><span>总杠杆 / 回撤</span><strong>${formatNumber(accountLeverage, 2)}x / ${formatPercent(accountItem.risk?.drawdown_percent || 0, 1)}</strong></div>
<div class="target-mini-row"><span>自动交易</span><strong>${accountControl.enabled === false ? 'OFF' : 'ON'}</strong></div>
</div>
<div class="target-mini-actions">
<button class="mini-btn ${accountControl.enabled === false ? 'good' : 'warn'}" data-toggle-target="${accountTargetKey}" data-enabled="${accountControl.enabled === false ? 'true' : 'false'}">
${accountControl.enabled === false ? '开启自动交易' : '关闭自动交易'}
</button>
</div>
</div>
`;
}).join('')}
</div>
`
: '';
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 || bitgetHaltActive) ? 'HALTED' : ((key === 'paper' && platformExecutionControl?.enabled === false) ? 'MANUAL OFF' : '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">${formatSensitiveMoney(account.current_balance || account.account_value)}</span>
</div>
<div class="platform-stat">
<span class="label">可用</span>
<span class="value">${formatSensitiveMoney(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 class="platform-stat">
<span class="label">自动交易</span>
<span class="value">${key === 'paper' ? (platformExecutionControl?.enabled === false ? 'OFF' : 'ON') : `${accounts.length - disabledBitgetCount}/${accounts.length} ON`}</span>
</div>
</div>
${key === 'paper' ? `
<div class="halt-actions" style="margin-top: 14px; justify-content: flex-start;">
<button class="mini-btn ${platformExecutionControl?.enabled === false ? 'good' : 'warn'}" data-toggle-target="${haltKey}" data-enabled="${platformExecutionControl?.enabled === false ? 'true' : 'false'}">
${platformExecutionControl?.enabled === false ? '开启自动交易' : '关闭自动交易'}
</button>
</div>
` : ''}
${accountRows}
</article>
`;
}).join('');
container.querySelectorAll('[data-toggle-target]').forEach((button) => {
button.addEventListener('click', async () => {
const targetKey = button.getAttribute('data-toggle-target');
const enabled = button.getAttribute('data-enabled') === 'true';
await updateExecutionControl(targetKey, enabled, button);
});
});
}
function renderSignalStream(signals) {
const container = document.getElementById('signalStream');
if (!signals || signals.length === 0) {
container.innerHTML = compactEmpty('最近没有可展示信号', '等待新的分析结果写入,运行状态可继续看上方心跳。');
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</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">Buy</span><span class="value">${signalStats?.stats_7d?.crypto?.buy || 0}</span></div>
<div class="stat-chip"><span class="label">Sell</span><span class="value">${signalStats?.stats_7d?.crypto?.sell || 0}</span></div>
</div>
</article>
`);
if (entries.length === 0) {
cards.push(compactEmpty('Crypto Agent 暂无最近信号缓存', '当前没有缓存到最近信号,可切到信号流或执行流继续查看。'));
} 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 renderAnalysisHeartbeat(analysisMonitor, analysisEvents) {
const heartbeat = document.getElementById('analysisHeartbeat');
const logList = document.getElementById('analysisLogList');
const summaryCard = document.getElementById('runtimeSummaryCard');
const guardianCard = document.getElementById('guardianSummaryCard');
const guardianTargetList = document.getElementById('guardianTargetList');
const monitor = analysisMonitor || {};
const notifications = cachedConsoleData?.crypto_agent?.analysis_notifications || {};
const schedule = cachedConsoleData?.crypto_agent?.llm_schedule || {};
const laneState = cachedConsoleData?.crypto_agent?.lane_analysis_state || {};
const eventState = cachedConsoleData?.crypto_agent?.event_analysis_state || {};
const guardian = cachedConsoleData?.crypto_agent?.execution_guardian || {};
const cycleStatus = monitor.last_cycle_status || 'idle';
const progressText = monitor.current_cycle_total
? `${monitor.current_cycle_index || 0}/${monitor.current_cycle_total} ${monitor.current_cycle_symbol || ''}`
: '-';
heartbeat.innerHTML = `
<div class="heartbeat-card">
<span class="label">最近心跳</span>
<span class="value">${monitor.last_heartbeat_at ? `${relativeTime(monitor.last_heartbeat_at)} / ${formatTime(monitor.last_heartbeat_at)}` : '-'}</span>
</div>
<div class="heartbeat-card">
<span class="label">最近轮次</span>
<span class="value">${cycleStatus}${monitor.last_cycle_completed_at ? ` / ${relativeTime(monitor.last_cycle_completed_at)}` : ''}</span>
</div>
<div class="heartbeat-card">
<span class="label">当前进度</span>
<span class="value">${progressText}</span>
</div>
<div class="heartbeat-card">
<span class="label">下一次运行</span>
<span class="value">${monitor.next_scheduled_run_at ? formatTime(monitor.next_scheduled_run_at) : '-'}</span>
</div>
`;
const heartbeatSentAt = notifications.last_heartbeat_notified_at;
const lastSignalAt = notifications.last_signal_at;
const lastSignalSymbol = notifications.last_signal_symbol || '-';
const laneRows = Object.entries(laneState).slice(0, 4).map(([symbol, state]) => `
<div class="lane-state-item">
<div class="lane-state-symbol">${symbol}</div>
<div class="lane-state-detail">
日内 ${state.last_intraday_at ? relativeTime(state.last_intraday_at) : '-'} /
趋势 ${state.last_trend_at ? relativeTime(state.last_trend_at) : '-'}
${state.last_force_reason ? `<br>强触发: ${state.last_force_reason}` : ''}
</div>
</div>
`).join('');
const latestEventTrigger = Object.entries(eventState)
.map(([symbol, state]) => ({ symbol, ...state }))
.filter((state) => state.last_triggered_at)
.sort((a, b) => new Date(b.last_triggered_at) - new Date(a.last_triggered_at))[0];
summaryCard.innerHTML = `
<div class="runtime-summary-title">运行摘要</div>
<div class="runtime-summary-main">${monitor.last_analysis_status || 'idle'} / ${monitor.last_analysis_symbol || '-'}</div>
<div class="runtime-summary-meta">
<div class="runtime-summary-row"><span>最近分析说明</span><strong>${monitor.last_analysis_detail || '-'}</strong></div>
<div class="runtime-summary-row"><span>最近信号</span><strong>${lastSignalAt ? `${lastSignalSymbol} / ${relativeTime(lastSignalAt)}` : '近 60 分钟无信号'}</strong></div>
<div class="runtime-summary-row"><span>上次心跳通知</span><strong>${heartbeatSentAt ? `${relativeTime(heartbeatSentAt)} / ${formatTime(heartbeatSentAt)}` : '尚未发送'}</strong></div>
<div class="runtime-summary-row"><span>LLM 冷却</span><strong>日内 ${schedule.intraday_cooldown_minutes || '-'}m / 趋势 ${schedule.trend_cooldown_minutes || '-'}m</strong></div>
<div class="runtime-summary-row"><span>事件触发</span><strong>${schedule.event_analysis_enabled ? `${schedule.event_analysis_window_minutes || '-'}m / ${formatPercent(schedule.event_analysis_price_change_percent || 0, 1)} / 冷却${schedule.event_analysis_cooldown_minutes || '-'}m` : '关闭'}</strong></div>
<div class="runtime-summary-row"><span>最近异动分析</span><strong>${latestEventTrigger ? `${latestEventTrigger.symbol} / ${relativeTime(latestEventTrigger.last_triggered_at)}` : '暂无'}</strong></div>
</div>
<div class="lane-state-list">${laneRows || '<div class="analysis-log-detail">暂无 lane 状态,等待下一轮分析。</div>'}</div>
`;
const guardianTargets = Array.isArray(guardian.targets) ? guardian.targets : [];
const guardianActions = Array.isArray(guardian.last_actions) ? guardian.last_actions : [];
const guardianTargetRows = guardianTargets.slice(0, 4).map((target) => `
<div class="runtime-summary-row">
<span>${target.target_key}</span>
<strong>${target.supports_tpsl_repair ? '监管 + 保护单' : '监管'}</strong>
</div>
`).join('');
const latestGuardianAction = guardianActions[0];
if (guardianCard) {
guardianCard.innerHTML = `
<div class="runtime-summary-title">执行监管器</div>
<div class="runtime-summary-main">${guardian.last_status || 'idle'} / ${guardian.last_run_at ? relativeTime(guardian.last_run_at) : '-'}</div>
<div class="runtime-summary-meta">
<div class="runtime-summary-row"><span>监管目标</span><strong>${guardianTargets.length}</strong></div>
<div class="runtime-summary-row"><span>最近错误</span><strong>${guardian.last_error || '无'}</strong></div>
<div class="runtime-summary-row"><span>最近动作</span><strong>${latestGuardianAction ? `${latestGuardianAction.action_type} / ${latestGuardianAction.symbol || latestGuardianAction.platform}` : '暂无'}</strong></div>
</div>
<div class="lane-state-list">${guardianTargetRows || '<div class="analysis-log-detail">暂无已注册监管目标。</div>'}</div>
`;
}
if (guardianTargetList) {
const bitgetAccounts = cachedConsoleData?.platforms?.bitget?.accounts || [];
const bitgetAccountMap = Object.fromEntries(bitgetAccounts.map((account) => [String(account.account_id), account]));
guardianTargetList.innerHTML = guardianTargets.length
? guardianTargets.map((target) => {
const account = target.platform === 'Bitget' ? (bitgetAccountMap[String(target.account_id)] || {}) : {};
const positionsCount = account.positions?.count || 0;
const ordersCount = account.orders?.count || 0;
const isHalted = !!(cachedConsoleData?.crypto_agent?.platform_halts?.[target.target_key]?.halted);
return `
<div class="target-mini-card ${isHalted ? 'halted' : ''}">
<div class="target-mini-head">
<div class="target-mini-title">${target.target_key}</div>
<div class="target-mini-meta">${target.platform} / account=${target.account_id}</div>
</div>
<div class="target-mini-stats">
<div class="target-mini-row"><span>挂单超时</span><strong>${target.supports_pending_timeout ? 'on' : 'off'}</strong></div>
<div class="target-mini-row"><span>持仓管理</span><strong>${target.supports_position_management ? 'on' : 'off'}</strong></div>
<div class="target-mini-row"><span>保护单补救</span><strong>${target.supports_tpsl_repair ? 'on' : 'off'}</strong></div>
<div class="target-mini-row"><span>仓位 / 挂单</span><strong>${positionsCount} / ${ordersCount}</strong></div>
</div>
</div>
`;
}).join('')
: compactEmpty('暂无执行监管目标', '注册多个交易所或多个账号后,这里会展开显示每个 target。');
}
if (!analysisEvents || analysisEvents.length === 0) {
logList.innerHTML = compactEmpty('最近还没有分析日志', '等待下一轮分析或新的运行事件写入。');
return;
}
logList.innerHTML = analysisEvents.map((event) => `
<div class="analysis-log-item ${event.status || 'info'}">
<div class="analysis-log-head">
<strong>${event.symbol || 'SYSTEM'} · ${event.event_type || '-'}</strong>
<span class="analysis-log-meta">${relativeTime(event.timestamp)} / ${formatTime(event.timestamp)}</span>
</div>
<div class="analysis-log-detail">${event.detail || '-'}</div>
</div>
`).join('');
}
function renderBlockedSummaries(events = cachedExecutionEvents) {
const container = document.getElementById('blockedSummaryList');
const blockedEvents = (Array.isArray(events) ? events : [])
.filter((event) => event.event_type === 'execution_blocked_summary')
.slice(0, 3);
if (!blockedEvents.length) {
container.innerHTML = compactEmpty('最近没有未落单阻塞', '出现执行阻塞时,这里会按平台归类展示原因。');
return;
}
container.innerHTML = blockedEvents.map((event) => {
const blockedPlatforms = event.blocked_platforms || [];
return `
<div class="blocked-item">
<div class="blocked-item-head">
<div class="blocked-item-title">${event.symbol || '-'} · ${event.signal_action_text || '-'} / ${event.signal_timeframe_text || '-'}</div>
<div class="blocked-item-meta">${relativeTime(event.timestamp)} / ${formatTime(event.timestamp)}</div>
</div>
<div class="analysis-log-detail">建议价 ${formatMoney(event.entry_price)} / 现价 ${formatMoney(event.current_price)} / 信心 ${formatPercent(event.confidence || 0, 1)}</div>
<div class="blocked-platforms">
${blockedPlatforms.map((item) => `
<div class="blocked-platform">
<strong>${item.target_key || item.platform}</strong>
<span>${item.tag} | ${item.detail}</span>
</div>
`).join('')}
</div>
</div>
`;
}).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 = compactEmpty('暂无最近决策预览', '还没有形成最近一轮执行预览,通常意味着还没出现可执行信号。');
return;
}
const cards = [];
entries.slice(0, 4).forEach(([symbol, preview]) => {
const paper = summarizeDecision(preview.paper);
const bitget = summarizeDecision(preview.bitget);
const bitgetAccounts = Object.entries(preview.bitget_accounts || {});
const bitgetAccountDetails = bitgetAccounts.length
? bitgetAccounts.map(([accountId, decision]) => {
const summary = summarizeDecision(decision);
return `
<div class="decision-account-card ${summary.tone}">
<div class="decision-account-head">
<div class="decision-account-title">${accountId}</div>
<div class="decision-account-meta">${summary.label}</div>
</div>
<div class="decision-account-detail">${summary.detail}</div>
</div>
`;
}).join('')
: `
<div class="decision-account-card ${bitget.tone}">
<div class="decision-account-head">
<div class="decision-account-title">default</div>
<div class="decision-account-meta">${bitget.label}</div>
</div>
<div class="decision-account-detail">${bitget.detail}</div>
</div>
`;
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 ${bitget.tone}"><span class="label">Bitget</span><span class="value">${bitget.label}</span></div>
</div>
${(preview.paper?.setup_type || preview.bitget?.setup_type)
? `
<div style="margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap;">
${preview.paper?.setup_type ? `<span class="event-inline-badge">Paper ${preview.paper.setup_type}</span>` : ''}
${preview.bitget?.setup_type ? `<span class="event-inline-badge">Bitget ${preview.bitget.setup_type}</span>` : ''}
</div>
`
: ''}
${(preview.paper?.entry_basis || preview.paper?.setup_basis || preview.bitget?.entry_basis || preview.bitget?.setup_basis)
? `
<div style="margin-top: 10px;" class="analysis-log-detail">
${preview.paper?.entry_basis || preview.paper?.setup_basis ? `<div>模拟盘: ${preview.paper.entry_basis || preview.paper.setup_basis}</div>` : ''}
${preview.bitget?.entry_basis || preview.bitget?.setup_basis ? `<div>Bitget: ${preview.bitget.entry_basis || preview.bitget.setup_basis}</div>` : ''}
</div>
`
: ''}
<div style="margin-top: 12px; color: var(--muted); font-size: 12px; line-height: 1.6;">
模拟盘: ${paper.detail}
</div>
<div class="decision-account-grid">
${bitgetAccountDetails}
</div>
</article>
`);
});
container.innerHTML = cards.join('');
}
function renderHalts(platformHalts, executionControls) {
const container = document.getElementById('haltList');
const bitgetTargets = Object.keys(platformHalts || {})
.filter((key) => key.startsWith('Bitget:'))
.sort();
const entries = [
['PaperTrading', '模拟盘'],
...bitgetTargets.map((key) => [key, key]),
];
container.innerHTML = entries.map(([key, label]) => {
const halt = platformHalts?.[key] || {};
const active = !!halt.halted;
const control = executionControls?.[key] || {};
const disabled = control.enabled === false;
return `
<div class="halt-item ${active ? 'active' : (disabled ? 'disabled' : '')}">
<div class="halt-top">
<div class="halt-name">${label}</div>
<div class="halt-state">${active ? 'HALTED' : (disabled ? 'MANUAL OFF' : 'ACTIVE')}</div>
</div>
<div class="halt-reason">
${active
? `${halt.reason || '已触发平台停机'}<br>回撤: ${formatPercent(halt.drawdown_pct || 0)} | 触发时间: ${formatTime(halt.halted_at)}`
: disabled
? `${control.reason || '已被人工关闭自动交易'}<br>更新时间: ${formatTime(control.updated_at)}`
: '运行正常,未触发平台停机或熔断。'}
</div>
<div class="halt-actions">
<button class="mini-btn ${disabled ? 'good' : 'warn'}" data-toggle-target="${key}" data-enabled="${disabled ? 'true' : 'false'}">
${disabled ? '开启自动交易' : '关闭自动交易'}
</button>
${active ? `<button class="mini-btn" data-platform="${key}" data-target-key="${key}">恢复执行</button>` : ''}
</div>
</div>
`;
}).join('');
container.querySelectorAll('[data-platform]').forEach((button) => {
button.addEventListener('click', async () => {
const platform = button.getAttribute('data-platform');
const targetKey = button.getAttribute('data-target-key');
await resumePlatform(platform, button, targetKey);
});
});
container.querySelectorAll('[data-toggle-target]').forEach((button) => {
button.addEventListener('click', async () => {
const targetKey = button.getAttribute('data-toggle-target');
const enabled = button.getAttribute('data-enabled') === 'true';
await updateExecutionControl(targetKey, enabled, button);
});
});
}
function renderExecutionEvents(events = cachedExecutionEvents) {
const container = document.getElementById('eventStream');
cachedExecutionEvents = Array.isArray(events) ? events : [];
const filtered = cachedExecutionEvents.filter((event) => currentEventFilter === 'all' || event.status === currentEventFilter);
if (!filtered || filtered.length === 0) {
container.innerHTML = compactEmpty('最近没有匹配的执行事件', currentEventFilter === 'all' ? '当前没有新的执行结果或异常事件。' : `筛选条件为 ${currentEventFilter.toUpperCase()},当前暂无匹配记录。`);
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.account_id ? `<span class="event-inline-badge">acct ${event.account_id}</span>` : ''}
${event.target_key ? `<span class="event-inline-badge">${event.target_key}</span>` : ''}
${event.decision ? `<span class="event-inline-badge">${event.decision}</span>` : ''}
${event.action ? `<span class="event-inline-badge">${event.action}</span>` : ''}
${event.signal_timeframe_text ? `<span class="event-inline-badge">${event.signal_timeframe_text}</span>` : ''}
${event.setup_type ? `<span class="event-inline-badge">${event.setup_type}</span>` : ''}
</div>
<span style="color: var(--muted);">${event.reason || '无说明'}</span>
${event.setup_basis || event.entry_basis ? `
<div class="analysis-log-detail" style="margin-top: 8px;">
${event.setup_basis ? `<div>Setup: ${event.setup_basis}</div>` : ''}
${event.entry_basis ? `<div>Entry: ${event.entry_basis}</div>` : ''}
</div>
` : ''}
${event.event_type === 'execution_blocked_summary' && Array.isArray(event.blocked_platforms) && event.blocked_platforms.length > 0 ? `
<div class="blocked-platforms" style="margin-top: 10px;">
${event.blocked_platforms.map((item) => `
<div class="blocked-platform">
<strong>${item.platform}</strong>
<span>${item.tag} | ${item.detail}</span>
</div>
`).join('')}
</div>
` : ''}
${(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 = compactEmpty('当前没有需要人工处理的事项', '系统暂无明显风险、停机或待干预问题。');
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'].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 = compactEmpty('当前没有跨平台持仓', '没有活动持仓时,这里会保持紧凑,不再占用大块留白。');
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>平台</th>
<th>账号</th>
<th>交易对</th>
<th>方向</th>
<th>入场 / 现价</th>
<th>仓位 / 杠杆</th>
<th>Setup</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 class="inline-mono">${item.account_id || '-'}</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>${item.setup_type ? `<div class="inline-mono">${item.setup_type}</div><div class="analysis-log-detail">${item.entry_basis || item.setup_basis || '-'}</div>` : '-'}</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 = compactEmpty('当前没有跨平台挂单', '没有入场单或保护单时,这里会保持紧凑展示。');
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>平台</th>
<th>账号</th>
<th>交易对</th>
<th>方向</th>
<th>类别</th>
<th>价格</th>
<th>数量 / 杠杆</th>
<th>信号</th>
<th>Setup</th>
<th>时间</th>
</tr>
</thead>
<tbody>
${total.map((item) => `
<tr>
<td><span class="platform-pill">${item.platform}</span></td>
<td class="inline-mono">${item.account_id || '-'}</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>${item.setup_type ? `<div class="inline-mono">${item.setup_type}</div><div class="analysis-log-detail">${item.entry_basis || item.setup_basis || '-'}</div>` : '-'}</td>
<td class="inline-mono">${item.created_at ? relativeTime(item.created_at) : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function resumePlatform(platform, button, targetKey = null) {
const platformMap = {
PaperTrading: 'PaperTrading',
Bitget: 'Bitget',
};
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, target_key: targetKey || 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 updateExecutionControl(targetKey, enabled, button) {
try {
button.disabled = true;
button.textContent = enabled ? '开启中...' : '关闭中...';
const response = await fetch('/api/trading/execution-controls', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target_key: targetKey,
enabled,
reason: enabled ? '控制台手动开启自动交易' : '控制台手动关闭自动交易',
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.detail || result.message || '更新失败');
}
setFeedback(`${targetKey} 自动交易已${enabled ? '开启' : '关闭'}`);
await loadConsole();
} catch (error) {
setFeedback(`更新自动交易开关失败: ${error.message}`, true);
button.disabled = false;
button.textContent = enabled ? '开启自动交易' : '关闭自动交易';
}
}
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 || {};
cachedConsoleData = data;
syncTabState(data);
renderHero(data);
renderHealthRibbon(data);
renderOpsSummary(data);
renderPlatforms(data.platforms, data.crypto_agent?.platform_halts, data.crypto_agent?.target_execution_controls || {});
renderSignalStream(data.signals?.latest || []);
renderAgentSignals(data.crypto_agent, data.signals);
renderAnalysisHeartbeat(
data.crypto_agent?.analysis_monitor || {},
data.crypto_agent?.recent_analysis_events || []
);
renderDecisionPreview(data.crypto_agent?.last_execution_preview || {});
renderHalts(data.crypto_agent?.platform_halts || {}, data.crypto_agent?.target_execution_controls || {});
renderExecutionEvents(data.execution_events || []);
renderBlockedSummaries(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('toggleSensitiveBtn').addEventListener('click', toggleSensitiveData);
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);
});
renderExecutionEvents();
});
});
loadSensitivePreference();
initTabs();
applyAutoRefreshState();
loadConsole();
</script>
</body>
</html>