diff --git a/frontend/app/probabilities/page.tsx b/frontend/app/probabilities/page.tsx new file mode 100644 index 00000000..a972ce87 --- /dev/null +++ b/frontend/app/probabilities/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { ProbabilityHubPage } from "@/components/probability-hub/ProbabilityHubPage"; + +export const metadata: Metadata = { + title: "PolyWeather - 概率判断总览", + description: "集中查看 52 个城市的 EMOS 概率判断与市场合约桶对比。", +}; + +export default function ProbabilitiesPage() { + return ; +} diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 704a0246..3a8ede1c 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -3373,6 +3373,12 @@ transform: translateY(-1px); } +.root :global(.info-btn.active) { + background: rgba(34, 211, 238, 0.14); + border-color: rgba(34, 211, 238, 0.42); + color: #e0fbff; +} + /* ── Guide Modal Customizations ── */ .root :global(.modal-content.large) { max-width: 900px; diff --git a/frontend/components/dashboard/HeaderBar.tsx b/frontend/components/dashboard/HeaderBar.tsx index 60e34c8d..9464ffca 100644 --- a/frontend/components/dashboard/HeaderBar.tsx +++ b/frontend/components/dashboard/HeaderBar.tsx @@ -3,7 +3,14 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import clsx from "clsx"; -import { LogIn, UserRound, RotateCw, BookOpen, Sparkles } from "lucide-react"; +import { + LogIn, + UserRound, + RotateCw, + BookOpen, + Sparkles, + BarChart3, +} from "lucide-react"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; @@ -21,17 +28,32 @@ function parseExpiryInfo(raw?: string | null) { }; } -export function HeaderBar() { +export function HeaderBar({ + refreshAction, + refreshSpinning, +}: { + refreshAction?: () => void | Promise; + refreshSpinning?: boolean; +}) { const store = useDashboardStore(); const { locale, setLocale, t } = useI18n(); const pathname = usePathname(); const isAuthenticated = store.proAccess.authenticated; const docsHref = "/docs/intro"; const docsActive = pathname?.startsWith("/docs"); + const probabilityHubHref = "/probabilities"; + const probabilityHubActive = pathname?.startsWith("/probabilities"); const trialPromoLabel = locale === "en-US" ? "New users get 3-day Pro trial" : "新用户可免费体验 3 天 Pro"; + const isRefreshing = refreshSpinning ?? store.loadingState.refresh; + const handleRefresh = () => { + if (refreshAction) { + return void refreshAction(); + } + return void store.refreshAll(); + }; const accountHref = isAuthenticated ? "/account" @@ -119,6 +141,16 @@ export function HeaderBar() { {t("header.docs")} + + + {t("header.probabilityHub")} + + void store.refreshAll()} + onClick={handleRefresh} > diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index baa7911c..371ac306 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -1164,12 +1164,12 @@ export function ProbabilityDistribution({ const probability = Math.round(Number(row.probability || 0) * 100); const rowMarketBucket = row.marketBucket; const rowMarketPrice = - rowMarketBucket?.market_price ?? rowMarketBucket?.yes_buy ?? null; + rowMarketBucket?.yes_buy ?? rowMarketBucket?.market_price ?? null; const yesPriceText = toPriceCents(rowMarketPrice); const marketTagFinal = rowMarketBucket ? locale === "en-US" - ? `Market ref: ${yesPriceText || "--"}` - : `市场参考: ${yesPriceText || "--"}` + ? `YES ask: ${yesPriceText || "--"}` + : `YES 买价: ${yesPriceText || "--"}` : null; return ( diff --git a/frontend/components/probability-hub/ProbabilityHubPage.module.css b/frontend/components/probability-hub/ProbabilityHubPage.module.css new file mode 100644 index 00000000..42a7e8cd --- /dev/null +++ b/frontend/components/probability-hub/ProbabilityHubPage.module.css @@ -0,0 +1,190 @@ +.pageRoot { + min-height: 100vh; + width: 100%; + overflow-y: auto; + overflow-x: hidden; +} + +.pageBody { + position: relative; + z-index: 1; + padding: 72px 20px 24px; +} + +.hero { + display: grid; + gap: 12px; + margin-bottom: 18px; +} + +.heroCard { + border: 1px solid var(--border-subtle); + border-radius: 14px; + padding: 16px 18px; + background: rgba(255, 255, 255, 0.03); +} + +.heroTitle { + font-size: 22px; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.heroText { + margin-top: 8px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.7; +} + +.heroMeta { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} + +.heroPill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(34, 211, 238, 0.18); + background: rgba(8, 47, 73, 0.24); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.3; +} + +.heroPill strong { + color: var(--text-primary); +} + +.errorCard { + border: 1px solid rgba(239, 68, 68, 0.24); + border-radius: 12px; + padding: 12px 14px; + background: rgba(127, 29, 29, 0.16); + color: #fecaca; + font-size: 13px; + line-height: 1.6; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.card { + display: grid; + gap: 12px; + border: 1px solid var(--border-subtle); + border-radius: 16px; + padding: 14px; + background: rgba(255, 255, 255, 0.02); +} + +.cardHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.cardTitleBlock { + display: grid; + gap: 4px; +} + +.cardTitle { + font-size: 16px; + font-weight: 800; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.cardSubTitle { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + +.cardMeta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.metaChip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 26px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(255, 255, 255, 0.03); + color: var(--text-muted); + font-size: 11px; +} + +.metaChip strong { + color: var(--text-secondary); +} + +.loadingGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.loadingCard { + height: 280px; + border-radius: 16px; + border: 1px solid var(--border-subtle); + background: + linear-gradient( + 90deg, + rgba(255, 255, 255, 0.02) 0%, + rgba(255, 255, 255, 0.05) 50%, + rgba(255, 255, 255, 0.02) 100% + ); + background-size: 200% 100%; + animation: probabilityHubShimmer 1.6s linear infinite; +} + +@keyframes probabilityHubShimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@media (max-width: 1200px) { + .grid, + .loadingGrid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .pageBody { + padding: 68px 12px 18px; + } + + .heroCard, + .card { + border-radius: 12px; + padding: 12px; + } + + .heroTitle { + font-size: 18px; + } +} diff --git a/frontend/components/probability-hub/ProbabilityHubPage.tsx b/frontend/components/probability-hub/ProbabilityHubPage.tsx new file mode 100644 index 00000000..fb89540a --- /dev/null +++ b/frontend/components/probability-hub/ProbabilityHubPage.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import clsx from "clsx"; +import dashboardStyles from "@/components/dashboard/Dashboard.module.css"; +import { HeaderBar } from "@/components/dashboard/HeaderBar"; +import { ProbabilityDistribution } from "@/components/dashboard/PanelSections"; +import { dashboardClient } from "@/lib/dashboard-client"; +import type { CityDetail, CityListItem } from "@/lib/dashboard-types"; +import { I18nProvider, useI18n } from "@/hooks/useI18n"; +import { DashboardStoreProvider } from "@/hooks/useDashboardStore"; +import styles from "./ProbabilityHubPage.module.css"; + +const DETAIL_BATCH_SIZE = 6; + +function sortCities(cities: CityListItem[]) { + const riskOrder: Record = { + high: 0, + medium: 1, + low: 2, + }; + + return [...cities].sort((a, b) => { + const riskDelta = + (riskOrder[String(a.risk_level || "").toLowerCase()] ?? 9) - + (riskOrder[String(b.risk_level || "").toLowerCase()] ?? 9); + if (riskDelta !== 0) return riskDelta; + return String(a.display_name || a.name).localeCompare( + String(b.display_name || b.name), + ); + }); +} + +function ProbabilityHubScreen() { + const { locale } = useI18n(); + const [cities, setCities] = useState([]); + const [detailsByName, setDetailsByName] = useState>({}); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + const loadAll = useCallback(async (force = false) => { + setError(null); + setRefreshing(true); + if (!cities.length || force) { + setLoading(true); + } + + try { + const cityList = sortCities(await dashboardClient.getCities()); + setCities(cityList); + + const nextDetails: Record = {}; + for (let index = 0; index < cityList.length; index += DETAIL_BATCH_SIZE) { + const batch = cityList.slice(index, index + DETAIL_BATCH_SIZE); + const results = await Promise.allSettled( + batch.map((city) => + dashboardClient.getCityDetail(city.name, { + depth: "market", + force, + }), + ), + ); + + results.forEach((result, batchIndex) => { + if (result.status !== "fulfilled") return; + nextDetails[batch[batchIndex].name] = result.value; + }); + + setDetailsByName((previous) => ({ + ...previous, + ...nextDetails, + })); + } + + setLastUpdatedAt(new Date().toISOString()); + } catch (loadError) { + setError( + loadError instanceof Error + ? loadError.message + : locale === "en-US" + ? "Failed to load probability hub" + : "加载概率页失败", + ); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [cities.length, locale]); + + useEffect(() => { + void loadAll(false); + }, [loadAll]); + + const loadedCount = Object.keys(detailsByName).length; + const cityCount = cities.length; + const readyCards = useMemo( + () => cities.filter((city) => detailsByName[city.name]).length, + [cities, detailsByName], + ); + + return ( +
+ loadAll(true)} + refreshSpinning={refreshing} + /> +
+
+
+
+ {locale === "en-US" + ? "52-city probability hub" + : "52 城市概率判断总览"} +
+
+ {locale === "en-US" + ? "This page centralizes the intraday probability block for all monitored cities. The goal is fast scanning: see calibrated EMOS probabilities, market bucket alignment, and price comparison without opening each city modal one by one." + : "这里把 52 个监控城市的概率判断板块集中到一个页面,方便直接横向扫一遍,不用逐个打开城市弹窗。重点看 EMOS 校准概率、市场合约桶聚合,以及价格对比。"} +
+
+ + {locale === "en-US" ? "Cities" : "城市数"} {cityCount || "--"} + + + {locale === "en-US" ? "Ready" : "已加载"} {readyCards} + + + {locale === "en-US" ? "Updated" : "更新时间"}{" "} + + {lastUpdatedAt + ? new Date(lastUpdatedAt).toLocaleTimeString( + locale === "en-US" ? "en-US" : "zh-CN", + { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }, + ) + : "--"} + + +
+
+ + {error ?
{error}
: null} +
+ + {loading && loadedCount === 0 ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ) : ( +
+ {cities.map((city) => { + const detail = detailsByName[city.name]; + if (!detail) { + return ( +
+
+
+
{city.display_name}
+
+ {city.airport} ({city.icao}) +
+
+
+
+ {locale === "en-US" + ? "Probability block is syncing..." + : "概率板块同步中..."} +
+
+ ); + } + + return ( +
+
+
+
{detail.display_name}
+
+ {detail.risk?.airport || city.airport} ({detail.risk?.icao || city.icao}) +
+
+
+
+ + {locale === "en-US" ? "Current" : "当前"}{" "} + + {detail.current?.temp != null + ? `${detail.current.temp}${detail.temp_symbol}` + : "--"} + + + + {locale === "en-US" ? "Obs" : "观测"}{" "} + {detail.current?.obs_time || "--"} + + + {locale === "en-US" ? "Updated" : "更新"}{" "} + + {detail.updated_at + ? new Date(detail.updated_at).toLocaleTimeString( + locale === "en-US" ? "en-US" : "zh-CN", + { + hour: "2-digit", + minute: "2-digit", + }, + ) + : "--"} + + +
+ +
+ ); + })} +
+ )} +
+
+ ); +} + +export function ProbabilityHubPage() { + return ( + + + + + + ); +} diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts index 5714c941..977219a9 100644 --- a/frontend/lib/i18n.ts +++ b/frontend/lib/i18n.ts @@ -10,6 +10,8 @@ const MESSAGES: Record> = { "header.subtitle": "天气衍生品智能分析", "header.docs": "文档", "header.docsAria": "打开产品文档中心", + "header.probabilityHub": "概率页", + "header.probabilityHubAria": "打开 52 城市概率汇总页", "header.info": "技术说明", "header.infoAria": "查看系统技术说明", "header.account": "账户", @@ -173,6 +175,8 @@ const MESSAGES: Record> = { "header.subtitle": "Weather Derivatives Intelligence", "header.docs": "Docs", "header.docsAria": "Open product documentation", + "header.probabilityHub": "Probabilities", + "header.probabilityHubAria": "Open the 52-city probability hub", "header.info": "Tech Notes", "header.infoAria": "Open system technical notes", "header.account": "Account",