From 42dd0a7c6d01f78c509adf11bcd3fd57f5bb508a Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 21 Apr 2026 23:01:38 +0800 Subject: [PATCH] feat: implement FutureForecastModal component and market-scan API integration --- .../app/api/city/[name]/market-scan/route.ts | 68 +++++++++++++++++++ .../dashboard/FutureForecastModal.tsx | 46 ++++++++++++- frontend/lib/dashboard-client.ts | 52 ++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 frontend/app/api/city/[name]/market-scan/route.ts diff --git a/frontend/app/api/city/[name]/market-scan/route.ts b/frontend/app/api/city/[name]/market-scan/route.ts new file mode 100644 index 00000000..3a7c4310 --- /dev/null +++ b/frontend/app/api/city/[name]/market-scan/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET( + req: NextRequest, + context: { params: Promise<{ name: string }> }, +) { + if (!API_BASE) { + const response = NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + return response; + } + + const { name } = await context.params; + const params = new URLSearchParams(); + const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; + params.set("force_refresh", forceRefresh); + + const targetDate = req.nextUrl.searchParams.get("target_date"); + if (targetDate) { + params.set("target_date", targetDate); + } + + const marketSlug = req.nextUrl.searchParams.get("market_slug"); + if (marketSlug) { + params.set("market_slug", marketSlug); + } + + const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`; + + try { + const auth = await buildBackendRequestHeaders(req, { + includeSupabaseIdentity: false, + }); + const res = await fetch(url, { + headers: auth.headers, + cache: "no-store", + }); + if (!res.ok) { + const raw = await res.text(); + const response = NextResponse.json( + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) }, + { status: 502 }, + ); + return applyAuthResponseCookies(response, auth.response); + } + const data = await res.json(); + const response = NextResponse.json(data, { + headers: { + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + const response = NextResponse.json( + { error: "Failed to fetch city market scan", detail: String(error) }, + { status: 500 }, + ); + return response; + } +} diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 57cb8f6a..391dad36 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -31,7 +31,8 @@ import { getTemperatureChartData, getWeatherSummary, } from "@/lib/dashboard-utils"; -import type { IntradayMeteorologySignal } from "@/lib/dashboard-types"; +import { dashboardClient } from "@/lib/dashboard-client"; +import type { IntradayMeteorologySignal, MarketScan } from "@/lib/dashboard-types"; function normalizeMarketValue(value?: number | null) { if (value == null) return null; @@ -681,6 +682,7 @@ export function FutureForecastModal() { const isPro = store.proAccess.subscriptionActive; const isProLoading = store.proAccess.loading; const [showDeferredTodaySections, setShowDeferredTodaySections] = useState(false); + const [freshMarketScan, setFreshMarketScan] = useState(null); if (!detail || !dateStr) return null; @@ -725,6 +727,46 @@ export function FutureForecastModal() { const isStructureSyncing = store.loadingState.futureDeep || !isFullDetailReady; const isAnyLayerSyncing = isStructureSyncing; const isTodayBlockingRefresh = isToday && isStructureSyncing; + const activeMarketScan = freshMarketScan || detail.market_scan || null; + + useEffect(() => { + setFreshMarketScan(null); + if (!isToday || !isFullDetailReady || !isPro) return; + const cityName = String(detail.name || detail.display_name || "").trim(); + if (!cityName || !dateStr) return; + + let cancelled = false; + dashboardClient + .getCityMarketScan(cityName, { + force: false, + marketSlug: detail.market_scan?.primary_market?.slug || null, + targetDate: dateStr, + }) + .then((payload) => { + if (cancelled) return; + setFreshMarketScan(payload.market_scan || null); + }) + .catch(() => { + if (!cancelled) { + setFreshMarketScan(null); + } + }); + + return () => { + cancelled = true; + }; + }, [ + dateStr, + detail.display_name, + detail.local_date, + detail.market_scan?.primary_market?.slug, + detail.name, + detail.updated_at, + isFullDetailReady, + isPro, + isToday, + ]); + const view = getFutureModalView(detail, dateStr, locale); const scorePosition = `${50 + view.front.score / 2}%`; const barStyle = { @@ -1946,7 +1988,7 @@ export function FutureForecastModal() { diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index 694b5267..71d6118c 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -5,6 +5,7 @@ import { CityListItem, CitySummary, HistoryPayload, + MarketScan, } from "@/lib/dashboard-types"; const CACHE_KEY = "polyWeather_v1"; @@ -69,6 +70,31 @@ export function getCityRevision(source?: CityDetail | CitySummary | null) { .map((item) => `${normalizeRevisionPart(item?.date)}:${normalizeRevisionPart(item?.max_temp)}`) .join("|") : ""; + const marketScan = "market_scan" in source ? source.market_scan : null; + const marketFootprint = marketScan + ? [ + normalizeRevisionPart(marketScan.selected_slug), + normalizeRevisionPart(marketScan.market_price), + normalizeRevisionPart(marketScan.yes_buy), + normalizeRevisionPart(marketScan.yes_sell), + normalizeRevisionPart(marketScan.no_buy), + normalizeRevisionPart(marketScan.no_sell), + normalizeRevisionPart(marketScan.price_analysis?.best_side), + normalizeRevisionPart( + Array.isArray(marketScan.all_buckets) + ? marketScan.all_buckets + .slice(0, 6) + .map( + (bucket) => + `${normalizeRevisionPart(bucket?.temp ?? bucket?.value)}:${normalizeRevisionPart( + bucket?.market_price ?? bucket?.yes_buy, + )}`, + ) + .join(",") + : "", + ), + ].join("|") + : ""; return [ normalizeRevisionPart(source.updated_at), normalizeRevisionPart(source.current?.obs_time), @@ -83,6 +109,7 @@ export function getCityRevision(source?: CityDetail | CitySummary | null) { : "", ), normalizeRevisionPart(forecastFootprint), + normalizeRevisionPart(marketFootprint), ].join("|"); } @@ -212,6 +239,31 @@ export const dashboardClient = { ); }, + async getCityMarketScan( + cityName: string, + options?: { + force?: boolean; + marketSlug?: string | null; + targetDate?: string | null; + }, + ) { + const params = new URLSearchParams({ + force_refresh: String(options?.force ?? false), + _ts: String(Date.now()), + }); + if (options?.targetDate) { + params.set("target_date", options.targetDate); + } + if (options?.marketSlug) { + params.set("market_slug", options.marketSlug); + } + return fetchJson<{ + fetched_at?: string | null; + market_scan?: MarketScan | null; + selected_date?: string | null; + }>(`/api/city/${normalizeCityName(cityName)}/market-scan?${params.toString()}`); + }, + async getHistory(cityName: string, options?: { includeRecords?: boolean }) { const includeRecords = options?.includeRecords === true; const requestKey = `${normalizeCityName(cityName)}::${