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;
|
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 { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||||
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
||||||
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
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 { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
||||||
import type {
|
import type {
|
||||||
CityDetail,
|
CityDetail,
|
||||||
@@ -163,8 +166,10 @@ function getProbabilityLabel(
|
|||||||
},
|
},
|
||||||
symbol: string,
|
symbol: string,
|
||||||
) {
|
) {
|
||||||
if (bucket.label) return normalizeTemperatureBucketLabel(bucket.label, symbol);
|
if (bucket.label)
|
||||||
if (bucket.bucket) return normalizeTemperatureBucketLabel(bucket.bucket, symbol);
|
return normalizeTemperatureBucketLabel(bucket.label, symbol);
|
||||||
|
if (bucket.bucket)
|
||||||
|
return normalizeTemperatureBucketLabel(bucket.bucket, symbol);
|
||||||
if (Number.isFinite(Number(bucket.value))) {
|
if (Number.isFinite(Number(bucket.value))) {
|
||||||
return `≥ ${Math.round(Number(bucket.value))}${symbol}`;
|
return `≥ ${Math.round(Number(bucket.value))}${symbol}`;
|
||||||
}
|
}
|
||||||
@@ -206,7 +211,9 @@ function getBucketMatchKey(bucket?: {
|
|||||||
}) {
|
}) {
|
||||||
const threshold = parseBucketThreshold(bucket);
|
const threshold = parseBucketThreshold(bucket);
|
||||||
if (threshold != null) return `t:${threshold.toFixed(2)}`;
|
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) {
|
function normalizeProbabilityValue(value: number | null | undefined) {
|
||||||
@@ -286,6 +293,31 @@ function formatEdge(value: number | null | undefined) {
|
|||||||
return `${sign}${normalized.toFixed(1)}%`;
|
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) {
|
function normalizeEdgePercent(value: number | null | undefined) {
|
||||||
if (!Number.isFinite(Number(value))) return null;
|
if (!Number.isFinite(Number(value))) return null;
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -301,7 +333,10 @@ function getBestSide(detail?: CityDetail | null) {
|
|||||||
return 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];
|
const sideAnalysis = detail?.market_scan?.price_analysis?.[side];
|
||||||
return normalizeEdgePercent(sideAnalysis?.edge_percent ?? sideAnalysis?.edge);
|
return normalizeEdgePercent(sideAnalysis?.edge_percent ?? sideAnalysis?.edge);
|
||||||
}
|
}
|
||||||
@@ -571,7 +606,9 @@ function buildAssistantOpportunityContext(
|
|||||||
detail?.airport_current?.max_so_far ??
|
detail?.airport_current?.max_so_far ??
|
||||||
null,
|
null,
|
||||||
market_question: marketQuestion,
|
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,
|
selected_date: marketScan?.selected_date || detail?.local_date || null,
|
||||||
best_side: marketScan?.price_analysis?.best_side || null,
|
best_side: marketScan?.price_analysis?.best_side || null,
|
||||||
yes_price: normalizeAssistantCents(
|
yes_price: normalizeAssistantCents(
|
||||||
@@ -623,10 +660,12 @@ function buildAssistantContextPayload(
|
|||||||
totals: {
|
totals: {
|
||||||
cities: snapshots.length,
|
cities: snapshots.length,
|
||||||
tradable_markets: opportunities.filter((item) => item.tradable).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")
|
medium_risk: opportunities.filter((item) => item.risk_level === "medium")
|
||||||
.length,
|
.length,
|
||||||
low_risk: opportunities.filter((item) => item.risk_level === "low").length,
|
low_risk: opportunities.filter((item) => item.risk_level === "low")
|
||||||
|
.length,
|
||||||
},
|
},
|
||||||
selected_city: selected,
|
selected_city: selected,
|
||||||
opportunities,
|
opportunities,
|
||||||
@@ -662,7 +701,8 @@ function buildAssistantContextPayload(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
term: "市场概率",
|
term: "市场概率",
|
||||||
meaning: "市场概率来自 YES/NO 实时价格的隐含概率,而不是模型输出。",
|
meaning:
|
||||||
|
"市场概率来自 YES/NO 实时价格的隐含概率,而不是模型输出。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
term: "DEB",
|
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") {
|
if (locale === "en-US") {
|
||||||
return selectedCityName
|
return selectedCityName
|
||||||
? `Ask about ${selectedCityName}'s current temperature, today's forecast high, market edge, or live opportunities.`
|
? `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"
|
: locale === "en-US"
|
||||||
? "FOCUS CITY"
|
? "FOCUS CITY"
|
||||||
: "焦点城市"}
|
: "焦点城市"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1547,54 +1590,55 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
|
|
||||||
{tradableOpportunity ? (
|
{tradableOpportunity ? (
|
||||||
<div className={clsx("home-card-section market", !isPro && "locked")}>
|
<div className={clsx("home-card-section market", !isPro && "locked")}>
|
||||||
<div className="home-market-header">
|
<div className="home-market-header">
|
||||||
<h3>
|
<h3>
|
||||||
{marketTitle} <small>ⓘ</small>
|
{marketTitle} <small>ⓘ</small>
|
||||||
</h3>
|
</h3>
|
||||||
<span>
|
<span>
|
||||||
{marketScan && !tradableOpportunity
|
{marketScan && !tradableOpportunity
|
||||||
? locale === "en-US"
|
? locale === "en-US"
|
||||||
? "Market closed"
|
? "Market closed"
|
||||||
: "市场已结束"
|
: "市场已结束"
|
||||||
: locale === "en-US"
|
: locale === "en-US"
|
||||||
? "Updated now"
|
? "Updated now"
|
||||||
: "刚刚更新"}
|
: "刚刚更新"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="home-market-ticket">
|
|
||||||
<div className="home-market-question">
|
|
||||||
<strong>{marketLabel}</strong>
|
|
||||||
<span>{marketScan?.selected_date || detail?.local_date || ""}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="home-market-prices">
|
<div className="home-market-ticket">
|
||||||
<span className="yes">YES {formatCents(yesPrice)}</span>
|
<div className="home-market-question">
|
||||||
<span className="no">NO {formatCents(noPrice)}</span>
|
<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>
|
<div className="home-market-metrics">
|
||||||
<div className="home-market-metrics">
|
<span>
|
||||||
<span>
|
{marketEdgeLabel} <strong>{formatEdge(marketEdge)}</strong>
|
||||||
{marketEdgeLabel} <strong>{formatEdge(marketEdge)}</strong>
|
</span>
|
||||||
</span>
|
<span>
|
||||||
<span>
|
{marketImpliedLabel}{" "}
|
||||||
{marketImpliedLabel}{" "}
|
<strong>{formatProbability(marketProbability)}</strong>
|
||||||
<strong>{formatProbability(marketProbability)}</strong>
|
</span>
|
||||||
</span>
|
<span>
|
||||||
<span>
|
{marketModelLabel}{" "}
|
||||||
{marketModelLabel}{" "}
|
<strong>{formatProbability(marketModelProbability)}</strong>
|
||||||
<strong>{formatProbability(marketModelProbability)}</strong>
|
</span>
|
||||||
</span>
|
<svg viewBox="0 0 96 32" aria-hidden="true">
|
||||||
<svg viewBox="0 0 96 32" aria-hidden="true">
|
{sparklinePoints ? <polyline points={sparklinePoints} /> : null}
|
||||||
{sparklinePoints ? <polyline points={sparklinePoints} /> : null}
|
</svg>
|
||||||
</svg>
|
</div>
|
||||||
</div>
|
{!isPro ? (
|
||||||
{!isPro ? (
|
<Link href="/account" className="home-market-lock">
|
||||||
<Link href="/account" className="home-market-lock">
|
{locale === "en-US" ? "Unlock market layer" : "解锁市场层"}
|
||||||
{locale === "en-US" ? "Unlock market layer" : "解锁市场层"}
|
</Link>
|
||||||
</Link>
|
) : null}
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1613,9 +1657,10 @@ function OpportunityStrip({
|
|||||||
const stripState = useMemo(() => {
|
const stripState = useMemo(() => {
|
||||||
const targetSet = new Set(scanTargetNames);
|
const targetSet = new Set(scanTargetNames);
|
||||||
const totalCount = scanTargetNames.length || snapshots.length;
|
const totalCount = scanTargetNames.length || snapshots.length;
|
||||||
const targetedSnapshots = (scanTargetNames.length
|
const targetedSnapshots = (
|
||||||
? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name))
|
scanTargetNames.length
|
||||||
: snapshots
|
? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name))
|
||||||
|
: snapshots
|
||||||
).slice(0, Math.max(scanTargetNames.length, 1));
|
).slice(0, Math.max(scanTargetNames.length, 1));
|
||||||
const effectiveStatusByCity = Object.fromEntries(
|
const effectiveStatusByCity = Object.fromEntries(
|
||||||
targetedSnapshots.map((snapshot) => [
|
targetedSnapshots.map((snapshot) => [
|
||||||
@@ -1641,10 +1686,13 @@ function OpportunityStrip({
|
|||||||
.filter((snapshot) => snapshot.tradableOpportunity)
|
.filter((snapshot) => snapshot.tradableOpportunity)
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const leftEdge = normalizeEdgePercent(getMarketEdgeValue(left.detail));
|
const leftEdge = normalizeEdgePercent(getMarketEdgeValue(left.detail));
|
||||||
const rightEdge = normalizeEdgePercent(getMarketEdgeValue(right.detail));
|
const rightEdge = normalizeEdgePercent(
|
||||||
|
getMarketEdgeValue(right.detail),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
Number(rightEdge ?? Number.NEGATIVE_INFINITY) -
|
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(
|
const yesCount = liveSnapshots.filter(
|
||||||
@@ -1655,7 +1703,9 @@ function OpportunityStrip({
|
|||||||
).length;
|
).length;
|
||||||
const avgTradableEdge = tradableSnapshots.length
|
const avgTradableEdge = tradableSnapshots.length
|
||||||
? tradableSnapshots.reduce((sum, snapshot) => {
|
? tradableSnapshots.reduce((sum, snapshot) => {
|
||||||
const edge = normalizeEdgePercent(getMarketEdgeValue(snapshot.detail));
|
const edge = normalizeEdgePercent(
|
||||||
|
getMarketEdgeValue(snapshot.detail),
|
||||||
|
);
|
||||||
return sum + (edge ?? 0);
|
return sum + (edge ?? 0);
|
||||||
}, 0) / tradableSnapshots.length
|
}, 0) / tradableSnapshots.length
|
||||||
: null;
|
: null;
|
||||||
@@ -1675,8 +1725,8 @@ function OpportunityStrip({
|
|||||||
const marketScan = snapshot.detail?.market_scan;
|
const marketScan = snapshot.detail?.market_scan;
|
||||||
return Boolean(
|
return Boolean(
|
||||||
marketScan?.price_analysis?.available ||
|
marketScan?.price_analysis?.available ||
|
||||||
marketScan?.yes_buy != null ||
|
marketScan?.yes_buy != null ||
|
||||||
marketScan?.no_buy != null,
|
marketScan?.no_buy != null,
|
||||||
);
|
);
|
||||||
}).length;
|
}).length;
|
||||||
const noEdgeCount = Math.max(quoteReadyCount - tradableSnapshots.length, 0);
|
const noEdgeCount = Math.max(quoteReadyCount - tradableSnapshots.length, 0);
|
||||||
@@ -1782,7 +1832,9 @@ function OpportunityStrip({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
overviewKicker:
|
overviewKicker:
|
||||||
locale === "en-US" ? "EMOS / CLOB Decision Layer" : "EMOS / CLOB 交易决策层",
|
locale === "en-US"
|
||||||
|
? "EMOS / CLOB Decision Layer"
|
||||||
|
: "EMOS / CLOB 交易决策层",
|
||||||
headingTitle:
|
headingTitle:
|
||||||
tradableSnapshots.length > 0
|
tradableSnapshots.length > 0
|
||||||
? locale === "en-US"
|
? locale === "en-US"
|
||||||
@@ -1830,7 +1882,8 @@ function OpportunityStrip({
|
|||||||
key: "avg-edge",
|
key: "avg-edge",
|
||||||
label: locale === "en-US" ? "Best edge" : "最佳 edge",
|
label: locale === "en-US" ? "Best edge" : "最佳 edge",
|
||||||
value: formatEdge(bestTradableEdge),
|
value: formatEdge(bestTradableEdge),
|
||||||
accent: bestTradableEdge != null ? ("green" as const) : ("slate" as const),
|
accent:
|
||||||
|
bestTradableEdge != null ? ("green" as const) : ("slate" as const),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
yesCountLabel:
|
yesCountLabel:
|
||||||
@@ -1854,7 +1907,11 @@ function OpportunityStrip({
|
|||||||
const heroBestProbability = getBestSideModelProbability(heroSnapshot?.detail);
|
const heroBestProbability = getBestSideModelProbability(heroSnapshot?.detail);
|
||||||
const heroBestEdge = getExecutableEdgePercent(heroSnapshot?.detail);
|
const heroBestEdge = getExecutableEdgePercent(heroSnapshot?.detail);
|
||||||
const heroSymbol = heroSnapshot
|
const heroSymbol = heroSnapshot
|
||||||
? getTempSymbol(heroSnapshot.city, heroSnapshot.summary, heroSnapshot.detail)
|
? getTempSymbol(
|
||||||
|
heroSnapshot.city,
|
||||||
|
heroSnapshot.summary,
|
||||||
|
heroSnapshot.detail,
|
||||||
|
)
|
||||||
: "°C";
|
: "°C";
|
||||||
const heroTier =
|
const heroTier =
|
||||||
heroSnapshot?.city.deb_recent_tier ||
|
heroSnapshot?.city.deb_recent_tier ||
|
||||||
@@ -1869,7 +1926,8 @@ function OpportunityStrip({
|
|||||||
heroSnapshot.detail,
|
heroSnapshot.detail,
|
||||||
)
|
)
|
||||||
: "--";
|
: "--";
|
||||||
const heroMarketBucket = heroSnapshot?.detail?.market_scan?.temperature_bucket;
|
const heroMarketBucket =
|
||||||
|
heroSnapshot?.detail?.market_scan?.temperature_bucket;
|
||||||
const heroQuestion =
|
const heroQuestion =
|
||||||
heroSnapshot?.detail?.market_scan?.primary_market?.question ||
|
heroSnapshot?.detail?.market_scan?.primary_market?.question ||
|
||||||
`${getProbabilityLabel(heroMarketBucket || {}, heroSymbol)} ${
|
`${getProbabilityLabel(heroMarketBucket || {}, heroSymbol)} ${
|
||||||
@@ -1916,7 +1974,9 @@ function OpportunityStrip({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
const secondaryItems = heroSnapshot
|
const secondaryItems = heroSnapshot
|
||||||
? stripState.items.filter((snapshot) => snapshot.city.name !== heroSnapshot.city.name)
|
? stripState.items.filter(
|
||||||
|
(snapshot) => snapshot.city.name !== heroSnapshot.city.name,
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1968,12 +2028,16 @@ function OpportunityStrip({
|
|||||||
</span>
|
</span>
|
||||||
<div className="opportunity-hero-title-row">
|
<div className="opportunity-hero-title-row">
|
||||||
<strong>{heroCityName}</strong>
|
<strong>{heroCityName}</strong>
|
||||||
<span className="opportunity-hero-date">{heroDateLabel}</span>
|
<span className="opportunity-hero-date">
|
||||||
|
{heroDateLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{heroQuestion}</p>
|
<p>{heroQuestion}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="opportunity-hero-tags">
|
<div className="opportunity-hero-tags">
|
||||||
<span className="opportunity-hero-tag engine">{heroEngine}</span>
|
<span className="opportunity-hero-tag engine">
|
||||||
|
{heroEngine}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"opportunity-hero-tag",
|
"opportunity-hero-tag",
|
||||||
@@ -2043,11 +2107,15 @@ function OpportunityStrip({
|
|||||||
<div className="opportunity-hero-book">
|
<div className="opportunity-hero-book">
|
||||||
<div className="opportunity-book-side yes">
|
<div className="opportunity-book-side yes">
|
||||||
<span>YES</span>
|
<span>YES</span>
|
||||||
<strong>{formatCents(heroSnapshot.detail?.market_scan?.yes_buy)}</strong>
|
<strong>
|
||||||
|
{formatCents(heroSnapshot.detail?.market_scan?.yes_buy)}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="opportunity-book-side no">
|
<div className="opportunity-book-side no">
|
||||||
<span>NO</span>
|
<span>NO</span>
|
||||||
<strong>{formatCents(heroSnapshot.detail?.market_scan?.no_buy)}</strong>
|
<strong>
|
||||||
|
{formatCents(heroSnapshot.detail?.market_scan?.no_buy)}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="opportunity-hero-sparkline-wrap">
|
<div className="opportunity-hero-sparkline-wrap">
|
||||||
<span>
|
<span>
|
||||||
@@ -2058,7 +2126,9 @@ function OpportunityStrip({
|
|||||||
viewBox="0 0 132 44"
|
viewBox="0 0 132 44"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{heroSparkline ? <polyline points={heroSparkline} /> : null}
|
{heroSparkline ? (
|
||||||
|
<polyline points={heroSparkline} />
|
||||||
|
) : null}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2242,8 +2312,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
const selectedSnapshot = useMemo(
|
const selectedSnapshot = useMemo(
|
||||||
() =>
|
() =>
|
||||||
store.selectedCity
|
store.selectedCity
|
||||||
? snapshots.find((snapshot) => snapshot.city.name === store.selectedCity) ||
|
? snapshots.find(
|
||||||
null
|
(snapshot) => snapshot.city.name === store.selectedCity,
|
||||||
|
) || null
|
||||||
: null,
|
: null,
|
||||||
[snapshots, store.selectedCity],
|
[snapshots, store.selectedCity],
|
||||||
);
|
);
|
||||||
@@ -2310,7 +2381,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
}
|
}
|
||||||
setDockPosition((current) =>
|
setDockPosition((current) =>
|
||||||
clampAssistantDockPosition(
|
clampAssistantDockPosition(
|
||||||
current || readAssistantDockPosition() || getDefaultAssistantDockPosition(),
|
current ||
|
||||||
|
readAssistantDockPosition() ||
|
||||||
|
getDefaultAssistantDockPosition(),
|
||||||
dockRef.current,
|
dockRef.current,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2496,7 +2569,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
onPointerMove={updateDockDrag}
|
onPointerMove={updateDockDrag}
|
||||||
onPointerUp={endDockDrag}
|
onPointerUp={endDockDrag}
|
||||||
onPointerCancel={endDockDrag}
|
onPointerCancel={endDockDrag}
|
||||||
aria-label={locale === "en-US" ? "Open AI assistant" : "打开 AI 助手"}
|
aria-label={
|
||||||
|
locale === "en-US" ? "Open AI assistant" : "打开 AI 助手"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/favicon-32x32.png"
|
src="/favicon-32x32.png"
|
||||||
@@ -2513,7 +2588,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
>
|
>
|
||||||
<div className="home-ai-header">
|
<div className="home-ai-header">
|
||||||
<div>
|
<div>
|
||||||
<strong>{locale === "en-US" ? "AI assistant" : "AI 对话助手"}</strong>
|
<strong>
|
||||||
|
{locale === "en-US" ? "AI assistant" : "AI 对话助手"}
|
||||||
|
</strong>
|
||||||
<span>
|
<span>
|
||||||
{locale === "en-US"
|
{locale === "en-US"
|
||||||
? "Ask about cities, forecast highs, edge, and live opportunities"
|
? "Ask about cities, forecast highs, edge, and live opportunities"
|
||||||
@@ -2536,7 +2613,9 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="home-ai-close"
|
className="home-ai-close"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
aria-label={locale === "en-US" ? "Close assistant" : "关闭 AI 助手"}
|
aria-label={
|
||||||
|
locale === "en-US" ? "Close assistant" : "关闭 AI 助手"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -2560,13 +2639,17 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
>
|
>
|
||||||
<p>{message.content}</p>
|
<p>{message.content}</p>
|
||||||
{message.cached ? (
|
{message.cached ? (
|
||||||
<small>{locale === "en-US" ? "Cache hit" : "命中缓存"}</small>
|
<small>
|
||||||
|
{locale === "en-US" ? "Cache hit" : "命中缓存"}
|
||||||
|
</small>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="home-ai-message assistant loading">
|
<div className="home-ai-message assistant loading">
|
||||||
<p>{locale === "en-US" ? "Thinking..." : "正在整理答案..."}</p>
|
<p>
|
||||||
|
{locale === "en-US" ? "Thinking..." : "正在整理答案..."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
@@ -2598,7 +2681,11 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="home-ai-composer-actions">
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="home-ai-send"
|
className="home-ai-send"
|
||||||
@@ -2613,7 +2700,10 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPaywall ? (
|
{showPaywall ? (
|
||||||
<div className="home-ai-paywall-backdrop" onClick={() => setShowPaywall(false)}>
|
<div
|
||||||
|
className="home-ai-paywall-backdrop"
|
||||||
|
onClick={() => setShowPaywall(false)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="home-ai-paywall-shell"
|
className="home-ai-paywall-shell"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
@@ -2822,7 +2912,10 @@ function DashboardScreen() {
|
|||||||
|
|
||||||
void refreshAllMarketScans();
|
void refreshAllMarketScans();
|
||||||
intervalId = window.setInterval(() => {
|
intervalId = window.setInterval(() => {
|
||||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
if (
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
document.visibilityState === "hidden"
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshAllMarketScans();
|
void refreshAllMarketScans();
|
||||||
@@ -2851,28 +2944,328 @@ function DashboardScreen() {
|
|||||||
modalChromeStyles.root,
|
modalChromeStyles.root,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MapCanvas />
|
|
||||||
<HeaderBar />
|
<HeaderBar />
|
||||||
<CitySidebar />
|
<div className="scan-terminal">
|
||||||
{showHomepageChrome ? (
|
{/* Left — Filter Panel */}
|
||||||
<>
|
<ScanFilterPanel />
|
||||||
<HomeIntelligencePanel snapshots={homepageSnapshots} />
|
|
||||||
<OpportunityStrip
|
{/* Center — Data Grid */}
|
||||||
snapshots={homepageSnapshots}
|
<div className="scan-data-grid">
|
||||||
marketScanStatusByCity={marketScanStatusByCity}
|
<div className="scan-data-grid-header">
|
||||||
scanTargetNames={marketScanTargetNames}
|
<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>
|
</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.historyState.isOpen && <HistoryModal />}
|
||||||
{store.futureModalDate && <FutureForecastModal />}
|
{store.futureModalDate && <FutureForecastModal />}
|
||||||
{showLoading && (
|
{showLoading && (
|
||||||
@@ -2882,31 +3275,11 @@ function DashboardScreen() {
|
|||||||
<span className="loading-cloud loading-cloud-1" />
|
<span className="loading-cloud loading-cloud-1" />
|
||||||
<span className="loading-cloud loading-cloud-2" />
|
<span className="loading-cloud loading-cloud-2" />
|
||||||
</div>
|
</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" aria-hidden="true">
|
||||||
<div className="loading-radar-core" />
|
<div className="loading-radar-core" />
|
||||||
<div className="loading-radar-ring loading-radar-ring-1" />
|
<div className="loading-radar-ring loading-radar-ring-1" />
|
||||||
<div className="loading-radar-ring loading-radar-ring-2" />
|
<div className="loading-radar-ring loading-radar-ring-2" />
|
||||||
<div className="loading-radar-sweep" />
|
<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>
|
||||||
<div className="loading-copy">
|
<div className="loading-copy">
|
||||||
<strong>PolyWeather</strong>
|
<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