feat: Implement PolyWeather web map API backend and integrate new frontend dashboard components for city weather details.
This commit is contained in:
+14
-14
@@ -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">
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user