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}
+ ) : (
+
+ )}