feat: Implement PolyWeather web map API backend and integrate new frontend dashboard components for city weather details.

This commit is contained in:
2569718930@qq.com
2026-03-11 11:37:07 +08:00
parent 23e2959404
commit 025feaa96e
7 changed files with 142 additions and 42 deletions
+14 -14
View File
@@ -22,20 +22,20 @@
```mermaid ```mermaid
mindmap mindmap
root((PolyWeather API)) root((PolyWeather API))
城市列表 "城市列表"
[GET /api/cities] "GET /api/cities"
城市主数据 "城市主数据"
[GET /api/city/:name] "GET /api/city/{name}"
[GET /api/city/:name/summary] "GET /api/city/{name}/summary"
[GET /api/city/:name/detail] "GET /api/city/{name}/detail"
历史数据 "历史数据"
[GET /api/history/:name] "GET /api/history/{name}"
关键对象 "关键对象"
current "current"
forecast "forecast"
[probabilities (mu + distribution)] "probabilities (mu + distribution)"
[multi_model / multi_model_daily] "multi_model / multi_model_daily"
[market_scan (P0 只读)] "market_scan (P0 只读)"
``` ```
--- ---
@@ -17,12 +17,16 @@ export async function GET(
const { name } = await context.params; const { name } = await context.params;
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
const marketSlug = req.nextUrl.searchParams.get("market_slug"); const marketSlug = req.nextUrl.searchParams.get("market_slug");
const targetDate = req.nextUrl.searchParams.get("target_date");
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
force_refresh: forceRefresh, force_refresh: forceRefresh,
}); });
if (marketSlug) { if (marketSlug) {
searchParams.set("market_slug", marketSlug); searchParams.set("market_slug", marketSlug);
} }
if (targetDate) {
searchParams.set("target_date", targetDate);
}
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`; const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`;
try { try {
@@ -33,6 +33,7 @@ import {
} from "@/lib/dashboard-utils"; } from "@/lib/dashboard-utils";
function normalizeMarketValue(value?: number | null) { function normalizeMarketValue(value?: number | null) {
if (value == null) return null;
const numeric = Number(value); const numeric = Number(value);
if (!Number.isFinite(numeric)) return null; if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100)); if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
@@ -567,7 +568,13 @@ export function FutureForecastModal() {
"future-refresh-btn", "future-refresh-btn",
store.loadingState.marketScan && "spinning", 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" : "刷新数据"} title={locale === "en-US" ? "Refresh Data" : "刷新数据"}
> >
<svg <svg
@@ -936,6 +943,7 @@ export function FutureForecastModal() {
detail={detail} detail={detail}
targetDate={dateStr} targetDate={dateStr}
hideTitle hideTitle
marketScan={marketScan}
/> />
</section> </section>
<section className="future-modal-section"> <section className="future-modal-section">
+32 -14
View File
@@ -28,12 +28,14 @@ function EmptyState({ text }: { text: string }) {
} }
function toPercent(value?: number | null) { function toPercent(value?: number | null) {
if (value == null) return null;
const numeric = Number(value); const numeric = Number(value);
if (!Number.isFinite(numeric)) return null; if (!Number.isFinite(numeric)) return null;
return `${(numeric * 100).toFixed(1)}%`; return `${(numeric * 100).toFixed(1)}%`;
} }
function toPriceCents(value?: number | null) { function toPriceCents(value?: number | null) {
if (value == null) return null;
const numeric = Number(value); const numeric = Number(value);
if (!Number.isFinite(numeric)) return null; if (!Number.isFinite(numeric)) return null;
const normalized = numeric > 1 ? numeric / 100 : numeric; const normalized = numeric > 1 ? numeric / 100 : numeric;
@@ -54,16 +56,20 @@ function parseTempFromText(value: unknown) {
} }
function getBucketTemp(bucket: ProbabilityBucket) { function getBucketTemp(bucket: ProbabilityBucket) {
const byValue = Number(bucket.value); if (bucket.value != null) {
if (Number.isFinite(byValue)) return byValue; const byValue = Number(bucket.value);
if (Number.isFinite(byValue)) return byValue;
}
return parseTempFromText(bucket.label || bucket.bucket || bucket.range); return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
} }
function getMarketBucketTemp(scan?: MarketScan | null) { function getMarketBucketTemp(scan?: MarketScan | null) {
if (!scan) return null; if (!scan) return null;
const byBucketValue = Number(scan.temperature_bucket?.value); if (scan.temperature_bucket?.value != null) {
if (Number.isFinite(byBucketValue)) return byBucketValue; const byBucketValue = Number(scan.temperature_bucket.value);
if (Number.isFinite(byBucketValue)) return byBucketValue;
}
const byBucketLabel = parseTempFromText( const byBucketLabel = parseTempFromText(
scan.temperature_bucket?.label || scan.temperature_bucket?.label ||
@@ -83,21 +89,29 @@ function getMarketBucketTemp(scan?: MarketScan | null) {
} }
function getMarketYesPrice(scan?: MarketScan | null) { function getMarketYesPrice(scan?: MarketScan | null) {
const preferred = Number(scan?.market_price); if (scan?.market_price != null) {
if (Number.isFinite(preferred)) return preferred; const preferred = Number(scan.market_price);
const implied = Number(scan?.yes_token?.implied_probability); if (Number.isFinite(preferred)) return preferred;
return Number.isFinite(implied) ? implied : null; }
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) { function getMarketNoPrice(scan?: MarketScan | null) {
const direct = Number(scan?.no_buy); if (scan?.no_buy != null) {
if (Number.isFinite(direct)) return direct; const direct = Number(scan.no_buy);
if (Number.isFinite(direct)) return direct;
}
const marketYes = getMarketYesPrice(scan); const marketYes = getMarketYesPrice(scan);
if (marketYes != null) return Math.max(0, Math.min(1, 1 - marketYes)); if (marketYes != null) return Math.max(0, Math.min(1, 1 - marketYes));
return null; return null;
} }
function normalizeMarketProbability(value?: number | null) { function normalizeMarketProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value); const numeric = Number(value);
if (!Number.isFinite(numeric)) return null; if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100)); 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) { function getMarketTopBucketKey(bucket: MarketTopBucket) {
const valueNum = Number(bucket?.value); if (bucket?.value != null) {
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`; const valueNum = Number(bucket.value);
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
}
const tempNum = Number(bucket?.temp); if (bucket?.temp != null) {
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`; const tempNum = Number(bucket.temp);
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
}
const parsed = parseTempFromText(bucket?.label); const parsed = parseTempFromText(bucket?.label);
if (parsed != null) return `l:${parsed.toFixed(2)}`; if (parsed != null) return `l:${parsed.toFixed(2)}`;
+41 -7
View File
@@ -33,7 +33,7 @@ interface DashboardStoreValue extends DashboardState {
futureModalDate: string | null; futureModalDate: string | null;
isGuideOpen: boolean; isGuideOpen: boolean;
loadCities: () => Promise<void>; loadCities: () => Promise<void>;
openFutureModal: (dateStr: string) => void; openFutureModal: (dateStr: string, forceRefresh?: boolean) => void;
openGuide: () => void; openGuide: () => void;
openHistory: () => Promise<void>; openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>; openTodayModal: (forceRefresh?: boolean) => Promise<void>;
@@ -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({ export function DashboardStoreProvider({
children, children,
}: { }: {
@@ -210,8 +215,13 @@ export function DashboardStoreProvider({
const selectedDetail = selectedCity const selectedDetail = selectedCity
? cityDetailsByName[selectedCity] || null ? cityDetailsByName[selectedCity] || null
: null; : null;
const selectedMarketDate =
futureModalDate || selectedForecastDate || selectedDetail?.local_date || null;
const selectedMarketScanKey = selectedCity
? getMarketScanCacheKey(selectedCity, selectedMarketDate)
: null;
const selectedMarketScan = selectedCity const selectedMarketScan = selectedCity
? marketScanByCityName[selectedCity] || null ? marketScanByCityName[selectedMarketScanKey || ""] || null
: null; : null;
useEffect(() => { useEffect(() => {
@@ -277,8 +287,10 @@ export function DashboardStoreProvider({
cityName: string, cityName: string,
force = false, force = false,
marketSlug?: string | null, marketSlug?: string | null,
targetDate?: string | null,
) => { ) => {
const cached = marketScanByCityName[cityName]; const cacheKey = getMarketScanCacheKey(cityName, targetDate);
const cached = marketScanByCityName[cacheKey];
if (!force && cached && !marketSlug) { if (!force && cached && !marketSlug) {
return cached; return cached;
} }
@@ -286,11 +298,12 @@ export function DashboardStoreProvider({
const latestScan = await dashboardClient.getCityMarketScan(cityName, { const latestScan = await dashboardClient.getCityMarketScan(cityName, {
force, force,
marketSlug, marketSlug,
targetDate,
}); });
if (latestScan) { if (latestScan) {
setMarketScanByCityName((current) => ({ setMarketScanByCityName((current) => ({
...current, ...current,
[cityName]: latestScan, [cacheKey]: latestScan,
})); }));
} }
return latestScan; return latestScan;
@@ -366,7 +379,9 @@ export function DashboardStoreProvider({
const detail = await ensureCityDetail(cityName); const detail = await ensureCityDetail(cityName);
setSelectedForecastDate(detail.local_date); setSelectedForecastDate(detail.local_date);
// 预热市场数据,不做 await 阻塞,后台静默拉取 // 预热市场数据,不做 await 阻塞,后台静默拉取
void ensureCityMarketScan(cityName, false).catch(() => {}); void ensureCityMarketScan(cityName, false, null, detail.local_date).catch(
() => {},
);
} finally { } finally {
setLoadingState((current) => ({ ...current, cityDetail: false })); setLoadingState((current) => ({ ...current, cityDetail: false }));
} }
@@ -464,9 +479,22 @@ export function DashboardStoreProvider({
isGuideOpen, isGuideOpen,
loadCities, loadCities,
loadingState, loadingState,
openFutureModal: (dateStr: string) => { openFutureModal: (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current(); mapStopMotionRef.current();
setFutureModalDate(dateStr); 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), openGuide: () => setIsGuideOpen(true),
openHistory, openHistory,
@@ -499,9 +527,15 @@ export function DashboardStoreProvider({
try { try {
// 如果缓存里没有或者想要强制刷新,则拉取最新市场数据 // 如果缓存里没有或者想要强制刷新,则拉取最新市场数据
const marketKey = getMarketScanCacheKey(
selectedCity,
detail.local_date,
);
await ensureCityMarketScan( await ensureCityMarketScan(
selectedCity, selectedCity,
forceRefresh || !marketScanByCityName[selectedCity], forceRefresh || !marketScanByCityName[marketKey],
null,
detail.local_date,
); );
} catch {} } catch {}
} catch { } catch {
+15 -2
View File
@@ -164,12 +164,19 @@ export const dashboardClient = {
async getCityMarketScan( async getCityMarketScan(
cityName: string, cityName: string,
options?: { force?: boolean; marketSlug?: string | null }, options?: {
force?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) { ) {
const force = options?.force ?? false; const force = options?.force ?? false;
const marketSlug = options?.marketSlug || null; const marketSlug = options?.marketSlug || null;
const targetDate = options?.targetDate || null;
if (!force) { if (!force) {
const requestKey = `${cityName}::cached::${marketSlug || "-"}`; const requestKey = `${cityName}::cached::${marketSlug || "-"}::${
targetDate || "-"
}`;
const existing = pendingMarketScanRequests.get(requestKey); const existing = pendingMarketScanRequests.get(requestKey);
if (existing) { if (existing) {
return existing; return existing;
@@ -181,6 +188,9 @@ export const dashboardClient = {
if (marketSlug) { if (marketSlug) {
params.set("market_slug", marketSlug); params.set("market_slug", marketSlug);
} }
if (targetDate) {
params.set("target_date", targetDate);
}
const request = fetchJson<{ market_scan?: MarketScan }>( const request = fetchJson<{ market_scan?: MarketScan }>(
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`, `/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
@@ -201,6 +211,9 @@ export const dashboardClient = {
if (marketSlug) { if (marketSlug) {
params.set("market_slug", marketSlug); params.set("market_slug", marketSlug);
} }
if (targetDate) {
params.set("target_date", targetDate);
}
return fetchJson<{ market_scan?: MarketScan }>( return fetchJson<{ market_scan?: MarketScan }>(
`/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`, `/api/city/${normalizeCityName(cityName)}/detail?${params.toString()}`,
+27 -4
View File
@@ -782,10 +782,28 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
def _build_city_detail_payload( def _build_city_detail_payload(
data: Dict[str, Any], data: Dict[str, Any],
market_slug: Optional[str] = None, market_slug: Optional[str] = None,
target_date: Optional[str] = None,
) -> Dict[str, Any]: ) -> 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() 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): if not isinstance(model_map, dict):
model_map = {} model_map = {}
@@ -839,7 +857,7 @@ def _build_city_detail_payload(
] ]
market_scan = _market_layer.build_market_scan( market_scan = _market_layer.build_market_scan(
city=data.get("name"), 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, temperature_bucket=primary_bucket if isinstance(primary_bucket, dict) else None,
model_probability=model_probability, model_probability=model_probability,
fallback_sparkline=fallback_sparkline, fallback_sparkline=fallback_sparkline,
@@ -945,11 +963,16 @@ async def city_detail_aggregate(
name: str, name: str,
force_refresh: bool = False, force_refresh: bool = False,
market_slug: Optional[str] = None, market_slug: Optional[str] = None,
target_date: Optional[str] = None,
): ):
_assert_entitlement(request) _assert_entitlement(request)
city = _normalize_city_or_404(name) city = _normalize_city_or_404(name)
data = _analyze(city, force_refresh=force_refresh) 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 # Entrypoint