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
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 只读)"
```
---
@@ -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 {
@@ -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" : "刷新数据"}
>
<svg
@@ -936,6 +943,7 @@ export function FutureForecastModal() {
detail={detail}
targetDate={dateStr}
hideTitle
marketScan={marketScan}
/>
</section>
<section className="future-modal-section">
+32 -14
View File
@@ -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)}`;
+41 -7
View File
@@ -33,7 +33,7 @@ interface DashboardStoreValue extends DashboardState {
futureModalDate: string | null;
isGuideOpen: boolean;
loadCities: () => Promise<void>;
openFutureModal: (dateStr: string) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => void;
openGuide: () => void;
openHistory: () => 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({
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 {
+15 -2
View File
@@ -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()}`,
+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(
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