From 89497bdf697a2cc10ed6cf662f0913dfb91ebee6 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 03:23:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9E=84=E5=BB=BA=E5=86=B2?= =?UTF-8?q?=E7=AA=81=EF=BC=9A=E7=A7=BB=E9=99=A4=E6=9C=AC=E5=9C=B0=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=20Panel=20=E5=AE=9A=E4=B9=89=EF=BC=8C=E6=B8=85?= =?UTF-8?q?=E7=90=86=20.next=20=E7=BC=93=E5=AD=98=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=BB=88=E7=AB=AF=E5=AD=90=E7=BB=84=E4=BB=B6=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/ScanTerminalDashboard.tsx | 800 +++++------------- .../scan-terminal/GroupedMarketTable.tsx | 156 ++++ .../dashboard/scan-terminal/Panel.tsx | 40 + .../scan-terminal/ProductAccessRequired.tsx | 276 ++++++ .../scan-terminal/RunwayMeteorologyPanel.tsx | 209 +++++ .../dashboard/scan-terminal/utils.ts | 29 + 6 files changed, 921 insertions(+), 589 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx create mode 100644 frontend/components/dashboard/scan-terminal/Panel.tsx create mode 100644 frontend/components/dashboard/scan-terminal/ProductAccessRequired.tsx create mode 100644 frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx create mode 100644 frontend/components/dashboard/scan-terminal/utils.ts diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index fa6dee83..12d5e76f 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -20,13 +20,16 @@ import { Table2, UserRound, } from "lucide-react"; -import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Area, AreaChart, Bar, BarChart as ReBarChart, CartesianGrid, + Line, + LineChart as ReLineChart, + ReferenceLine, ResponsiveContainer, Tooltip, XAxis, @@ -36,8 +39,7 @@ import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils"; -import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay"; -import { useTerminalPay } from "@/components/subscription/useTerminalPay"; +import { ProductAccessRequired } from "@/components/dashboard/scan-terminal/ProductAccessRequired"; import { type ContinentGroup, buildContinentGroups, @@ -59,6 +61,10 @@ import { import { ScanTerminalLoadingScreen } from "@/components/dashboard/scan-terminal/ScanTerminalShellParts"; import { scanRootClass } from "@/components/dashboard/scan-root-styles"; import { useRelativeTime } from "@/hooks/useRelativeTime"; +import { Panel } from "@/components/dashboard/scan-terminal/Panel"; +import { GroupedMarketTable } from "@/components/dashboard/scan-terminal/GroupedMarketTable"; +import { RunwayMeteorologyPanel } from "@/components/dashboard/scan-terminal/RunwayMeteorologyPanel"; +import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils"; function createEmptyAccess(loading = true): ProAccessState { return { @@ -90,33 +96,7 @@ function createLocalAccess(): ProAccessState { }; } -function rowName(row?: ScanOpportunityRow | null) { - return row?.city_display_name || row?.display_name || row?.city || "--"; -} -function pct(value: unknown, digits = 1) { - const n = Number(value); - if (!Number.isFinite(n)) return "--"; - return `${(Math.abs(n) <= 1 ? n * 100 : n).toFixed(digits)}%`; -} - -function money(value: unknown) { - const n = Number(value); - if (!Number.isFinite(n)) return "--"; - return `$${Math.round(n).toLocaleString()}`; -} - -function temp(value: unknown, unit?: string | null) { - const n = Number(value); - if (!Number.isFinite(n)) return "--"; - return `${n.toFixed(1)}${unit || "°"}`; -} - -function edgeClass(value: unknown) { - const n = Number(value); - if (!Number.isFinite(n) || n === 0) return "text-slate-500"; - return n > 0 ? "text-emerald-700" : "text-red-600"; -} const TERM = { cityContract: { en: "City / Contract", zh: "城市 / 合约" }, @@ -209,6 +189,21 @@ function decisionLabel(row?: ScanOpportunityRow | null) { return "Monitor"; } +function decisionClass(label: string) { + const l = label.toLowerCase(); + if (l.includes("approve") || l.includes("active") || l.includes("活跃")) { + return "bg-emerald-50 border-emerald-200 text-emerald-700"; + } + if (l.includes("watch") || l.includes("观察") || l.includes("monitor")) { + return "bg-amber-50 border-amber-200 text-amber-700"; + } + if (l.includes("veto") || l.includes("downgrade") || l.includes("closed") || l.includes("关闭")) { + return "bg-slate-50 border-slate-200 text-slate-500"; + } + return "bg-blue-50 border-blue-200 text-blue-700"; +} + + function SparkArea({ color = "#2563eb", data, @@ -301,171 +296,6 @@ function ProbabilityDistributionChart({ ); } -function Panel({ - children, - className, - title, -}: { - children: React.ReactNode; - className?: string; - title: string; -}) { - return ( -
-
-

- {title} -

- - ↗ - -
- {children} -
- ); -} - -function GroupedMarketTable({ - groups, - isEn, - onSelect, - selectedId, -}: { - groups: ContinentGroup[]; - isEn: boolean; - onSelect: (row: ScanOpportunityRow) => void; - selectedId?: string | null; -}) { - const [collapsed, setCollapsed] = useState>(() => { - const c = new Set(); - const defaultExpanded = getDefaultExpanded(groups); - for (const g of groups) { - if (!defaultExpanded.has(g.key)) c.add(g.key); - } - return c; - }); - - const toggleGroup = (key: string) => { - setCollapsed((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - }; - - return ( -
- - - - - - - - - - - - - - - - {groups.map((group) => { - const isExpanded = !collapsed.has(group.key); - const label = isEn ? group.labelEn : group.labelZh; - return ( - - {/* Group header row */} - - - - {/* Data rows */} - {isExpanded && - group.rows.map((row) => { - const signal = getSignalState(row); - const gapColor = GAP_COLOR_MAP[getGapColor(row)]; - return ( - onSelect(row)} - > - - - - - - - - - - - ); - })} - - ); - })} - -
CityObsHighDEBGapMarketEdgeSpr/LiqSignal
- -
-
{rowName(row)}
-
- {row.airport || ""}{row.local_time ? ` · ${row.local_time}` : ""} -
-
- {temp(row.current_temp, row.temp_symbol)} - - {temp(row.current_max_so_far, row.temp_symbol)} - - {temp(row.deb_prediction, row.temp_symbol)} - - {temp(row.signed_gap ?? row.gap_to_target, row.temp_symbol)} - - {formatPrice(row.midpoint, row.ask, row.bid)} - - {pct(row.edge_percent)} - - {formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)} - - - {getSignalLabel(signal, isEn)} - -
-
- ); -} - function PolyWeatherTerminal({ generatedText, isEn, @@ -477,6 +307,9 @@ function PolyWeatherTerminal({ setSelectedRow, toggleLocale, userLocalTime, + searchQuery, + setSearchQuery, + searchInputRef, }: { generatedText: string; isEn: boolean; @@ -488,7 +321,31 @@ function PolyWeatherTerminal({ setSelectedRow: (row: ScanOpportunityRow) => void; toggleLocale: () => void; userLocalTime: string; + searchQuery: string; + setSearchQuery: (val: string) => void; + searchInputRef: React.RefObject; }) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const activeEl = document.activeElement; + const isInputFocused = + activeEl && + (activeEl.tagName === "INPUT" || + activeEl.tagName === "TEXTAREA" || + (activeEl instanceof HTMLElement && activeEl.isContentEditable)); + if (e.key === "/" && !isInputFocused) { + e.preventDefault(); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } + if (e.key === "Escape" && activeEl === searchInputRef.current) { + setSearchQuery(""); + searchInputRef.current?.blur(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [searchInputRef, setSearchQuery]); const approveRows = rows .filter((row) => ["Approve", "Tradable"].includes(decisionLabel(row))) .slice(0, 8); @@ -522,61 +379,78 @@ function PolyWeatherTerminal({ return (
-