From 025feaa96ec779777c022e869c9331f61bbd3493 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 11 Mar 2026 11:37:07 +0800 Subject: [PATCH] feat: Implement PolyWeather web map API backend and integrate new frontend dashboard components for city weather details. --- docs/API_ZH.md | 28 +++++------ frontend/app/api/city/[name]/detail/route.ts | 4 ++ .../dashboard/FutureForecastModal.tsx | 10 +++- .../components/dashboard/PanelSections.tsx | 46 ++++++++++++------ frontend/hooks/useDashboardStore.tsx | 48 ++++++++++++++++--- frontend/lib/dashboard-client.ts | 17 ++++++- web/app.py | 31 ++++++++++-- 7 files changed, 142 insertions(+), 42 deletions(-) diff --git a/docs/API_ZH.md b/docs/API_ZH.md index b1409ba1..2c9aef0d 100644 --- a/docs/API_ZH.md +++ b/docs/API_ZH.md @@ -22,20 +22,20 @@ ```mermaid mindmap root((PolyWeather API)) - 城市列表 - [GET /api/cities] - 城市主数据 - [GET /api/city/:name] - [GET /api/city/:name/summary] - [GET /api/city/:name/detail] - 历史数据 - [GET /api/history/:name] - 关键对象 - current - forecast - [probabilities (mu + distribution)] - [multi_model / multi_model_daily] - [market_scan (P0 只读)] + "城市列表" + "GET /api/cities" + "城市主数据" + "GET /api/city/{name}" + "GET /api/city/{name}/summary" + "GET /api/city/{name}/detail" + "历史数据" + "GET /api/history/{name}" + "关键对象" + "current" + "forecast" + "probabilities (mu + distribution)" + "multi_model / multi_model_daily" + "market_scan (P0 只读)" ``` --- diff --git a/frontend/app/api/city/[name]/detail/route.ts b/frontend/app/api/city/[name]/detail/route.ts index e237653c..a498689e 100644 --- a/frontend/app/api/city/[name]/detail/route.ts +++ b/frontend/app/api/city/[name]/detail/route.ts @@ -17,12 +17,16 @@ export async function GET( const { name } = await context.params; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const marketSlug = req.nextUrl.searchParams.get("market_slug"); + const targetDate = req.nextUrl.searchParams.get("target_date"); const searchParams = new URLSearchParams({ force_refresh: forceRefresh, }); if (marketSlug) { searchParams.set("market_slug", marketSlug); } + if (targetDate) { + searchParams.set("target_date", targetDate); + } const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`; try { diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 249b6a34..007903e3 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -33,6 +33,7 @@ import { } from "@/lib/dashboard-utils"; function normalizeMarketValue(value?: number | null) { + if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100)); @@ -567,7 +568,13 @@ export function FutureForecastModal() { "future-refresh-btn", store.loadingState.marketScan && "spinning", )} - onClick={() => store.openTodayModal(true)} + onClick={() => { + if (isToday) { + void store.openTodayModal(true); + return; + } + store.openFutureModal(dateStr, true); + }} title={locale === "en-US" ? "Refresh Data" : "刷新数据"} >
diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 87d155cd..3db8c670 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -28,12 +28,14 @@ function EmptyState({ text }: { text: string }) { } function toPercent(value?: number | null) { + if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; return `${(numeric * 100).toFixed(1)}%`; } function toPriceCents(value?: number | null) { + if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; const normalized = numeric > 1 ? numeric / 100 : numeric; @@ -54,16 +56,20 @@ function parseTempFromText(value: unknown) { } function getBucketTemp(bucket: ProbabilityBucket) { - const byValue = Number(bucket.value); - if (Number.isFinite(byValue)) return byValue; + if (bucket.value != null) { + const byValue = Number(bucket.value); + if (Number.isFinite(byValue)) return byValue; + } return parseTempFromText(bucket.label || bucket.bucket || bucket.range); } function getMarketBucketTemp(scan?: MarketScan | null) { if (!scan) return null; - const byBucketValue = Number(scan.temperature_bucket?.value); - if (Number.isFinite(byBucketValue)) return byBucketValue; + if (scan.temperature_bucket?.value != null) { + const byBucketValue = Number(scan.temperature_bucket.value); + if (Number.isFinite(byBucketValue)) return byBucketValue; + } const byBucketLabel = parseTempFromText( scan.temperature_bucket?.label || @@ -83,21 +89,29 @@ function getMarketBucketTemp(scan?: MarketScan | null) { } function getMarketYesPrice(scan?: MarketScan | null) { - const preferred = Number(scan?.market_price); - if (Number.isFinite(preferred)) return preferred; - const implied = Number(scan?.yes_token?.implied_probability); - return Number.isFinite(implied) ? implied : null; + if (scan?.market_price != null) { + const preferred = Number(scan.market_price); + if (Number.isFinite(preferred)) return preferred; + } + if (scan?.yes_token?.implied_probability != null) { + const implied = Number(scan.yes_token.implied_probability); + if (Number.isFinite(implied)) return implied; + } + return null; } function getMarketNoPrice(scan?: MarketScan | null) { - const direct = Number(scan?.no_buy); - if (Number.isFinite(direct)) return direct; + if (scan?.no_buy != null) { + const direct = Number(scan.no_buy); + if (Number.isFinite(direct)) return direct; + } const marketYes = getMarketYesPrice(scan); if (marketYes != null) return Math.max(0, Math.min(1, 1 - marketYes)); return null; } function normalizeMarketProbability(value?: number | null) { + if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100)); @@ -120,11 +134,15 @@ function getMarketTopBuckets(scan?: MarketScan | null) { } function getMarketTopBucketKey(bucket: MarketTopBucket) { - const valueNum = Number(bucket?.value); - if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`; + if (bucket?.value != null) { + const valueNum = Number(bucket.value); + if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`; + } - const tempNum = Number(bucket?.temp); - if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`; + if (bucket?.temp != null) { + const tempNum = Number(bucket.temp); + if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`; + } const parsed = parseTempFromText(bucket?.label); if (parsed != null) return `l:${parsed.toFixed(2)}`; diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 8d13afba..18c0cc24 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -33,7 +33,7 @@ interface DashboardStoreValue extends DashboardState { futureModalDate: string | null; isGuideOpen: boolean; loadCities: () => Promise; - openFutureModal: (dateStr: string) => void; + openFutureModal: (dateStr: string, forceRefresh?: boolean) => void; openGuide: () => void; openHistory: () => Promise; openTodayModal: (forceRefresh?: boolean) => Promise; @@ -158,6 +158,11 @@ function mergeAiAnalysisIfStable( }; } +function getMarketScanCacheKey(cityName: string, targetDate?: string | null) { + const normalizedDate = String(targetDate || "").trim() || "local"; + return `${cityName}::${normalizedDate}`; +} + export function DashboardStoreProvider({ children, }: { @@ -210,8 +215,13 @@ export function DashboardStoreProvider({ const selectedDetail = selectedCity ? cityDetailsByName[selectedCity] || null : null; + const selectedMarketDate = + futureModalDate || selectedForecastDate || selectedDetail?.local_date || null; + const selectedMarketScanKey = selectedCity + ? getMarketScanCacheKey(selectedCity, selectedMarketDate) + : null; const selectedMarketScan = selectedCity - ? marketScanByCityName[selectedCity] || null + ? marketScanByCityName[selectedMarketScanKey || ""] || null : null; useEffect(() => { @@ -277,8 +287,10 @@ export function DashboardStoreProvider({ cityName: string, force = false, marketSlug?: string | null, + targetDate?: string | null, ) => { - const cached = marketScanByCityName[cityName]; + const cacheKey = getMarketScanCacheKey(cityName, targetDate); + const cached = marketScanByCityName[cacheKey]; if (!force && cached && !marketSlug) { return cached; } @@ -286,11 +298,12 @@ export function DashboardStoreProvider({ const latestScan = await dashboardClient.getCityMarketScan(cityName, { force, marketSlug, + targetDate, }); if (latestScan) { setMarketScanByCityName((current) => ({ ...current, - [cityName]: latestScan, + [cacheKey]: latestScan, })); } return latestScan; @@ -366,7 +379,9 @@ export function DashboardStoreProvider({ const detail = await ensureCityDetail(cityName); setSelectedForecastDate(detail.local_date); // 预热市场数据,不做 await 阻塞,后台静默拉取 - void ensureCityMarketScan(cityName, false).catch(() => {}); + void ensureCityMarketScan(cityName, false, null, detail.local_date).catch( + () => {}, + ); } finally { setLoadingState((current) => ({ ...current, cityDetail: false })); } @@ -464,9 +479,22 @@ export function DashboardStoreProvider({ isGuideOpen, loadCities, loadingState, - openFutureModal: (dateStr: string) => { + openFutureModal: (dateStr: string, forceRefresh = false) => { mapStopMotionRef.current(); setFutureModalDate(dateStr); + if (!selectedCity) return; + const cacheKey = getMarketScanCacheKey(selectedCity, dateStr); + setLoadingState((current) => ({ ...current, marketScan: true })); + void ensureCityMarketScan( + selectedCity, + forceRefresh || !marketScanByCityName[cacheKey], + null, + dateStr, + ) + .catch(() => {}) + .finally(() => { + setLoadingState((current) => ({ ...current, marketScan: false })); + }); }, openGuide: () => setIsGuideOpen(true), openHistory, @@ -499,9 +527,15 @@ export function DashboardStoreProvider({ try { // 如果缓存里没有或者想要强制刷新,则拉取最新市场数据 + const marketKey = getMarketScanCacheKey( + selectedCity, + detail.local_date, + ); await ensureCityMarketScan( selectedCity, - forceRefresh || !marketScanByCityName[selectedCity], + forceRefresh || !marketScanByCityName[marketKey], + null, + detail.local_date, ); } catch {} } catch { diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index c4ef86e9..2567c736 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -164,12 +164,19 @@ export const dashboardClient = { async getCityMarketScan( cityName: string, - options?: { force?: boolean; marketSlug?: string | null }, + options?: { + force?: boolean; + marketSlug?: string | null; + targetDate?: string | null; + }, ) { const force = options?.force ?? false; const marketSlug = options?.marketSlug || null; + const targetDate = options?.targetDate || null; if (!force) { - const requestKey = `${cityName}::cached::${marketSlug || "-"}`; + const requestKey = `${cityName}::cached::${marketSlug || "-"}::${ + targetDate || "-" + }`; const existing = pendingMarketScanRequests.get(requestKey); if (existing) { return existing; @@ -181,6 +188,9 @@ export const dashboardClient = { if (marketSlug) { params.set("market_slug", marketSlug); } + if (targetDate) { + params.set("target_date", targetDate); + } const request = fetchJson<{ market_scan?: MarketScan }>( `/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`, @@ -201,6 +211,9 @@ export const dashboardClient = { if (marketSlug) { params.set("market_slug", marketSlug); } + if (targetDate) { + params.set("target_date", targetDate); + } return fetchJson<{ market_scan?: MarketScan }>( `/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`, diff --git a/web/app.py b/web/app.py index 242a9dae..62d237da 100644 --- a/web/app.py +++ b/web/app.py @@ -782,10 +782,28 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: def _build_city_detail_payload( data: Dict[str, Any], market_slug: Optional[str] = None, + target_date: Optional[str] = None, ) -> Dict[str, Any]: - distribution = data.get("probabilities", {}).get("distribution", []) or [] + local_date = str(data.get("local_date") or "").strip() + requested_date = str(target_date or "").strip() + selected_date = requested_date or local_date + + multi_model_daily = data.get("multi_model_daily") or {} + selected_daily = ( + multi_model_daily.get(selected_date) + if isinstance(multi_model_daily, dict) + else None + ) + if not isinstance(selected_daily, dict): + selected_daily = {} + selected_date = local_date + + distribution = selected_daily.get("probabilities") + if not isinstance(distribution, list) or not distribution: + distribution = data.get("probabilities", {}).get("distribution", []) or [] + city_name = str(data.get("name") or "").strip().lower() - model_map = data.get("multi_model") or {} + model_map = selected_daily.get("models") or data.get("multi_model") or {} if not isinstance(model_map, dict): model_map = {} @@ -839,7 +857,7 @@ def _build_city_detail_payload( ] market_scan = _market_layer.build_market_scan( city=data.get("name"), - target_date=data.get("local_date"), + target_date=selected_date or data.get("local_date"), temperature_bucket=primary_bucket if isinstance(primary_bucket, dict) else None, model_probability=model_probability, fallback_sparkline=fallback_sparkline, @@ -945,11 +963,16 @@ async def city_detail_aggregate( name: str, force_refresh: bool = False, market_slug: Optional[str] = None, + target_date: Optional[str] = None, ): _assert_entitlement(request) city = _normalize_city_or_404(name) data = _analyze(city, force_refresh=force_refresh) - return _build_city_detail_payload(data, market_slug=market_slug) + return _build_city_detail_payload( + data, + market_slug=market_slug, + target_date=target_date, + ) # ────────────────────────────────────────────────────────── # Entrypoint