feat: implement dashboard layout with scan filter panel, KPI summary bar, and opportunity table components

This commit is contained in:
2569718930@qq.com
2026-04-23 22:48:57 +08:00
parent 79a4333aaf
commit b54c86cdb3
5 changed files with 1995 additions and 122 deletions
@@ -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>
);
}