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