From 329529bd1d73ba8716904a2b0ce854744e485dc8 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 24 Apr 2026 13:43:05 +0800 Subject: [PATCH] feat: add ScanTerminalDashboard component for visualizing and filtering scan opportunities --- .../dashboard/ScanTerminalDashboard.tsx | 978 +----------------- 1 file changed, 4 insertions(+), 974 deletions(-) diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index bbf567f2..807add15 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -14,7 +14,6 @@ import { useDeferredValue, useEffect, useMemo, - useRef, useState, } from "react"; import styles from "./Dashboard.module.css"; @@ -35,26 +34,13 @@ import { useDashboardStore, } from "@/hooks/useDashboardStore"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; -import { - dashboardClient, - type AssistantContextPayload, - type AssistantOpportunityContext, -} from "@/lib/dashboard-client"; +import { dashboardClient } from "@/lib/dashboard-client"; import type { - DistributionPreviewPoint, - MarketScan, - PrimarySignal, ScanOpportunityRow, ScanTerminalResponse, } from "@/lib/dashboard-types"; -import { - getLocalizedAirportName, - getLocalizedCityName, -} from "@/lib/dashboard-home-copy"; -import { - formatTemperatureValue, - normalizeTemperatureLabel, -} from "@/lib/dashboard-utils"; +import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; +import { formatTemperatureValue } from "@/lib/dashboard-utils"; const DEFAULT_FILTERS: FilterState = { scan_mode: "tradable", @@ -72,68 +58,6 @@ const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000; type ContentView = "list" | "map" | "calendar"; type ThemeMode = "dark" | "light"; -type AssistantMessage = { - id: string; - role: "assistant" | "user"; - content: string; -}; - -function formatPercent(value?: number | null, signed = false) { - if (value == null || Number.isNaN(Number(value))) return "--"; - const numeric = Number(value); - return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`; -} - -function formatProbability(value?: number | null) { - if (value == null || Number.isNaN(Number(value))) return "--"; - return `${(Number(value) * 100).toFixed(0)}%`; -} - -function formatPrice(value?: number | null) { - if (value == null || Number.isNaN(Number(value))) return "--"; - return `${Math.round(Number(value) * 100)}¢`; -} - -function formatVolume(value?: number | null) { - const numeric = Number(value || 0); - if (!Number.isFinite(numeric) || numeric <= 0) return "--"; - if (numeric >= 1_000_000) return `$${(numeric / 1_000_000).toFixed(1)}M`; - if (numeric >= 1_000) return `$${(numeric / 1_000).toFixed(0)}K`; - return `$${numeric.toFixed(0)}`; -} - -function formatRemainingWindow(value?: number | null, locale = "zh-CN") { - if (value == null || !Number.isFinite(Number(value))) return "--"; - const numeric = Math.max(0, Math.round(Number(value))); - const hours = Math.floor(numeric / 60); - const minutes = numeric % 60; - if (locale === "en-US") { - if (hours <= 0) return `${minutes}m`; - return `${hours}h ${minutes}m`; - } - if (hours <= 0) return `${minutes} 分钟`; - return `${hours}h ${minutes}m`; -} - -function scoreTone(score?: number | null) { - const numeric = Number(score || 0); - if (numeric >= 85) return "green"; - if (numeric >= 70) return "yellow"; - return "red"; -} - -function confidenceLabel(score?: number | null, locale = "zh-CN") { - const numeric = Number(score || 0); - if (locale === "en-US") { - if (numeric >= 85) return "High"; - if (numeric >= 70) return "Medium"; - return "Watch"; - } - if (numeric >= 85) return "高"; - if (numeric >= 70) return "中"; - return "观察"; -} - function formatShortDate(value?: string | null, locale = "zh-CN") { const text = String(value || "").trim(); if (!text) return "--"; @@ -217,542 +141,6 @@ function findRowForCity(rows: ScanOpportunityRow[], cityName?: string | null) { return rows.find((row) => rowMatchesCity(row, cityName || "")) || null; } -function getSideRow( - marketScan: MarketScan | null | undefined, - selectedRow: ScanOpportunityRow, - side: "yes" | "no", -) { - const rows = Array.isArray(marketScan?.scan_rows) ? marketScan.scan_rows : []; - const matched = rows.find( - (row) => - row.market_slug === selectedRow.market_slug && - row.selected_date === selectedRow.selected_date && - row.side === side, - ); - if (matched) return matched; - if (selectedRow.side === side) return selectedRow; - return null; -} - -function getDetailSideAsk( - scanRow: ScanOpportunityRow | null, - marketScan: MarketScan | null | undefined, - side: "yes" | "no", -) { - if (scanRow?.ask != null) return scanRow.ask; - if (side === "yes") { - if (scanRow?.yes_ask != null) return scanRow.yes_ask; - return marketScan?.yes_buy ?? null; - } - if (scanRow?.no_ask != null) return scanRow.no_ask; - return marketScan?.no_buy ?? null; -} - -function getDetailSideBid( - scanRow: ScanOpportunityRow | null, - marketScan: MarketScan | null | undefined, - side: "yes" | "no", -) { - if (scanRow?.bid != null) return scanRow.bid; - if (side === "yes") { - if (scanRow?.yes_bid != null) return scanRow.yes_bid; - return marketScan?.yes_sell ?? null; - } - if (scanRow?.no_bid != null) return scanRow.no_bid; - return marketScan?.no_sell ?? null; -} - -function buildComparisonBuckets( - marketScan: MarketScan | null | undefined, - row: ScanOpportunityRow | null, -) { - const rowPreview = Array.isArray(row?.distribution_preview) - ? row.distribution_preview.filter( - (item): item is DistributionPreviewPoint => - Boolean(item && (item.label || item.value != null)), - ) - : []; - if (rowPreview.length) { - return rowPreview.slice(0, 6).map((item) => ({ - label: String(item.label ?? item.value ?? "--"), - model: Number(item.model_probability ?? 0) * 100, - market: Number(item.market_probability ?? 0) * 100, - highlighted: Boolean(item.highlighted), - })); - } - const buckets = Array.isArray(marketScan?.top_buckets) - ? marketScan?.top_buckets - : Array.isArray(marketScan?.all_buckets) - ? marketScan?.all_buckets?.slice(0, 6) - : []; - if (buckets.length) { - return buckets - .slice(0, 6) - .map((bucket) => ({ - label: String(bucket.temp ?? bucket.value ?? bucket.label ?? "--"), - model: Number(bucket.probability ?? 0) * 100, - market: Number(bucket.market_price ?? bucket.yes_buy ?? 0) * 100, - highlighted: false, - })) - .filter((bucket) => bucket.label !== "--"); - } - - if (!row) return []; - return [ - { - label: row.target_label || "--", - model: Number(row.model_event_probability || 0) * 100, - market: Number(row.market_event_probability || 0) * 100, - highlighted: true, - }, - ]; -} - -function hashClientText(value: string) { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) | 0; - } - return Math.abs(hash).toString(16).padStart(8, "0").slice(0, 8); -} - -function normalizeAssistantProbability(value?: number | null) { - if (!Number.isFinite(Number(value))) return null; - const numeric = Number(value); - return Math.abs(numeric) <= 1 ? numeric : numeric / 100; -} - -function getAssistantSidePrice(row: ScanOpportunityRow, side: "yes" | "no") { - if (side === "yes") { - return row.yes_ask ?? (String(row.side || "").toLowerCase() === "yes" ? row.ask : null); - } - return row.no_ask ?? (String(row.side || "").toLowerCase() === "no" ? row.ask : null); -} - -function buildAssistantOpportunity( - row: ScanOpportunityRow, - locale: string, -): AssistantOpportunityContext { - const tempSymbol = row.temp_symbol || "°C"; - const marketLabel = - normalizeTemperatureLabel(row.target_label, tempSymbol) || - row.market_question || - row.target_label || - null; - const bestSide = String(row.side || "").toUpperCase(); - - return { - city_name: row.city, - city_display_name: getLocalizedCityName( - row.city, - row.city_display_name || row.display_name || row.city, - locale, - ), - airport: getLocalizedAirportName(row.city, row.airport || "", locale), - risk_level: row.risk_level || "low", - tradable: - row.tradable === true && - row.closed !== true && - row.active !== false && - row.accepting_orders !== false, - local_time: row.local_time || null, - current_temperature: Number.isFinite(Number(row.current_temp)) - ? Number(row.current_temp) - : null, - deb_prediction: Number.isFinite(Number(row.deb_prediction)) - ? Number(row.deb_prediction) - : null, - temp_symbol: tempSymbol, - today_high: Number.isFinite(Number(row.current_max_so_far)) - ? Number(row.current_max_so_far) - : Number.isFinite(Number(row.deb_prediction)) - ? Number(row.deb_prediction) - : null, - market_question: row.market_question || null, - market_label: marketLabel, - selected_date: row.selected_date || row.local_date || null, - best_side: bestSide === "YES" || bestSide === "NO" ? bestSide : null, - yes_price: getAssistantSidePrice(row, "yes"), - no_price: getAssistantSidePrice(row, "no"), - edge_percent: Number.isFinite(Number(row.edge_percent)) - ? Number(row.edge_percent) - : null, - market_probability: normalizeAssistantProbability( - row.market_event_probability ?? row.market_probability, - ), - model_probability: normalizeAssistantProbability( - row.model_event_probability ?? row.model_probability, - ), - status: row.closed ? "closed" : row.tradable ? "tradable" : "watch", - }; -} - -function buildAssistantContext(params: { - terminalData: ScanTerminalResponse | null; - rows: ScanOpportunityRow[]; - selectedRow: ScanOpportunityRow | null; - locale: string; - totalCities: number; -}): AssistantContextPayload { - const opportunities = params.rows - .slice(0, 52) - .map((row) => buildAssistantOpportunity(row, params.locale)); - const selectedCity = params.selectedRow - ? buildAssistantOpportunity(params.selectedRow, params.locale) - : opportunities[0] || null; - const source = JSON.stringify({ - generated_at: params.terminalData?.generated_at || "", - rows: params.rows.slice(0, 20).map((row) => [ - row.id, - row.city, - row.market_slug, - row.side, - row.edge_percent, - row.yes_ask, - row.no_ask, - ]), - }); - - return { - snapshot_id: - params.terminalData?.snapshot_id || - `home-${hashClientText(source)}`, - locale: params.locale, - generated_at: params.terminalData?.generated_at || new Date().toISOString(), - totals: { - cities: params.totalCities || params.rows.length, - tradable_markets: - params.terminalData?.summary?.tradable_market_count ?? - opportunities.filter((item) => item.tradable).length, - high_risk: params.rows.filter((row) => row.risk_level === "high").length, - medium_risk: params.rows.filter((row) => row.risk_level === "medium").length, - low_risk: params.rows.filter((row) => row.risk_level === "low").length, - }, - selected_city: selectedCity, - opportunities, - glossary: - params.locale === "en-US" - ? [ - { - term: "edge", - meaning: - "Edge is model probability minus market-implied probability. Positive edge means the model is more favorable than the market price.", - }, - { - term: "EMOS", - meaning: - "EMOS is the calibrated probability distribution for max-temperature buckets.", - }, - { - term: "DEB", - meaning: - "DEB is PolyWeather's forecast anchor for the 24-hour maximum temperature.", - }, - ] - : [ - { - term: "edge", - meaning: - "edge 是模型概率减去市场隐含概率。正 edge 表示模型判断比市场价格更有利。", - }, - { - term: "EMOS", - meaning: "EMOS 是最高温分桶的校准概率分布。", - }, - { - term: "DEB", - meaning: "DEB 是 PolyWeather 对 24 小时最高温的预测锚点。", - }, - ], - }; -} - -function ScanOpportunityDetailPanel({ - row, - marketScan, - loading, -}: { - row: ScanOpportunityRow | null; - marketScan?: MarketScan | null; - loading?: boolean; -}) { - const store = useDashboardStore(); - const { locale } = useI18n(); - const isEn = locale === "en-US"; - const isPro = store.proAccess.subscriptionActive; - - if (!row) { - return ( - - ); - } - - const localizedCityName = getLocalizedCityName( - row.city, - row.city_display_name || row.display_name || row.city, - locale, - ); - const localizedAirport = getLocalizedAirportName(row.city, row.airport || "", locale); - const detailSignal = marketScan?.primary_signal as PrimarySignal | null | undefined; - const displayRow = - detailSignal && detailSignal.market_slug === row.market_slug - ? detailSignal - : row; - const tempSymbol = row.temp_symbol || displayRow.temp_symbol || "°C"; - const yesRow = getSideRow(marketScan, row, "yes"); - const noRow = getSideRow(marketScan, row, "no"); - const comparisonBuckets = buildComparisonBuckets(marketScan, row); - const maxBar = Math.max( - 1, - ...comparisonBuckets.flatMap((bucket) => [bucket.model, bucket.market]), - ); - const scoreClass = scoreTone(displayRow.final_score); - const phaseMeta = getWindowPhaseMeta(displayRow, locale); - const isCitySnapshot = - String(row.window_phase || "").toLowerCase() === "city_snapshot" || - !row.market_slug; - const cityDetail = - store.selectedDetail?.name?.toLowerCase() === row.city.toLowerCase() - ? store.selectedDetail - : store.cityDetailsByName[row.city] || null; - - const openTodayAnalysis = async () => { - if (!row.city) return; - await store.selectCity(row.city); - await store.openTodayModal(); - }; - - return ( - - ); -} - function CalendarView({ rows, locale, @@ -854,364 +242,6 @@ function OverviewMapView({ locale }: { locale: string }) { ); } -function AssistantWidget({ - terminalData, - rows, - selectedRow, - locale, - totalCities, -}: { - terminalData: ScanTerminalResponse | null; - rows: ScanOpportunityRow[]; - selectedRow: ScanOpportunityRow | null; - locale: string; - totalCities: number; -}) { - const isEn = locale === "en-US"; - const [open, setOpen] = useState(false); - const [question, setQuestion] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [messages, setMessages] = useState(() => [ - { - id: "initial", - role: "assistant", - content: isEn - ? "Ask about current opportunities, edge ranking, one city, or the forecast high. I only use the current scan data." - : "可以问当前机会、edge 排序、某个城市是否值得参与,或今天预测最高温。我只使用当前扫描数据。", - }, - ]); - const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - const dragStateRef = useRef<{ - pointerId: number; - startX: number; - startY: number; - originX: number; - originY: number; - moved: boolean; - } | null>(null); - const suppressClickRef = useRef(false); - const [dragging, setDragging] = useState(false); - - const context = useMemo( - () => - buildAssistantContext({ - terminalData, - rows, - selectedRow, - locale, - totalCities, - }), - [locale, rows, selectedRow, terminalData, totalCities], - ); - - useEffect(() => { - setMessages((current) => - current[0]?.id === "initial" - ? [ - { - id: "initial", - role: "assistant", - content: isEn - ? "Ask about current opportunities, edge ranking, one city, or the forecast high. I only use the current scan data." - : "可以问当前机会、edge 排序、某个城市是否值得参与,或今天预测最高温。我只使用当前扫描数据。", - }, - ...current.slice(1), - ] - : current, - ); - }, [isEn]); - - useEffect(() => { - const stored = window.localStorage.getItem("polyweather_ai_position_v1"); - if (stored) { - try { - const parsed = JSON.parse(stored) as { x?: number; y?: number }; - if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) { - setPosition({ - x: Math.min(Math.max(Number(parsed.x), 12), window.innerWidth - 72), - y: Math.min(Math.max(Number(parsed.y), 76), window.innerHeight - 72), - }); - return; - } - } catch { - // Ignore invalid localStorage state. - } - } - setPosition({ - x: Math.max(16, window.innerWidth - 88), - y: Math.max(88, window.innerHeight - 96), - }); - }, []); - - useEffect(() => { - if (!position) return; - window.localStorage.setItem( - "polyweather_ai_position_v1", - JSON.stringify(position), - ); - }, [position]); - - const clampPosition = useCallback((nextX: number, nextY: number, isPanel: boolean) => { - const width = isPanel ? Math.min(380, window.innerWidth - 32) : 56; - const height = isPanel ? Math.min(520, window.innerHeight - 96) : 56; - return { - x: Math.min(Math.max(nextX, 12), Math.max(12, window.innerWidth - width - 12)), - y: Math.min(Math.max(nextY, 76), Math.max(76, window.innerHeight - height - 12)), - }; - }, []); - - const startDrag = useCallback( - (event: React.PointerEvent) => { - if (!position) return; - dragStateRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: position.x, - originY: position.y, - moved: false, - }; - setDragging(true); - event.currentTarget.setPointerCapture?.(event.pointerId); - }, - [position], - ); - - const updateDrag = useCallback( - (event: React.PointerEvent) => { - const dragState = dragStateRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) return; - const deltaX = event.clientX - dragState.startX; - const deltaY = event.clientY - dragState.startY; - if (Math.abs(deltaX) + Math.abs(deltaY) > 4) { - dragState.moved = true; - } - setPosition( - clampPosition(dragState.originX + deltaX, dragState.originY + deltaY, open), - ); - }, - [clampPosition, open], - ); - - const endDrag = useCallback((event: React.PointerEvent) => { - const dragState = dragStateRef.current; - if (dragState?.pointerId === event.pointerId) { - event.currentTarget.releasePointerCapture?.(event.pointerId); - setDragging(false); - if (dragState.moved) { - suppressClickRef.current = true; - window.setTimeout(() => { - suppressClickRef.current = false; - }, 160); - } - window.setTimeout(() => { - dragStateRef.current = null; - }, 0); - } - }, []); - - const starterQuestions = useMemo(() => { - const cityName = - context.selected_city?.city_display_name || - (isEn ? "the focus city" : "当前焦点城市"); - return isEn - ? [ - "Which market is worth buying now?", - "Rank opportunities by edge", - `What is today's forecast high for ${cityName}?`, - ] - : [ - "当前有哪些值得参与的市场?", - "按 edge 排序", - `${cityName} 今天预测最高温是多少?`, - ]; - }, [context.selected_city?.city_display_name, isEn]); - - const submitQuestion = useCallback( - async (rawQuestion?: string) => { - const nextQuestion = String(rawQuestion ?? question).trim(); - if (!nextQuestion || submitting) return; - const userMessage: AssistantMessage = { - id: `user-${Date.now()}`, - role: "user", - content: nextQuestion, - }; - setMessages((current) => [...current, userMessage]); - setQuestion(""); - setError(null); - setSubmitting(true); - try { - const response = await dashboardClient.askAssistant({ - question: nextQuestion, - locale, - snapshotId: context.snapshot_id, - context, - }); - setMessages((current) => [ - ...current, - { - id: `assistant-${Date.now()}`, - role: "assistant", - content: response.answer, - }, - ]); - } catch (submitError) { - const message = String(submitError); - const paywall = - message.includes("402") || message.includes("assistant_requires_pro"); - setError( - paywall - ? isEn - ? "PolyWeather AI assistant is a Pro feature." - : "AI 对话助手属于 Pro 功能,请先开通后使用。" - : isEn - ? "Assistant request failed. Try again after the market scan refreshes." - : "AI 请求失败。请等市场扫描刷新后再试。", - ); - } finally { - setSubmitting(false); - } - }, - [context, isEn, locale, question, submitting], - ); - - const handleLauncherClick = useCallback(() => { - if (suppressClickRef.current || dragStateRef.current?.moved) return; - setOpen(true); - setPosition((current) => { - const next = current || { - x: window.innerWidth - 420, - y: window.innerHeight - 520, - }; - return clampPosition(next.x, next.y, true); - }); - }, [clampPosition]); - - if (!position) return null; - - return ( -
- {!open ? ( - - ) : ( -
-
-
- {isEn ? "AI Assistant" : "AI 对话助手"} - - {isEn ? "Current market snapshot only" : "仅基于当前市场快照"} - -
-
- - -
-
- -
- {isEn - ? "Uses only scan data already provided by PolyWeather; no invented prices, probabilities, or cities." - : "只使用 PolyWeather 当前扫描数据,不编造价格、概率或城市。"} -
- -
- {messages.map((message) => ( -
-

{message.content}

-
- ))} - {submitting ? ( -
-

{isEn ? "Reading the current scan..." : "正在读取当前扫描数据..."}

-
- ) : null} -
- -
- {starterQuestions.map((starter) => ( - - ))} -
- -
-