diff --git a/frontend/app/api/scan/terminal/route.ts b/frontend/app/api/scan/terminal/route.ts new file mode 100644 index 00000000..553f6bf4 --- /dev/null +++ b/frontend/app/api/scan/terminal/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + const params = new URLSearchParams(); + for (const key of [ + "scan_mode", + "min_price", + "max_price", + "min_edge_pct", + "min_liquidity", + "high_liquidity_only", + "market_type", + "time_range", + "limit", + "force_refresh", + ]) { + const value = req.nextUrl.searchParams.get(key); + if (value != null && value !== "") { + params.set(key, value); + } + } + + const url = `${API_BASE}/api/scan/terminal?${params.toString()}`; + + try { + const auth = await buildBackendRequestHeaders(req); + const res = await fetch(url, { + headers: auth.headers, + cache: "no-store", + }); + if (!res.ok) { + const raw = await res.text(); + const response = NextResponse.json( + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, + { status: 502 }, + ); + return applyAuthResponseCookies(response, auth.response); + } + const data = await res.json(); + const response = NextResponse.json(data, { + headers: { + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch scan terminal data", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/components/dashboard/DashboardEntry.tsx b/frontend/components/dashboard/DashboardEntry.tsx index ee914549..74f53fda 100644 --- a/frontend/components/dashboard/DashboardEntry.tsx +++ b/frontend/components/dashboard/DashboardEntry.tsx @@ -3,10 +3,10 @@ import dynamic from "next/dynamic"; import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton"; -const PolyWeatherDashboard = dynamic( +const ScanTerminalDashboard = dynamic( () => - import("@/components/dashboard/PolyWeatherDashboard").then( - (module) => module.PolyWeatherDashboard, + import("@/components/dashboard/ScanTerminalDashboard").then( + (module) => module.ScanTerminalDashboard, ), { ssr: false, @@ -15,5 +15,5 @@ const PolyWeatherDashboard = dynamic( ); export function DashboardEntry() { - return ; + return ; } diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 5bdafc02..355fdbf3 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -1,160 +1,63 @@ "use client"; -import React, { useMemo } from "react"; -import Image from "next/image"; +import React from "react"; 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"; +import type { ScanOpportunityRow } from "@/lib/dashboard-types"; +import { getLocalizedCityName } 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}`; +function getStatusMeta( + row: ScanOpportunityRow, + locale: string, +): { label: string; tone: "green" | "amber" | "purple" | "neutral" } { + const phase = String(row.window_phase || "").toLowerCase(); + if (phase === "active_peak" || phase === "setup_today") { + return { label: locale === "en-US" ? "Tradable" : "可交易", tone: "green" }; } - 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) + if (phase === "tomorrow" || phase === "week_ahead") { 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 getDisplayScore(rawScore: number): number { - if (rawScore <= 0) return Math.max(0, 70 + rawScore / 1000); - if (rawScore > 1000) { - return Math.min(100, 80 + (rawScore - 1000) / 30); } - return Math.min(80, Math.max(0, (rawScore / 1000) * 80)); + if (phase === "post_peak") { + return { label: locale === "en-US" ? "Post Peak" : "峰后确认", tone: "amber" }; + } + return { label: locale === "en-US" ? "Watching" : "观察中", tone: "neutral" }; } -function ScoreRing({ score }: { score: number }) { - const displayScore = getDisplayScore(score); +function formatPercent(value?: number | null, signed = false) { + if (value == null || Number.isNaN(Number(value))) return "--"; + const numeric = Number(value); + if (!signed) return `${numeric.toFixed(1)}%`; + return `${numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`; +} + +function formatTimeBlock(row: ScanOpportunityRow, locale: string) { + const parts: string[] = []; + if (row.local_time) { + parts.push(row.local_time); + } + if (row.selected_date) { + parts.push(row.selected_date); + } + return parts.join(" · ") || (locale === "en-US" ? "No time" : "暂无时间"); +} + +function formatAction(row: ScanOpportunityRow, locale: string) { + if (row.side === "yes") { + return `${locale === "en-US" ? "Buy" : "买入"} Yes ${row.target_label || ""}`.trim(); + } + if (row.side === "no") { + return `${locale === "en-US" ? "Buy" : "买入"} No ${row.target_label || ""}`.trim(); + } + return row.action || "--"; +} + +function formatProbability(value?: number | null) { + if (value == null || Number.isNaN(Number(value))) return "--"; + return `${(Number(value) * 100).toFixed(0)}%`; +} + +function ScoreRing({ score }: { score?: number | null }) { + const displayScore = Math.max(0, Math.min(100, Number(score || 0))); const radius = 20; const circumference = 2 * Math.PI * radius; const progress = (displayScore / 100) * circumference; @@ -191,27 +94,33 @@ function ScoreRing({ score }: { score: number }) { ); } -export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) { +export function OpportunityTable({ + rows, + selectedRowId, + onSelectRow, +}: { + rows: ScanOpportunityRow[]; + selectedRowId?: string | null; + onSelectRow?: (row: ScanOpportunityRow) => void; +}) { const { locale } = useI18n(); - const store = useDashboardStore(); const isEn = locale === "en-US"; return (
- {/* Header */}
# {isEn ? "City / Market" : "城市 / 市场"} - {isEn ? "Local Time / Status" : "当前时间 / 阶段"} + {isEn ? "Time / Phase" : "时间 / 阶段"} - {isEn ? "Prob. Dist. vs Market" : "模型分布 vs 市场分布"} + {isEn ? "EMOS vs Market" : "EMOS vs 市场"} - {isEn ? "Best Action" : "最佳机会"} + {isEn ? "Best Action" : "最佳动作"} {isEn ? "Edge" : "边际优势"} @@ -222,90 +131,99 @@ export function OpportunityTable({ rows }: { rows: OpportunityRow[] }) {
- {/* Rows */} - {rows.map((row) => { - const cityName = getLocalizedCityDisplay( + {rows.map((row, index) => { + const status = getStatusMeta(row, locale); + const localizedCityName = getLocalizedCityName( row.city, + row.city_display_name || row.display_name || 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"; + const selected = selectedRowId === row.id; + const finalScore = Number(row.final_score || 0); return ( -
store.focusCity(row.city.name)} +
+ ); })} + + {!rows.length ? ( +
+
+
+ {isEn ? "No primary signal" : "当前无主信号"} +
+

+ {isEn + ? "No opportunity passed the price, spread, liquidity, and edge filters." + : "当前没有机会同时满足价格、点差、流动性和 edge 过滤。"} +

+
+
+ ) : null}
); } diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx index e950404b..54c7e0da 100644 --- a/frontend/components/dashboard/PolyWeatherDashboard.tsx +++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx @@ -1,3318 +1,7 @@ "use client"; -import clsx from "clsx"; -import dynamic from "next/dynamic"; -import Image from "next/image"; -import Link from "next/link"; -import { - useEffect, - useMemo, - useRef, - useState, - type FormEvent, - type PointerEvent as ReactPointerEvent, -} from "react"; -import { Search } from "lucide-react"; -import styles from "./Dashboard.module.css"; -import detailChromeStyles from "./DetailPanelChrome.module.css"; -import modalChromeStyles from "./ModalChrome.module.css"; -import { - DashboardStoreProvider, - useDashboardStore, -} from "@/hooks/useDashboardStore"; -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, - CityListItem, - CitySummary, - MarketScan, - MarketTopBucket, - ProbabilityBucket, - RiskLevel, -} from "@/lib/dashboard-types"; -import type { - AssistantContextPayload, - AssistantOpportunityContext, -} from "@/lib/dashboard-client"; -import { dashboardClient, getCityRevision } from "@/lib/dashboard-client"; -import { - getLocalizedAirportDisplay, - getLocalizedCityDisplay, -} from "@/lib/dashboard-home-copy"; -import { - getTemperatureChartData, - getWeatherSummary, -} from "@/lib/dashboard-utils"; -const loadHistoryModal = () => - import("@/components/dashboard/HistoryModal").then( - (module) => module.HistoryModal, - ); - -const loadFutureForecastModal = () => - import("@/components/dashboard/FutureForecastModal").then( - (module) => module.FutureForecastModal, - ); - -const MapCanvas = dynamic( - () => - import("@/components/dashboard/MapCanvas").then( - (module) => module.MapCanvas, - ), - { - ssr: false, - loading: () =>