diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index adb0c645..0f35785c 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -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; +} diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx new file mode 100644 index 00000000..54f0ceeb --- /dev/null +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -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 --; + const maxProb = Math.max(...buckets.map((b) => b.probability), 0.01); + return ( +
+ {buckets.map((bucket, i) => ( +
+
+ + {bucket.label.replace(/[°CF]/g, "")} + +
+ ))} +
+ ); +} + +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 ( +
+ + + + + + {score} + +
+ ); +} + +export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) { + const { locale } = useI18n(); + const store = useDashboardStore(); + const isEn = locale === "en-US"; + + return ( +
+ {/* Header */} +
+ # + + {isEn ? "City / Market" : "城市 / 市场"} + + + {isEn ? "Local Time / Status" : "当前时间 / 阶段"} + + + {isEn ? "Prob. Dist. vs Market" : "模型分布 vs 市场分布"} + + + {isEn ? "Best Action" : "最佳机会"} + + + {isEn ? "Edge" : "边际优势"} + + + {isEn ? "Score" : "综合得分"} + + +
+ + {/* 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 ( +
store.focusCity(row.city.name)} + > + {/* Rank */} + + {row.rank} + + + {/* City */} + + + + + + {cityName} + + {isEn ? "Today's high" : "今日最高温"} ·{" "} + {getMarketVolume(row.detail)} + + + + + {/* Time + Status */} + + + {getTimeLabel(row.detail)} ({getTimezone(row.detail)}) + + + {status.label} + + + + {/* Probability Chart */} + + + + + {/* Best Action */} + + + {getBestAction(row.detail, locale)} + + + + {/* Edge */} + + 0 ? "positive" : "neutral"}`} + > + {edge != null + ? `${edge > 0 ? "+" : ""}${(edge * 100).toFixed(1)}%` + : "--"} + + + + {/* Score */} + + + + + {/* Favorite */} + + + +
+ ); + })} +
+ ); +} diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx index 58c7df3e..251aedfb 100644 --- a/frontend/components/dashboard/PolyWeatherDashboard.tsx +++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx @@ -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" - : "焦点城市"} + : "焦点城市"}
@@ -1547,54 +1590,55 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) { {tradableOpportunity ? (
-
-

- {marketTitle} -

- - {marketScan && !tradableOpportunity - ? locale === "en-US" - ? "Market closed" - : "市场已结束" - : locale === "en-US" - ? "Updated now" - : "刚刚更新"} - -
-
-
- {marketLabel} - {marketScan?.selected_date || detail?.local_date || ""} +
+

+ {marketTitle} +

+ + {marketScan && !tradableOpportunity + ? locale === "en-US" + ? "Market closed" + : "市场已结束" + : locale === "en-US" + ? "Updated now" + : "刚刚更新"} +
-
- YES {formatCents(yesPrice)} - NO {formatCents(noPrice)} +
+
+ {marketLabel} + + {marketScan?.selected_date || detail?.local_date || ""} + +
+
+ YES {formatCents(yesPrice)} + NO {formatCents(noPrice)} +
-
-
- - {marketEdgeLabel} {formatEdge(marketEdge)} - - - {marketImpliedLabel}{" "} - {formatProbability(marketProbability)} - - - {marketModelLabel}{" "} - {formatProbability(marketModelProbability)} - - -
- {!isPro ? ( - - {locale === "en-US" ? "Unlock market layer" : "解锁市场层"} - - ) : null} +
+ + {marketEdgeLabel} {formatEdge(marketEdge)} + + + {marketImpliedLabel}{" "} + {formatProbability(marketProbability)} + + + {marketModelLabel}{" "} + {formatProbability(marketModelProbability)} + + +
+ {!isPro ? ( + + {locale === "en-US" ? "Unlock market layer" : "解锁市场层"} + + ) : null}
) : null} - ); } @@ -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({
{heroCityName} - {heroDateLabel} + + {heroDateLabel} +

{heroQuestion}

- {heroEngine} + + {heroEngine} +
YES - {formatCents(heroSnapshot.detail?.market_scan?.yes_buy)} + + {formatCents(heroSnapshot.detail?.market_scan?.yes_buy)} +
NO - {formatCents(heroSnapshot.detail?.market_scan?.no_buy)} + + {formatCents(heroSnapshot.detail?.market_scan?.no_buy)} +
@@ -2058,7 +2126,9 @@ function OpportunityStrip({ viewBox="0 0 132 44" aria-hidden="true" > - {heroSparkline ? : null} + {heroSparkline ? ( + + ) : null}
@@ -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 助手" + } >
- {locale === "en-US" ? "AI assistant" : "AI 对话助手"} + + {locale === "en-US" ? "AI assistant" : "AI 对话助手"} + {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 助手" + } > × @@ -2560,13 +2639,17 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) { >

{message.content}

{message.cached ? ( - {locale === "en-US" ? "Cache hit" : "命中缓存"} + + {locale === "en-US" ? "Cache hit" : "命中缓存"} + ) : null}
))} {loading ? (
-

{locale === "en-US" ? "Thinking..." : "正在整理答案..."}

+

+ {locale === "en-US" ? "Thinking..." : "正在整理答案..."} +

) : null}
@@ -2598,7 +2681,11 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) { } />
- {error ? {error} : } + {error ? ( + {error} + ) : ( + + )} + + +
+ + ({ + rank: index + 1, + city: snapshot.city, + summary: snapshot.summary, + detail: snapshot.detail, + score: snapshot.score, + tradable: snapshot.tradableOpportunity, + }))} /> - - - ) : null} - {showCitySyncToast ? ( -
-
- ) : null} + + {/* Right — Scan Detail Panel */} + {store.selectedDetail ? ( + + ) : ( + + )} +
+ {store.historyState.isOpen && } {store.futureModalDate && } {showLoading && ( @@ -2882,31 +3275,11 @@ function DashboardScreen() {
-