feat: implement dashboard layout with scan filter panel, KPI summary bar, and opportunity table components
This commit is contained in:
@@ -8053,3 +8053,857 @@
|
||||
width: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SCAN TERMINAL — 3-Column Layout
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.root :global(.scan-terminal) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: calc(100vh - var(--header-height, 72px));
|
||||
margin-top: var(--header-height, 72px);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── Left: Filter Panel ── */
|
||||
.root :global(.scan-filter-panel) {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-glass);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 16px;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-section) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Mode Tabs */
|
||||
.root :global(.scan-mode-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab:hover) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab.active) {
|
||||
background: rgba(0, 224, 164, 0.08);
|
||||
color: #00e0a4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab.active)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 25%;
|
||||
height: 50%;
|
||||
width: 3px;
|
||||
background: #00e0a4;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab-indicator) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Filter Rows */
|
||||
.root :global(.scan-filter-row) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-row-label) {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.scan-range-display) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.root :global(.scan-range-slider) {
|
||||
flex: 1;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-slider::-webkit-slider-thumb) {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #00e0a4;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.root :global(.scan-toggle) {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle.active) {
|
||||
background: rgba(0, 224, 164, 0.3);
|
||||
border-color: #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle-knob) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle.active .scan-toggle-knob) {
|
||||
left: 18px;
|
||||
background: #00e0a4;
|
||||
}
|
||||
|
||||
/* Select */
|
||||
.root :global(.scan-select) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-select:focus) {
|
||||
border-color: #00e0a4;
|
||||
}
|
||||
|
||||
/* Scan CTA */
|
||||
.root :global(.scan-cta-button) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #00e0a4, #00c48f);
|
||||
color: #030711;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button:hover) {
|
||||
background: linear-gradient(135deg, #00f0b0, #00d89a);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px rgba(0, 224, 164, 0.3);
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Center: Data Grid ── */
|
||||
.root :global(.scan-data-grid) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 20px 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid-title) {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid-subtitle) {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid-controls) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* KPI Bar */
|
||||
.root :global(.scan-kpi-bar) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-card) {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-card:hover) {
|
||||
border-color: var(--border-glass);
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-green) {
|
||||
border-top: 2px solid rgba(0, 224, 164, 0.4);
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-purple) {
|
||||
border-top: 2px solid rgba(123, 97, 255, 0.4);
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-icon) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-label) {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-value) {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-green .scan-kpi-value) {
|
||||
color: #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-kpi-delta) {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* View Tabs */
|
||||
.root :global(.scan-view-tabs) {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.root :global(.scan-view-tab) {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.root :global(.scan-view-tab:hover) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-view-tab.active) {
|
||||
color: #00e0a4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.scan-view-tab.active)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #00e0a4;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
/* ── Opportunity Table ── */
|
||||
.root :global(.scan-table-container) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-header) {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 200px 160px 1fr 150px 90px 64px 40px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row) {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 200px 160px 1fr 150px 90px 64px 40px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row:hover) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row.selected) {
|
||||
background: rgba(0, 224, 164, 0.04);
|
||||
border-left: 2px solid #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row.tradable) {
|
||||
border-left: 2px solid rgba(0, 224, 164, 0.3);
|
||||
}
|
||||
|
||||
/* Table cells */
|
||||
.root :global(.scan-td) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Rank */
|
||||
.root :global(.scan-rank-circle) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(0, 224, 164, 0.12);
|
||||
color: #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.rank-amber .scan-rank-circle) {
|
||||
background: rgba(255, 176, 32, 0.12);
|
||||
color: #ffb020;
|
||||
}
|
||||
|
||||
.root :global(.rank-purple .scan-rank-circle) {
|
||||
background: rgba(123, 97, 255, 0.12);
|
||||
color: #7b61ff;
|
||||
}
|
||||
|
||||
.root :global(.rank-neutral .scan-rank-circle) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* City Cell */
|
||||
.root :global(.scan-td-city) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-thumb) {
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-img-placeholder) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 224, 164, 0.1),
|
||||
rgba(123, 97, 255, 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.scan-city-info) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-name) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-sub) {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Time Cell */
|
||||
.root :global(.scan-td-time) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-time-text) {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Status Badge */
|
||||
.root :global(.scan-status-badge) {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-badge.tone-green) {
|
||||
background: rgba(0, 224, 164, 0.12);
|
||||
color: #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-badge.tone-amber) {
|
||||
background: rgba(255, 176, 32, 0.12);
|
||||
color: #ffb020;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-badge.tone-purple) {
|
||||
background: rgba(123, 97, 255, 0.12);
|
||||
color: #7b61ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-badge.tone-neutral) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Mini Probability Chart */
|
||||
.root :global(.scan-mini-chart) {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 40px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mini-bar-col) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.root :global(.scan-mini-bar) {
|
||||
width: 100%;
|
||||
min-width: 12px;
|
||||
max-width: 20px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 224, 164, 0.3),
|
||||
rgba(0, 224, 164, 0.7)
|
||||
);
|
||||
transition: height 0.3s;
|
||||
}
|
||||
|
||||
.root :global(.scan-mini-bar-label) {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.scan-no-data) {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Action Cell */
|
||||
.root :global(.scan-action-text) {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #00e0a4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Edge */
|
||||
.root :global(.scan-edge-value) {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.scan-edge-value.positive) {
|
||||
color: #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-edge-value.neutral) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Score Ring */
|
||||
.root :global(.scan-score-ring) {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-score-value) {
|
||||
position: absolute;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Favorite */
|
||||
.root :global(.scan-fav-icon) {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.root :global(.scan-fav-icon:hover) {
|
||||
color: #ffb020;
|
||||
}
|
||||
|
||||
/* ── Right: Scan Detail Panel ── */
|
||||
.root :global(.scan-detail-panel) {
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border-glass);
|
||||
background: var(--bg-secondary);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-header) {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-hero-img) {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-hero-placeholder) {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 224, 164, 0.15),
|
||||
rgba(123, 97, 255, 0.15)
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-city-info) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-city-name) {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-city-sub) {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-volume) {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-section) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-section-title) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Conditions Table */
|
||||
.root :global(.scan-conditions-table) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-item) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-label) {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-value) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-value.accent-green) {
|
||||
color: #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-value.accent-red) {
|
||||
color: #ff4d6a;
|
||||
}
|
||||
|
||||
/* Trade Cards */
|
||||
.root :global(.scan-trade-cards) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card) {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card.yes) {
|
||||
border-top: 2px solid #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card.no) {
|
||||
border-top: 2px solid #ff4d6a;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-title) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-price) {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-edge) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-edge.positive) {
|
||||
color: #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-edge.negative) {
|
||||
color: #ff4d6a;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-note) {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Score Gauge */
|
||||
.root :global(.scan-detail-score) {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-big) {
|
||||
font-size: 40px;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-label) {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-suffix) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Confidence Dots */
|
||||
.root :global(.scan-confidence-dots) {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-confidence-dot) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-confidence-dot.filled) {
|
||||
background: #00e0a4;
|
||||
}
|
||||
|
||||
.root :global(.scan-confidence-dot.filled.amber) {
|
||||
background: #ffb020;
|
||||
}
|
||||
|
||||
.root :global(.scan-confidence-dot.filled.red) {
|
||||
background: #ff4d6a;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.root :global(.scan-detail-empty) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import { Star } from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import type {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
CitySummary,
|
||||
} from "@/lib/dashboard-types";
|
||||
import { getLocalizedCityDisplay } from "@/lib/dashboard-home-copy";
|
||||
|
||||
interface OpportunityRow {
|
||||
rank: number;
|
||||
city: CityListItem;
|
||||
summary?: CitySummary | null;
|
||||
detail?: CityDetail | null;
|
||||
score: number;
|
||||
tradable: boolean;
|
||||
}
|
||||
|
||||
function getTodayHighLabel(detail?: CityDetail | null, symbol = "°C"): string {
|
||||
const current = detail?.current;
|
||||
if (current?.temp != null) return `${current.temp.toFixed(1)}${symbol}`;
|
||||
return "--";
|
||||
}
|
||||
|
||||
function getTimeLabel(detail?: CityDetail | null): string {
|
||||
if (!detail?.local_time) return "--";
|
||||
return detail.local_time;
|
||||
}
|
||||
|
||||
function getTimezone(detail?: CityDetail | null): string {
|
||||
if (detail?.utc_offset_seconds != null) {
|
||||
const hours = Math.round(detail.utc_offset_seconds / 3600);
|
||||
return `UTC${hours >= 0 ? "+" : ""}${hours}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getMarketVolume(detail?: CityDetail | null): string {
|
||||
const vol =
|
||||
detail?.market_scan?.volume ?? detail?.market_scan?.primary_market?.volume;
|
||||
if (vol == null) return "--";
|
||||
if (vol >= 1_000_000) return `$${(vol / 1_000_000).toFixed(1)}M`;
|
||||
if (vol >= 1_000) return `$${Math.round(vol / 1_000)}K`;
|
||||
return `$${vol.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function getScanStatus(
|
||||
detail?: CityDetail | null,
|
||||
locale = "zh-CN",
|
||||
): { label: string; tone: string } {
|
||||
const scan = detail?.market_scan;
|
||||
if (!scan?.available)
|
||||
return {
|
||||
label: locale === "en-US" ? "No Market" : "无盘口",
|
||||
tone: "neutral",
|
||||
};
|
||||
const pa = scan?.price_analysis;
|
||||
const bestSide = pa?.best_side;
|
||||
const sideView = bestSide === "no" ? pa?.no : pa?.yes;
|
||||
const edgeVal =
|
||||
sideView?.edge != null
|
||||
? Math.abs(Number(sideView.edge)) > 1
|
||||
? Number(sideView.edge) / 100
|
||||
: Number(sideView.edge)
|
||||
: null;
|
||||
if (edgeVal != null && edgeVal >= 0.05)
|
||||
return {
|
||||
label: locale === "en-US" ? "Touch Play" : "触达博弈",
|
||||
tone: "amber",
|
||||
};
|
||||
if (edgeVal != null && edgeVal >= 0.02)
|
||||
return {
|
||||
label: locale === "en-US" ? "Tradable" : "即时确认",
|
||||
tone: "green",
|
||||
};
|
||||
if (edgeVal != null && edgeVal > 0)
|
||||
return { label: locale === "en-US" ? "Early" : "早期机会", tone: "purple" };
|
||||
return { label: locale === "en-US" ? "Market" : "市场", tone: "neutral" };
|
||||
}
|
||||
|
||||
function getBestAction(detail?: CityDetail | null, locale = "zh-CN"): string {
|
||||
const scan = detail?.market_scan;
|
||||
if (!scan?.available) return "--";
|
||||
const pa = scan?.price_analysis;
|
||||
const side = pa?.best_side;
|
||||
const topBucket = scan?.top_buckets?.[0];
|
||||
const label = topBucket?.label || topBucket?.slug || "";
|
||||
if (side === "yes" && label)
|
||||
return `${locale === "en-US" ? "Buy" : "买入"} Yes ${label}`;
|
||||
if (side === "no" && label)
|
||||
return `${locale === "en-US" ? "Buy" : "买入"} No ${label}`;
|
||||
return "--";
|
||||
}
|
||||
|
||||
function getEdge(detail?: CityDetail | null): number | null {
|
||||
const pa = detail?.market_scan?.price_analysis;
|
||||
const bestSide = pa?.best_side;
|
||||
const sideView = bestSide === "no" ? pa?.no : pa?.yes;
|
||||
const edge = sideView?.edge;
|
||||
if (edge == null) return null;
|
||||
const val = Number(edge);
|
||||
return Math.abs(val) > 1 ? val / 100 : val;
|
||||
}
|
||||
|
||||
function getProbabilityBuckets(
|
||||
detail?: CityDetail | null,
|
||||
): Array<{ label: string; probability: number }> {
|
||||
const view = detail?.probabilities?.distribution || [];
|
||||
if (!Array.isArray(view)) return [];
|
||||
return view.slice(0, 8).map((b) => ({
|
||||
label: String(b.label || b.value || ""),
|
||||
probability: Number(b.probability || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function MiniProbabilityChart({
|
||||
buckets,
|
||||
}: {
|
||||
buckets: Array<{ label: string; probability: number }>;
|
||||
}) {
|
||||
if (!buckets.length) return <span className="scan-no-data">--</span>;
|
||||
const maxProb = Math.max(...buckets.map((b) => b.probability), 0.01);
|
||||
return (
|
||||
<div className="scan-mini-chart">
|
||||
{buckets.map((bucket, i) => (
|
||||
<div key={i} className="scan-mini-bar-col">
|
||||
<div
|
||||
className="scan-mini-bar"
|
||||
style={{
|
||||
height: `${Math.max((bucket.probability / maxProb) * 100, 4)}%`,
|
||||
}}
|
||||
title={`${bucket.label}: ${(bucket.probability * 100).toFixed(1)}%`}
|
||||
/>
|
||||
<span className="scan-mini-bar-label">
|
||||
{bucket.label.replace(/[°CF]/g, "")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreRing({ score }: { score: number }) {
|
||||
const radius = 20;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = (score / 100) * circumference;
|
||||
const color = score >= 80 ? "#00E0A4" : score >= 60 ? "#FFB020" : "#FF4D6A";
|
||||
|
||||
return (
|
||||
<div className="scan-score-ring">
|
||||
<svg viewBox="0 0 48 48" width={48} height={48}>
|
||||
<circle
|
||||
cx={24}
|
||||
cy={24}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.06)"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<circle
|
||||
cx={24}
|
||||
cy={24}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={3}
|
||||
strokeDasharray={`${progress} ${circumference - progress}`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 24 24)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="scan-score-value" style={{ color }}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) {
|
||||
const { locale } = useI18n();
|
||||
const store = useDashboardStore();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
return (
|
||||
<div className="scan-table-container">
|
||||
{/* Header */}
|
||||
<div className="scan-table-header">
|
||||
<span className="scan-th scan-th-rank">#</span>
|
||||
<span className="scan-th scan-th-city">
|
||||
{isEn ? "City / Market" : "城市 / 市场"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-time">
|
||||
{isEn ? "Local Time / Status" : "当前时间 / 阶段"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-prob">
|
||||
{isEn ? "Prob. Dist. vs Market" : "模型分布 vs 市场分布"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-action">
|
||||
{isEn ? "Best Action" : "最佳机会"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-edge">
|
||||
{isEn ? "Edge" : "边际优势"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-score">
|
||||
{isEn ? "Score" : "综合得分"}
|
||||
</span>
|
||||
<span className="scan-th scan-th-fav" />
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{rows.map((row) => {
|
||||
const cityName = getLocalizedCityDisplay(
|
||||
row.city,
|
||||
locale,
|
||||
row.summary,
|
||||
row.detail,
|
||||
);
|
||||
const status = getScanStatus(row.detail, locale);
|
||||
const edge = getEdge(row.detail);
|
||||
const buckets = getProbabilityBuckets(row.detail);
|
||||
const isSelected = store.selectedCity === row.city.name;
|
||||
const symbol = row.detail?.temp_symbol || "°C";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.city.name}
|
||||
className={`scan-table-row ${isSelected ? "selected" : ""} ${row.tradable ? "tradable" : ""}`}
|
||||
onClick={() => store.focusCity(row.city.name)}
|
||||
>
|
||||
{/* Rank */}
|
||||
<span className={`scan-td scan-td-rank rank-${status.tone}`}>
|
||||
<span className="scan-rank-circle">{row.rank}</span>
|
||||
</span>
|
||||
|
||||
{/* City */}
|
||||
<span className="scan-td scan-td-city">
|
||||
<span className="scan-city-thumb">
|
||||
<span className="scan-city-img-placeholder" />
|
||||
</span>
|
||||
<span className="scan-city-info">
|
||||
<span className="scan-city-name">{cityName}</span>
|
||||
<span className="scan-city-sub">
|
||||
{isEn ? "Today's high" : "今日最高温"} ·{" "}
|
||||
{getMarketVolume(row.detail)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Time + Status */}
|
||||
<span className="scan-td scan-td-time">
|
||||
<span className="scan-time-text">
|
||||
{getTimeLabel(row.detail)} ({getTimezone(row.detail)})
|
||||
</span>
|
||||
<span className={`scan-status-badge tone-${status.tone}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Probability Chart */}
|
||||
<span className="scan-td scan-td-prob">
|
||||
<MiniProbabilityChart buckets={buckets} />
|
||||
</span>
|
||||
|
||||
{/* Best Action */}
|
||||
<span className="scan-td scan-td-action">
|
||||
<span className="scan-action-text">
|
||||
{getBestAction(row.detail, locale)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Edge */}
|
||||
<span className="scan-td scan-td-edge">
|
||||
<span
|
||||
className={`scan-edge-value ${edge != null && edge > 0 ? "positive" : "neutral"}`}
|
||||
>
|
||||
{edge != null
|
||||
? `${edge > 0 ? "+" : ""}${(edge * 100).toFixed(1)}%`
|
||||
: "--"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Score */}
|
||||
<span className="scan-td scan-td-score">
|
||||
<ScoreRing score={row.score} />
|
||||
</span>
|
||||
|
||||
{/* Favorite */}
|
||||
<span className="scan-td scan-td-fav">
|
||||
<Star size={16} className="scan-fav-icon" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||
import { ScanFilterPanel } from "@/components/dashboard/ScanFilterPanel";
|
||||
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
|
||||
import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
|
||||
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
||||
import type {
|
||||
CityDetail,
|
||||
@@ -163,8 +166,10 @@ function getProbabilityLabel(
|
||||
},
|
||||
symbol: string,
|
||||
) {
|
||||
if (bucket.label) return normalizeTemperatureBucketLabel(bucket.label, symbol);
|
||||
if (bucket.bucket) return normalizeTemperatureBucketLabel(bucket.bucket, symbol);
|
||||
if (bucket.label)
|
||||
return normalizeTemperatureBucketLabel(bucket.label, symbol);
|
||||
if (bucket.bucket)
|
||||
return normalizeTemperatureBucketLabel(bucket.bucket, symbol);
|
||||
if (Number.isFinite(Number(bucket.value))) {
|
||||
return `≥ ${Math.round(Number(bucket.value))}${symbol}`;
|
||||
}
|
||||
@@ -206,7 +211,9 @@ function getBucketMatchKey(bucket?: {
|
||||
}) {
|
||||
const threshold = parseBucketThreshold(bucket);
|
||||
if (threshold != null) return `t:${threshold.toFixed(2)}`;
|
||||
return `l:${String(bucket?.label || bucket?.bucket || bucket?.range || "").trim().toLowerCase()}`;
|
||||
return `l:${String(bucket?.label || bucket?.bucket || bucket?.range || "")
|
||||
.trim()
|
||||
.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function normalizeProbabilityValue(value: number | null | undefined) {
|
||||
@@ -286,6 +293,31 @@ function formatEdge(value: number | null | undefined) {
|
||||
return `${sign}${normalized.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getScanStatusLabel(detail?: CityDetail | null): string {
|
||||
const scan = detail?.market_scan;
|
||||
if (!scan?.available) return "无盘口";
|
||||
const pa = scan?.price_analysis;
|
||||
const bestSide = pa?.best_side;
|
||||
const sideView = bestSide === "no" ? pa?.no : pa?.yes;
|
||||
const edgeVal =
|
||||
sideView?.edge != null
|
||||
? Math.abs(Number(sideView.edge)) > 1
|
||||
? Number(sideView.edge) / 100
|
||||
: Number(sideView.edge)
|
||||
: null;
|
||||
if (edgeVal != null && edgeVal >= 0.05) return "触达博弈";
|
||||
if (edgeVal != null && edgeVal >= 0.02) return "即时确认";
|
||||
if (edgeVal != null && edgeVal > 0) return "早期机会";
|
||||
return "市场";
|
||||
}
|
||||
|
||||
function formatVolume(value?: number | null): string {
|
||||
if (value == null) return "--";
|
||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `$${Math.round(value / 1_000)}K`;
|
||||
return `$${value.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function normalizeEdgePercent(value: number | null | undefined) {
|
||||
if (!Number.isFinite(Number(value))) return null;
|
||||
const numeric = Number(value);
|
||||
@@ -301,7 +333,10 @@ function getBestSide(detail?: CityDetail | null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSideEdgePercent(detail: CityDetail | null | undefined, side: "yes" | "no") {
|
||||
function getSideEdgePercent(
|
||||
detail: CityDetail | null | undefined,
|
||||
side: "yes" | "no",
|
||||
) {
|
||||
const sideAnalysis = detail?.market_scan?.price_analysis?.[side];
|
||||
return normalizeEdgePercent(sideAnalysis?.edge_percent ?? sideAnalysis?.edge);
|
||||
}
|
||||
@@ -571,7 +606,9 @@ function buildAssistantOpportunityContext(
|
||||
detail?.airport_current?.max_so_far ??
|
||||
null,
|
||||
market_question: marketQuestion,
|
||||
market_label: marketBucket ? getProbabilityLabel(marketBucket, symbol) : null,
|
||||
market_label: marketBucket
|
||||
? getProbabilityLabel(marketBucket, symbol)
|
||||
: null,
|
||||
selected_date: marketScan?.selected_date || detail?.local_date || null,
|
||||
best_side: marketScan?.price_analysis?.best_side || null,
|
||||
yes_price: normalizeAssistantCents(
|
||||
@@ -623,10 +660,12 @@ function buildAssistantContextPayload(
|
||||
totals: {
|
||||
cities: snapshots.length,
|
||||
tradable_markets: opportunities.filter((item) => item.tradable).length,
|
||||
high_risk: opportunities.filter((item) => item.risk_level === "high").length,
|
||||
high_risk: opportunities.filter((item) => item.risk_level === "high")
|
||||
.length,
|
||||
medium_risk: opportunities.filter((item) => item.risk_level === "medium")
|
||||
.length,
|
||||
low_risk: opportunities.filter((item) => item.risk_level === "low").length,
|
||||
low_risk: opportunities.filter((item) => item.risk_level === "low")
|
||||
.length,
|
||||
},
|
||||
selected_city: selected,
|
||||
opportunities,
|
||||
@@ -662,7 +701,8 @@ function buildAssistantContextPayload(
|
||||
},
|
||||
{
|
||||
term: "市场概率",
|
||||
meaning: "市场概率来自 YES/NO 实时价格的隐含概率,而不是模型输出。",
|
||||
meaning:
|
||||
"市场概率来自 YES/NO 实时价格的隐含概率,而不是模型输出。",
|
||||
},
|
||||
{
|
||||
term: "DEB",
|
||||
@@ -676,7 +716,10 @@ function buildAssistantContextPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function buildAssistantGreeting(locale: string, selectedCityName?: string | null) {
|
||||
function buildAssistantGreeting(
|
||||
locale: string,
|
||||
selectedCityName?: string | null,
|
||||
) {
|
||||
if (locale === "en-US") {
|
||||
return selectedCityName
|
||||
? `Ask about ${selectedCityName}'s current temperature, today's forecast high, market edge, or live opportunities.`
|
||||
@@ -1361,7 +1404,7 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
: "最佳机会"
|
||||
: locale === "en-US"
|
||||
? "FOCUS CITY"
|
||||
: "焦点城市"}
|
||||
: "焦点城市"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1547,54 +1590,55 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
|
||||
{tradableOpportunity ? (
|
||||
<div className={clsx("home-card-section market", !isPro && "locked")}>
|
||||
<div className="home-market-header">
|
||||
<h3>
|
||||
{marketTitle} <small>ⓘ</small>
|
||||
</h3>
|
||||
<span>
|
||||
{marketScan && !tradableOpportunity
|
||||
? locale === "en-US"
|
||||
? "Market closed"
|
||||
: "市场已结束"
|
||||
: locale === "en-US"
|
||||
? "Updated now"
|
||||
: "刚刚更新"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="home-market-ticket">
|
||||
<div className="home-market-question">
|
||||
<strong>{marketLabel}</strong>
|
||||
<span>{marketScan?.selected_date || detail?.local_date || ""}</span>
|
||||
<div className="home-market-header">
|
||||
<h3>
|
||||
{marketTitle} <small>ⓘ</small>
|
||||
</h3>
|
||||
<span>
|
||||
{marketScan && !tradableOpportunity
|
||||
? locale === "en-US"
|
||||
? "Market closed"
|
||||
: "市场已结束"
|
||||
: locale === "en-US"
|
||||
? "Updated now"
|
||||
: "刚刚更新"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="home-market-prices">
|
||||
<span className="yes">YES {formatCents(yesPrice)}</span>
|
||||
<span className="no">NO {formatCents(noPrice)}</span>
|
||||
<div className="home-market-ticket">
|
||||
<div className="home-market-question">
|
||||
<strong>{marketLabel}</strong>
|
||||
<span>
|
||||
{marketScan?.selected_date || detail?.local_date || ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="home-market-prices">
|
||||
<span className="yes">YES {formatCents(yesPrice)}</span>
|
||||
<span className="no">NO {formatCents(noPrice)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-market-metrics">
|
||||
<span>
|
||||
{marketEdgeLabel} <strong>{formatEdge(marketEdge)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{marketImpliedLabel}{" "}
|
||||
<strong>{formatProbability(marketProbability)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{marketModelLabel}{" "}
|
||||
<strong>{formatProbability(marketModelProbability)}</strong>
|
||||
</span>
|
||||
<svg viewBox="0 0 96 32" aria-hidden="true">
|
||||
{sparklinePoints ? <polyline points={sparklinePoints} /> : null}
|
||||
</svg>
|
||||
</div>
|
||||
{!isPro ? (
|
||||
<Link href="/account" className="home-market-lock">
|
||||
{locale === "en-US" ? "Unlock market layer" : "解锁市场层"}
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="home-market-metrics">
|
||||
<span>
|
||||
{marketEdgeLabel} <strong>{formatEdge(marketEdge)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{marketImpliedLabel}{" "}
|
||||
<strong>{formatProbability(marketProbability)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{marketModelLabel}{" "}
|
||||
<strong>{formatProbability(marketModelProbability)}</strong>
|
||||
</span>
|
||||
<svg viewBox="0 0 96 32" aria-hidden="true">
|
||||
{sparklinePoints ? <polyline points={sparklinePoints} /> : null}
|
||||
</svg>
|
||||
</div>
|
||||
{!isPro ? (
|
||||
<Link href="/account" className="home-market-lock">
|
||||
{locale === "en-US" ? "Unlock market layer" : "解锁市场层"}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1613,9 +1657,10 @@ function OpportunityStrip({
|
||||
const stripState = useMemo(() => {
|
||||
const targetSet = new Set(scanTargetNames);
|
||||
const totalCount = scanTargetNames.length || snapshots.length;
|
||||
const targetedSnapshots = (scanTargetNames.length
|
||||
? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name))
|
||||
: snapshots
|
||||
const targetedSnapshots = (
|
||||
scanTargetNames.length
|
||||
? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name))
|
||||
: snapshots
|
||||
).slice(0, Math.max(scanTargetNames.length, 1));
|
||||
const effectiveStatusByCity = Object.fromEntries(
|
||||
targetedSnapshots.map((snapshot) => [
|
||||
@@ -1641,10 +1686,13 @@ function OpportunityStrip({
|
||||
.filter((snapshot) => snapshot.tradableOpportunity)
|
||||
.sort((left, right) => {
|
||||
const leftEdge = normalizeEdgePercent(getMarketEdgeValue(left.detail));
|
||||
const rightEdge = normalizeEdgePercent(getMarketEdgeValue(right.detail));
|
||||
const rightEdge = normalizeEdgePercent(
|
||||
getMarketEdgeValue(right.detail),
|
||||
);
|
||||
return (
|
||||
Number(rightEdge ?? Number.NEGATIVE_INFINITY) -
|
||||
Number(leftEdge ?? Number.NEGATIVE_INFINITY) || right.score - left.score
|
||||
Number(leftEdge ?? Number.NEGATIVE_INFINITY) ||
|
||||
right.score - left.score
|
||||
);
|
||||
});
|
||||
const yesCount = liveSnapshots.filter(
|
||||
@@ -1655,7 +1703,9 @@ function OpportunityStrip({
|
||||
).length;
|
||||
const avgTradableEdge = tradableSnapshots.length
|
||||
? tradableSnapshots.reduce((sum, snapshot) => {
|
||||
const edge = normalizeEdgePercent(getMarketEdgeValue(snapshot.detail));
|
||||
const edge = normalizeEdgePercent(
|
||||
getMarketEdgeValue(snapshot.detail),
|
||||
);
|
||||
return sum + (edge ?? 0);
|
||||
}, 0) / tradableSnapshots.length
|
||||
: null;
|
||||
@@ -1675,8 +1725,8 @@ function OpportunityStrip({
|
||||
const marketScan = snapshot.detail?.market_scan;
|
||||
return Boolean(
|
||||
marketScan?.price_analysis?.available ||
|
||||
marketScan?.yes_buy != null ||
|
||||
marketScan?.no_buy != null,
|
||||
marketScan?.yes_buy != null ||
|
||||
marketScan?.no_buy != null,
|
||||
);
|
||||
}).length;
|
||||
const noEdgeCount = Math.max(quoteReadyCount - tradableSnapshots.length, 0);
|
||||
@@ -1782,7 +1832,9 @@ function OpportunityStrip({
|
||||
},
|
||||
],
|
||||
overviewKicker:
|
||||
locale === "en-US" ? "EMOS / CLOB Decision Layer" : "EMOS / CLOB 交易决策层",
|
||||
locale === "en-US"
|
||||
? "EMOS / CLOB Decision Layer"
|
||||
: "EMOS / CLOB 交易决策层",
|
||||
headingTitle:
|
||||
tradableSnapshots.length > 0
|
||||
? locale === "en-US"
|
||||
@@ -1830,7 +1882,8 @@ function OpportunityStrip({
|
||||
key: "avg-edge",
|
||||
label: locale === "en-US" ? "Best edge" : "最佳 edge",
|
||||
value: formatEdge(bestTradableEdge),
|
||||
accent: bestTradableEdge != null ? ("green" as const) : ("slate" as const),
|
||||
accent:
|
||||
bestTradableEdge != null ? ("green" as const) : ("slate" as const),
|
||||
},
|
||||
],
|
||||
yesCountLabel:
|
||||
@@ -1854,7 +1907,11 @@ function OpportunityStrip({
|
||||
const heroBestProbability = getBestSideModelProbability(heroSnapshot?.detail);
|
||||
const heroBestEdge = getExecutableEdgePercent(heroSnapshot?.detail);
|
||||
const heroSymbol = heroSnapshot
|
||||
? getTempSymbol(heroSnapshot.city, heroSnapshot.summary, heroSnapshot.detail)
|
||||
? getTempSymbol(
|
||||
heroSnapshot.city,
|
||||
heroSnapshot.summary,
|
||||
heroSnapshot.detail,
|
||||
)
|
||||
: "°C";
|
||||
const heroTier =
|
||||
heroSnapshot?.city.deb_recent_tier ||
|
||||
@@ -1869,7 +1926,8 @@ function OpportunityStrip({
|
||||
heroSnapshot.detail,
|
||||
)
|
||||
: "--";
|
||||
const heroMarketBucket = heroSnapshot?.detail?.market_scan?.temperature_bucket;
|
||||
const heroMarketBucket =
|
||||
heroSnapshot?.detail?.market_scan?.temperature_bucket;
|
||||
const heroQuestion =
|
||||
heroSnapshot?.detail?.market_scan?.primary_market?.question ||
|
||||
`${getProbabilityLabel(heroMarketBucket || {}, heroSymbol)} ${
|
||||
@@ -1916,7 +1974,9 @@ function OpportunityStrip({
|
||||
],
|
||||
);
|
||||
const secondaryItems = heroSnapshot
|
||||
? stripState.items.filter((snapshot) => snapshot.city.name !== heroSnapshot.city.name)
|
||||
? stripState.items.filter(
|
||||
(snapshot) => snapshot.city.name !== heroSnapshot.city.name,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
@@ -1968,12 +2028,16 @@ function OpportunityStrip({
|
||||
</span>
|
||||
<div className="opportunity-hero-title-row">
|
||||
<strong>{heroCityName}</strong>
|
||||
<span className="opportunity-hero-date">{heroDateLabel}</span>
|
||||
<span className="opportunity-hero-date">
|
||||
{heroDateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p>{heroQuestion}</p>
|
||||
</div>
|
||||
<div className="opportunity-hero-tags">
|
||||
<span className="opportunity-hero-tag engine">{heroEngine}</span>
|
||||
<span className="opportunity-hero-tag engine">
|
||||
{heroEngine}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"opportunity-hero-tag",
|
||||
@@ -2043,11 +2107,15 @@ function OpportunityStrip({
|
||||
<div className="opportunity-hero-book">
|
||||
<div className="opportunity-book-side yes">
|
||||
<span>YES</span>
|
||||
<strong>{formatCents(heroSnapshot.detail?.market_scan?.yes_buy)}</strong>
|
||||
<strong>
|
||||
{formatCents(heroSnapshot.detail?.market_scan?.yes_buy)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="opportunity-book-side no">
|
||||
<span>NO</span>
|
||||
<strong>{formatCents(heroSnapshot.detail?.market_scan?.no_buy)}</strong>
|
||||
<strong>
|
||||
{formatCents(heroSnapshot.detail?.market_scan?.no_buy)}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="opportunity-hero-sparkline-wrap">
|
||||
<span>
|
||||
@@ -2058,7 +2126,9 @@ function OpportunityStrip({
|
||||
viewBox="0 0 132 44"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{heroSparkline ? <polyline points={heroSparkline} /> : null}
|
||||
{heroSparkline ? (
|
||||
<polyline points={heroSparkline} />
|
||||
) : null}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2242,8 +2312,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
const selectedSnapshot = useMemo(
|
||||
() =>
|
||||
store.selectedCity
|
||||
? snapshots.find((snapshot) => snapshot.city.name === store.selectedCity) ||
|
||||
null
|
||||
? snapshots.find(
|
||||
(snapshot) => snapshot.city.name === store.selectedCity,
|
||||
) || null
|
||||
: null,
|
||||
[snapshots, store.selectedCity],
|
||||
);
|
||||
@@ -2310,7 +2381,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
}
|
||||
setDockPosition((current) =>
|
||||
clampAssistantDockPosition(
|
||||
current || readAssistantDockPosition() || getDefaultAssistantDockPosition(),
|
||||
current ||
|
||||
readAssistantDockPosition() ||
|
||||
getDefaultAssistantDockPosition(),
|
||||
dockRef.current,
|
||||
),
|
||||
);
|
||||
@@ -2496,7 +2569,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
onPointerMove={updateDockDrag}
|
||||
onPointerUp={endDockDrag}
|
||||
onPointerCancel={endDockDrag}
|
||||
aria-label={locale === "en-US" ? "Open AI assistant" : "打开 AI 助手"}
|
||||
aria-label={
|
||||
locale === "en-US" ? "Open AI assistant" : "打开 AI 助手"
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src="/favicon-32x32.png"
|
||||
@@ -2513,7 +2588,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
>
|
||||
<div className="home-ai-header">
|
||||
<div>
|
||||
<strong>{locale === "en-US" ? "AI assistant" : "AI 对话助手"}</strong>
|
||||
<strong>
|
||||
{locale === "en-US" ? "AI assistant" : "AI 对话助手"}
|
||||
</strong>
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? "Ask about cities, forecast highs, edge, and live opportunities"
|
||||
@@ -2536,7 +2613,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
type="button"
|
||||
className="home-ai-close"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-label={locale === "en-US" ? "Close assistant" : "关闭 AI 助手"}
|
||||
aria-label={
|
||||
locale === "en-US" ? "Close assistant" : "关闭 AI 助手"
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -2560,13 +2639,17 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
>
|
||||
<p>{message.content}</p>
|
||||
{message.cached ? (
|
||||
<small>{locale === "en-US" ? "Cache hit" : "命中缓存"}</small>
|
||||
<small>
|
||||
{locale === "en-US" ? "Cache hit" : "命中缓存"}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{loading ? (
|
||||
<div className="home-ai-message assistant loading">
|
||||
<p>{locale === "en-US" ? "Thinking..." : "正在整理答案..."}</p>
|
||||
<p>
|
||||
{locale === "en-US" ? "Thinking..." : "正在整理答案..."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={messagesEndRef} />
|
||||
@@ -2598,7 +2681,11 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
}
|
||||
/>
|
||||
<div className="home-ai-composer-actions">
|
||||
{error ? <span className="home-ai-error">{error}</span> : <span />}
|
||||
{error ? (
|
||||
<span className="home-ai-error">{error}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="home-ai-send"
|
||||
@@ -2613,7 +2700,10 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
||||
</div>
|
||||
|
||||
{showPaywall ? (
|
||||
<div className="home-ai-paywall-backdrop" onClick={() => setShowPaywall(false)}>
|
||||
<div
|
||||
className="home-ai-paywall-backdrop"
|
||||
onClick={() => setShowPaywall(false)}
|
||||
>
|
||||
<div
|
||||
className="home-ai-paywall-shell"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -2822,7 +2912,10 @@ function DashboardScreen() {
|
||||
|
||||
void refreshAllMarketScans();
|
||||
intervalId = window.setInterval(() => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.visibilityState === "hidden"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void refreshAllMarketScans();
|
||||
@@ -2851,28 +2944,328 @@ function DashboardScreen() {
|
||||
modalChromeStyles.root,
|
||||
)}
|
||||
>
|
||||
<MapCanvas />
|
||||
<HeaderBar />
|
||||
<CitySidebar />
|
||||
{showHomepageChrome ? (
|
||||
<>
|
||||
<HomeIntelligencePanel snapshots={homepageSnapshots} />
|
||||
<OpportunityStrip
|
||||
snapshots={homepageSnapshots}
|
||||
marketScanStatusByCity={marketScanStatusByCity}
|
||||
scanTargetNames={marketScanTargetNames}
|
||||
<div className="scan-terminal">
|
||||
{/* Left — Filter Panel */}
|
||||
<ScanFilterPanel />
|
||||
|
||||
{/* Center — Data Grid */}
|
||||
<div className="scan-data-grid">
|
||||
<div className="scan-data-grid-header">
|
||||
<div>
|
||||
<div className="scan-data-grid-title">
|
||||
{t("dashboard.opportunityTitle") || "可交易机会"}
|
||||
</div>
|
||||
<div className="scan-data-grid-subtitle">
|
||||
{t("dashboard.opportunitySubtitle") ||
|
||||
"基于当前时间、实况数据和模型预测,筛选出最具交易价值的市场"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScanKPIBar
|
||||
data={{
|
||||
recommendedCount: homepageSnapshots.filter(
|
||||
(s) => s.tradableOpportunity,
|
||||
).length,
|
||||
avgEdge: (() => {
|
||||
const tradable = homepageSnapshots.filter(
|
||||
(s) => s.tradableOpportunity,
|
||||
);
|
||||
if (!tradable.length) return null;
|
||||
const sum = tradable.reduce((acc, s) => {
|
||||
const edge = normalizeEdgePercent(
|
||||
getMarketEdgeValue(s.detail),
|
||||
);
|
||||
return acc + (edge ?? 0);
|
||||
}, 0);
|
||||
return (sum / tradable.length) * 100;
|
||||
})(),
|
||||
totalWinRate: (() => {
|
||||
const tradable = homepageSnapshots.filter(
|
||||
(s) => s.tradableOpportunity,
|
||||
);
|
||||
if (!tradable.length) return null;
|
||||
const modelProbs = tradable
|
||||
.map((s) => s.detail?.market_scan?.model_probability)
|
||||
.filter((v): v is number => v != null);
|
||||
if (!modelProbs.length) return null;
|
||||
return (
|
||||
(modelProbs.reduce((a, b) => a + b, 0) / modelProbs.length) *
|
||||
100
|
||||
);
|
||||
})(),
|
||||
tradableMarkets: homepageSnapshots.filter(
|
||||
(s) => s.tradableOpportunity,
|
||||
).length,
|
||||
filteredTotal: homepageSnapshots.length,
|
||||
totalVolume: homepageSnapshots.reduce((sum, s) => {
|
||||
const vol =
|
||||
s.detail?.market_scan?.volume ??
|
||||
s.detail?.market_scan?.primary_market?.volume ??
|
||||
0;
|
||||
return sum + vol;
|
||||
}, 0),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="scan-view-tabs">
|
||||
<button className="scan-view-tab active">
|
||||
{t("dashboard.opportunityList") || "机会列表"}
|
||||
</button>
|
||||
<button className="scan-view-tab">
|
||||
{t("dashboard.distributionView") || "分布视图"}
|
||||
</button>
|
||||
<button className="scan-view-tab">
|
||||
{t("dashboard.calendarView") || "日历视图"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<OpportunityTable
|
||||
rows={homepageSnapshots.map((snapshot, index) => ({
|
||||
rank: index + 1,
|
||||
city: snapshot.city,
|
||||
summary: snapshot.summary,
|
||||
detail: snapshot.detail,
|
||||
score: snapshot.score,
|
||||
tradable: snapshot.tradableOpportunity,
|
||||
}))}
|
||||
/>
|
||||
<HomeAssistantDock snapshots={homepageSnapshots} />
|
||||
</>
|
||||
) : null}
|
||||
{showCitySyncToast ? (
|
||||
<div className="city-loading-toast" role="status" aria-live="polite">
|
||||
<span className="city-loading-dot" aria-hidden="true" />
|
||||
<span className="city-loading-copy">
|
||||
{t("dashboard.loading")} {activeCityName}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Right — Scan Detail Panel */}
|
||||
{store.selectedDetail ? (
|
||||
<aside className="scan-detail-panel">
|
||||
{/* City Header */}
|
||||
<div className="scan-detail-header">
|
||||
<span className="scan-detail-hero-placeholder" />
|
||||
<div className="scan-detail-city-info">
|
||||
<div className="scan-detail-city-name">{activeCityName}</div>
|
||||
<div className="scan-detail-city-sub">
|
||||
{getScanStatusLabel(store.selectedDetail)}
|
||||
{" · "}
|
||||
{store.selectedDetail.temp_symbol === "°F" ? "≥ " : "≥ "}
|
||||
{store.selectedDetail.forecast?.today_high != null
|
||||
? `${store.selectedDetail.forecast.today_high}${store.selectedDetail.temp_symbol}`
|
||||
: "--"}
|
||||
</div>
|
||||
<div className="scan-detail-volume">
|
||||
{formatVolume(store.selectedDetail.market_scan?.volume)} · 24h{" "}
|
||||
{store.selectedDetail.market_scan?.primary_market
|
||||
?.liquidity != null
|
||||
? `$${store.selectedDetail.market_scan.primary_market.liquidity.toLocaleString()}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Conditions */}
|
||||
<div className="scan-detail-section">
|
||||
<div className="scan-detail-section-title">
|
||||
{t("section.currentConditions") || "当前概况"}
|
||||
</div>
|
||||
<div className="scan-conditions-table">
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{t("detail.localTime") || "当前时间"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{store.selectedDetail.local_time}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{t("detail.currentTemp") || "当前温度"}
|
||||
</span>
|
||||
<span className="scan-condition-value accent-green">
|
||||
{store.selectedDetail.current?.temp != null
|
||||
? `${store.selectedDetail.current.temp.toFixed(1)}${store.selectedDetail.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{t("detail.todayHigh") || "今日最高"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{store.selectedDetail.current?.max_so_far != null
|
||||
? `${store.selectedDetail.current.max_so_far.toFixed(1)}${store.selectedDetail.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{t("detail.targetTemp") || "目标温度"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{store.selectedDetail.deb?.prediction != null
|
||||
? `${store.selectedDetail.deb.prediction.toFixed(1)}${store.selectedDetail.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{t("detail.gap") || "距目标差距"}
|
||||
</span>
|
||||
<span
|
||||
className={`scan-condition-value ${
|
||||
store.selectedDetail.current?.temp != null &&
|
||||
store.selectedDetail.deb?.prediction != null
|
||||
? store.selectedDetail.deb.prediction -
|
||||
(store.selectedDetail.current.max_so_far ??
|
||||
store.selectedDetail.current.temp) >
|
||||
0
|
||||
? "accent-red"
|
||||
: "accent-green"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{store.selectedDetail.current?.temp != null &&
|
||||
store.selectedDetail.deb?.prediction != null
|
||||
? `${(store.selectedDetail.deb.prediction - (store.selectedDetail.current.max_so_far ?? store.selectedDetail.current.temp)).toFixed(1)}${store.selectedDetail.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-condition-item">
|
||||
<span className="scan-condition-label">
|
||||
{t("detail.peakWindow") || "预计峰值"}
|
||||
</span>
|
||||
<span className="scan-condition-value">
|
||||
{store.selectedDetail.peak?.hours?.join(" - ") || "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Recommendation */}
|
||||
{store.selectedDetail.market_scan?.price_analysis && (
|
||||
<div className="scan-detail-section">
|
||||
<div className="scan-detail-section-title">
|
||||
{t("section.tradeRecommendation") || "推荐交易"}
|
||||
</div>
|
||||
<div className="scan-trade-cards">
|
||||
<div className="scan-trade-card yes">
|
||||
<div className="scan-trade-card-title">
|
||||
买入 Yes{" "}
|
||||
{store.selectedDetail.market_scan?.temperature_bucket
|
||||
?.label || ""}
|
||||
</div>
|
||||
<div className="scan-trade-card-price">
|
||||
{store.selectedDetail.market_scan?.price_analysis?.yes
|
||||
?.ask != null
|
||||
? `${(store.selectedDetail.market_scan.price_analysis.yes.ask * 100).toFixed(0)}%`
|
||||
: "--"}
|
||||
{" → "}
|
||||
{store.selectedDetail.market_scan?.price_analysis?.yes
|
||||
?.model_probability != null
|
||||
? `${(store.selectedDetail.market_scan.price_analysis.yes.model_probability * 100).toFixed(0)}%`
|
||||
: "--"}
|
||||
</div>
|
||||
<div
|
||||
className={`scan-trade-card-edge ${
|
||||
(store.selectedDetail.market_scan?.price_analysis?.yes
|
||||
?.edge ?? 0) > 0
|
||||
? "positive"
|
||||
: "negative"
|
||||
}`}
|
||||
>
|
||||
{store.selectedDetail.market_scan?.price_analysis?.yes
|
||||
?.edge != null
|
||||
? `${store.selectedDetail.market_scan.price_analysis.yes.edge > 0 ? "+" : ""}${(store.selectedDetail.market_scan.price_analysis.yes.edge * 100).toFixed(1)}% 边际优势`
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-trade-card no">
|
||||
<div className="scan-trade-card-title">
|
||||
卖出 Yes{" "}
|
||||
{store.selectedDetail.market_scan?.temperature_bucket
|
||||
?.label || ""}
|
||||
</div>
|
||||
<div className="scan-trade-card-price">
|
||||
{store.selectedDetail.market_scan?.price_analysis?.no
|
||||
?.ask != null
|
||||
? `${(store.selectedDetail.market_scan.price_analysis.no.ask * 100).toFixed(0)}%`
|
||||
: "--"}
|
||||
{" → "}
|
||||
{store.selectedDetail.market_scan?.price_analysis?.no
|
||||
?.model_probability != null
|
||||
? `${(1 - store.selectedDetail.market_scan.price_analysis.no.model_probability) * 100 > 0 ? `${((1 - store.selectedDetail.market_scan.price_analysis.no.model_probability) * 100).toFixed(0)}%` : "--"}`
|
||||
: "--"}
|
||||
</div>
|
||||
<div
|
||||
className={`scan-trade-card-edge ${
|
||||
(store.selectedDetail.market_scan?.price_analysis?.no
|
||||
?.edge ?? 0) > 0
|
||||
? "positive"
|
||||
: "negative"
|
||||
}`}
|
||||
>
|
||||
{store.selectedDetail.market_scan?.price_analysis?.no
|
||||
?.edge != null
|
||||
? `${store.selectedDetail.market_scan.price_analysis.no.edge > 0 ? "+" : ""}${(store.selectedDetail.market_scan.price_analysis.no.edge * 100).toFixed(1)}% 边际优势`
|
||||
: "--"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Score */}
|
||||
<div className="scan-detail-score">
|
||||
<div>
|
||||
<span
|
||||
className="scan-detail-score-big"
|
||||
style={{
|
||||
color: (() => {
|
||||
const snap = homepageSnapshots.find(
|
||||
(s) => s.city.name === store.selectedCity,
|
||||
);
|
||||
const score = snap?.score ?? 0;
|
||||
return score >= 80
|
||||
? "#00E0A4"
|
||||
: score >= 60
|
||||
? "#FFB020"
|
||||
: "#FF4D6A";
|
||||
})(),
|
||||
}}
|
||||
>
|
||||
{homepageSnapshots.find(
|
||||
(s) => s.city.name === store.selectedCity,
|
||||
)?.score ?? "--"}
|
||||
</span>
|
||||
<span className="scan-detail-score-suffix"> /100</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="scan-detail-score-label">
|
||||
{t("detail.compositeScore") || "综合得分"}
|
||||
</div>
|
||||
<div className="scan-confidence-dots">
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const snap = homepageSnapshots.find(
|
||||
(s) => s.city.name === store.selectedCity,
|
||||
);
|
||||
const score = snap?.score ?? 0;
|
||||
const filled = score >= i * 20;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`scan-confidence-dot ${filled ? "filled" : ""} ${score < 60 ? "red" : score < 80 ? "amber" : ""}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
<aside className="scan-detail-panel">
|
||||
<div className="scan-detail-empty">
|
||||
{t("detail.selectCity") || "← 点击左侧行查看详情"}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{store.historyState.isOpen && <HistoryModal />}
|
||||
{store.futureModalDate && <FutureForecastModal />}
|
||||
{showLoading && (
|
||||
@@ -2882,31 +3275,11 @@ function DashboardScreen() {
|
||||
<span className="loading-cloud loading-cloud-1" />
|
||||
<span className="loading-cloud loading-cloud-2" />
|
||||
</div>
|
||||
<div className="loading-windfield" aria-hidden="true">
|
||||
<span className="loading-windline loading-windline-1" />
|
||||
<span className="loading-windline loading-windline-2" />
|
||||
<span className="loading-windline loading-windline-3" />
|
||||
</div>
|
||||
<div className="loading-radar" aria-hidden="true">
|
||||
<div className="loading-radar-core" />
|
||||
<div className="loading-radar-ring loading-radar-ring-1" />
|
||||
<div className="loading-radar-ring loading-radar-ring-2" />
|
||||
<div className="loading-radar-sweep" />
|
||||
<div className="loading-radar-blip loading-radar-blip-1" />
|
||||
<div className="loading-radar-blip loading-radar-blip-2" />
|
||||
</div>
|
||||
<div className="loading-thermals" aria-hidden="true">
|
||||
<span className="loading-thermal loading-thermal-1" />
|
||||
<span className="loading-thermal loading-thermal-2" />
|
||||
<span className="loading-thermal loading-thermal-3" />
|
||||
<span className="loading-thermal loading-thermal-4" />
|
||||
</div>
|
||||
<div className="loading-drizzle" aria-hidden="true">
|
||||
<span className="loading-drizzle-drop loading-drizzle-drop-1" />
|
||||
<span className="loading-drizzle-drop loading-drizzle-drop-2" />
|
||||
<span className="loading-drizzle-drop loading-drizzle-drop-3" />
|
||||
<span className="loading-drizzle-drop loading-drizzle-drop-4" />
|
||||
<span className="loading-drizzle-drop loading-drizzle-drop-5" />
|
||||
</div>
|
||||
<div className="loading-copy">
|
||||
<strong>PolyWeather</strong>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Crosshair,
|
||||
Clock,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
SlidersHorizontal,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import styles from "./Dashboard.module.css";
|
||||
|
||||
interface FilterState {
|
||||
mode: "tradable" | "early" | "touch" | "trend";
|
||||
priceRange: [number, number];
|
||||
minEdge: number;
|
||||
highLiquidityOnly: boolean;
|
||||
marketType: "maxtemp" | "all";
|
||||
timeRange: "today" | "tomorrow" | "week";
|
||||
}
|
||||
|
||||
const SCAN_MODES = [
|
||||
{
|
||||
key: "tradable" as const,
|
||||
icon: Crosshair,
|
||||
labelEn: "Tradable",
|
||||
labelZh: "可交易机会",
|
||||
descEn: "Markets with immediate trading value",
|
||||
descZh: "交易价值最高的市场",
|
||||
},
|
||||
{
|
||||
key: "early" as const,
|
||||
icon: Clock,
|
||||
labelEn: "Early",
|
||||
labelZh: "早期机会",
|
||||
descEn: "Long-horizon positions",
|
||||
descZh: "长周期布局",
|
||||
},
|
||||
{
|
||||
key: "touch" as const,
|
||||
icon: Zap,
|
||||
labelEn: "Touch Play",
|
||||
labelZh: "触达博弈",
|
||||
descEn: "Approaching settle threshold",
|
||||
descZh: "触达博弈最高的市场",
|
||||
},
|
||||
{
|
||||
key: "trend" as const,
|
||||
icon: TrendingUp,
|
||||
labelEn: "Trend",
|
||||
labelZh: "趋势确认",
|
||||
descEn: "Trend-confirmed opportunities",
|
||||
descZh: "趋势确认、顺势加仓",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ScanFilterPanel({
|
||||
onScan,
|
||||
isScanning,
|
||||
}: {
|
||||
onScan?: (filters: FilterState) => void;
|
||||
isScanning?: boolean;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
mode: "tradable",
|
||||
priceRange: [0.05, 0.95],
|
||||
minEdge: 2,
|
||||
highLiquidityOnly: false,
|
||||
marketType: "maxtemp",
|
||||
timeRange: "today",
|
||||
});
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(
|
||||
key: K,
|
||||
value: FilterState[K],
|
||||
) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="scan-filter-panel">
|
||||
{/* === Scan Mode Section === */}
|
||||
<div className="scan-filter-section">
|
||||
<div className="scan-filter-label">
|
||||
{isEn ? "Scan Mode" : "扫描模式"}
|
||||
</div>
|
||||
<div className="scan-mode-tabs">
|
||||
{SCAN_MODES.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const isActive = filters.mode === mode.key;
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
className={`scan-mode-tab ${isActive ? "active" : ""}`}
|
||||
onClick={() => updateFilter("mode", mode.key)}
|
||||
title={isEn ? mode.descEn : mode.descZh}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className="scan-mode-tab-label">
|
||||
{isEn ? mode.labelEn : mode.labelZh}
|
||||
</span>
|
||||
{isActive && <span className="scan-mode-tab-indicator" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === Filter Controls === */}
|
||||
<div className="scan-filter-section">
|
||||
<div className="scan-filter-label">
|
||||
<SlidersHorizontal size={14} />
|
||||
{isEn ? "Filter Criteria" : "筛选条件"}
|
||||
</div>
|
||||
|
||||
{/* Price Range */}
|
||||
<div className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "Price Range" : "价格范围"}
|
||||
</span>
|
||||
<div className="scan-range-display">
|
||||
<span>{filters.priceRange[0].toFixed(2)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={filters.priceRange[0] * 100}
|
||||
onChange={(e) =>
|
||||
updateFilter("priceRange", [
|
||||
Number(e.target.value) / 100,
|
||||
filters.priceRange[1],
|
||||
])
|
||||
}
|
||||
className="scan-range-slider"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={filters.priceRange[1] * 100}
|
||||
onChange={(e) =>
|
||||
updateFilter("priceRange", [
|
||||
filters.priceRange[0],
|
||||
Number(e.target.value) / 100,
|
||||
])
|
||||
}
|
||||
className="scan-range-slider"
|
||||
/>
|
||||
<span>{filters.priceRange[1].toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Min Edge */}
|
||||
<div className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "Min Edge" : "最小边际优势"}
|
||||
</span>
|
||||
<div className="scan-range-display">
|
||||
<span>{filters.minEdge}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={filters.minEdge}
|
||||
onChange={(e) => updateFilter("minEdge", Number(e.target.value))}
|
||||
className="scan-range-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* High Liquidity Only */}
|
||||
<div className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "High Liquidity Only" : "只看高流动性"}
|
||||
</span>
|
||||
<button
|
||||
className={`scan-toggle ${filters.highLiquidityOnly ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
updateFilter("highLiquidityOnly", !filters.highLiquidityOnly)
|
||||
}
|
||||
>
|
||||
<span className="scan-toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Market Type */}
|
||||
<div className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "Market Type" : "市场类型"}
|
||||
</span>
|
||||
<select
|
||||
className="scan-select"
|
||||
value={filters.marketType}
|
||||
onChange={(e) =>
|
||||
updateFilter(
|
||||
"marketType",
|
||||
e.target.value as FilterState["marketType"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="maxtemp">
|
||||
{isEn ? "Max Temperature" : "最高温度"}
|
||||
</option>
|
||||
<option value="all">{isEn ? "All Markets" : "所有市场"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Time Range */}
|
||||
<div className="scan-filter-row">
|
||||
<span className="scan-filter-row-label">
|
||||
{isEn ? "Time Range" : "时间周期"}
|
||||
</span>
|
||||
<select
|
||||
className="scan-select"
|
||||
value={filters.timeRange}
|
||||
onChange={(e) =>
|
||||
updateFilter(
|
||||
"timeRange",
|
||||
e.target.value as FilterState["timeRange"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="today">{isEn ? "Today" : "今天"}</option>
|
||||
<option value="tomorrow">{isEn ? "Tomorrow" : "明天"}</option>
|
||||
<option value="week">{isEn ? "This Week" : "本周"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === CTA === */}
|
||||
<button
|
||||
className="scan-cta-button"
|
||||
onClick={() => onScan?.(filters)}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<Search size={16} />
|
||||
{isScanning
|
||||
? isEn
|
||||
? "Scanning..."
|
||||
: "扫描中..."
|
||||
: isEn
|
||||
? "Start Scan"
|
||||
: "开始扫描"}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TrendingUp, BarChart3, Radio, DollarSign, Target } from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
interface KPIData {
|
||||
recommendedCount: number;
|
||||
recommendedDelta?: number;
|
||||
avgEdge: number | null;
|
||||
avgEdgeDelta?: number | null;
|
||||
totalWinRate: number | null;
|
||||
tradableMarkets: number;
|
||||
filteredTotal: number;
|
||||
totalVolume: number;
|
||||
}
|
||||
|
||||
function formatVolume(value: number): string {
|
||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`;
|
||||
return `$${value.toFixed(0)}`;
|
||||
}
|
||||
|
||||
export function ScanKPIBar({ data }: { data: KPIData }) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const kpis = [
|
||||
{
|
||||
icon: Target,
|
||||
label: isEn ? "Recommended" : "推荐机会",
|
||||
value: String(data.recommendedCount),
|
||||
delta:
|
||||
data.recommendedDelta != null
|
||||
? `${isEn ? "vs last" : "较上次"} ${data.recommendedDelta > 0 ? "+" : ""}${data.recommendedDelta}`
|
||||
: null,
|
||||
accent: "green" as const,
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
label: isEn ? "Avg Edge" : "平均边际优势",
|
||||
value: data.avgEdge != null ? `+${data.avgEdge.toFixed(1)}%` : "--",
|
||||
delta:
|
||||
data.avgEdgeDelta != null
|
||||
? `${isEn ? "vs last" : "较上次"} ${data.avgEdgeDelta > 0 ? "+" : ""}${data.avgEdgeDelta.toFixed(1)}%`
|
||||
: null,
|
||||
accent: "green" as const,
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
label: isEn ? "Total Win Rate" : "总胜率预测",
|
||||
value:
|
||||
data.totalWinRate != null ? `+${data.totalWinRate.toFixed(1)}%` : "--",
|
||||
delta: isEn ? "Model confidence" : "模型全分配",
|
||||
accent: "green" as const,
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: isEn ? "Tradable Markets" : "可交易市场",
|
||||
value: String(data.tradableMarkets),
|
||||
delta: `${isEn ? "Filtered" : "过滤后"} / ${data.filteredTotal}`,
|
||||
accent: "purple" as const,
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
label: isEn ? "Total Volume" : "总成交量",
|
||||
value: formatVolume(data.totalVolume),
|
||||
delta: isEn ? "Past 24h" : "过去 24 小时",
|
||||
accent: "purple" as const,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="scan-kpi-bar">
|
||||
{kpis.map((kpi) => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<div
|
||||
key={kpi.label}
|
||||
className={`scan-kpi-card scan-kpi-${kpi.accent}`}
|
||||
>
|
||||
<div className="scan-kpi-header">
|
||||
<Icon size={14} className="scan-kpi-icon" />
|
||||
<span className="scan-kpi-label">{kpi.label}</span>
|
||||
</div>
|
||||
<div className="scan-kpi-value">{kpi.value}</div>
|
||||
{kpi.delta && <div className="scan-kpi-delta">{kpi.delta}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user