diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 532d6681..b0371da5 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -86,7 +86,7 @@ --border-subtle: rgba(226, 232, 240, 0.74); --text-primary: #0F172A; --text-secondary: #475569; - --text-muted: #94A3B8; + --text-muted: #64748B; background: #F7F9FC; color: #0F172A; background-image: @@ -13826,7 +13826,60 @@ } .root :global(.scan-terminal.light .scan-map-shell) { - background: rgba(255, 255, 255, 0.72); + background: + radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%), + linear-gradient(180deg, #f8fbff, #eaf3ff); + border-color: rgba(37, 99, 235, 0.16); + box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12); +} + +.root :global(.scan-terminal.light .scan-map-shell .map), +.root :global(.scan-terminal.light .scan-map-shell .leaflet-container) { + background: #eaf3ff !important; +} + +.root :global(.scan-terminal.light .scan-map-shell .leaflet-tile) { + filter: saturate(1.06) contrast(0.98) brightness(1.04) !important; +} + +.root :global(.scan-terminal.light .scan-map-caption) { + color: #475569; +} + +.root :global(.scan-terminal.light .scan-list-tabs button), +.root :global(.scan-terminal.light .scan-mode-tab-sub), +.root :global(.scan-terminal.light .scan-opportunity-models em), +.root :global(.scan-terminal.light .scan-opportunity-stat small), +.root :global(.scan-terminal.light .panel-meta-chip), +.root :global(.scan-terminal.light .panel-weather-chip), +.root :global(.scan-terminal.light .scan-distribution-card), +.root :global(.scan-terminal.light .scan-chart-legend), +.root :global(.scan-terminal.light .scan-kpi-note), +.root :global(.scan-terminal.light .scan-calendar-subtitle), +.root :global(.scan-terminal.light .scan-calendar-action span), +.root :global(.scan-terminal.light .scan-calendar-countdown small), +.root :global(.scan-terminal.light .scan-opportunity-ai small), +.root :global(.scan-terminal.light .scan-ai-log-head span), +.root :global(.scan-terminal.light .scan-ai-log-time), +.root :global(.scan-terminal.light .scan-ai-log-empty), +.root :global(.scan-terminal.light .scan-ai-log-item small), +.root :global(.scan-terminal.light .scan-ai-summary-card p), +.root :global(.scan-terminal.light .scan-ai-city-head p), +.root :global(.scan-terminal.light .scan-ai-contract p), +.root :global(.scan-terminal.light .scan-ai-contract small), +.root :global(.scan-terminal.light .scan-v4-analysis p), +.root :global(.scan-terminal.light .scan-v4-analysis ul), +.root :global(.scan-terminal.light .scan-v4-evidence > div > span), +.root :global(.scan-terminal.light .scan-v4-model-sources em), +.root :global(.scan-terminal.light .scan-ai-raw-metar), +.root :global(.scan-terminal.light .scan-opportunity-hero p), +.root :global(.scan-terminal.light .scan-opportunity-summary span), +.root :global(.scan-terminal.light .scan-opportunity-lane-head p), +.root :global(.scan-terminal.light .scan-opportunity-decision-card p), +.root :global(.scan-terminal.light .scan-opportunity-decision-head span), +.root :global(.scan-terminal.light .scan-opportunity-decision-primary span), +.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) { + color: #475569; } @media (max-width: 1480px) { diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index d5c694ac..3ba51c13 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -23,6 +23,62 @@ function toFiniteDecisionNumber(value: unknown) { return Number.isFinite(numeric) ? numeric : null; } +function parseEpochMs(value: unknown) { + if (value == null || value === "") return null; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric > 1_000_000_000_000 ? numeric : numeric * 1000; + const parsed = new Date(String(value)); + return Number.isNaN(parsed.getTime()) ? null : parsed.getTime(); +} + +function formatMetarReportTime(detail: CityDetail | null, report: string, isEn: boolean) { + const offsetSeconds = Number(detail?.utc_offset_seconds); + const epochMs = + parseEpochMs(detail?.airport_current?.report_time) ?? + parseEpochMs(detail?.airport_current?.obs_time_epoch) ?? + parseEpochMs(detail?.airport_current?.obs_time) ?? + parseEpochMs(detail?.current?.report_time) ?? + parseEpochMs(detail?.current?.obs_time_epoch) ?? + parseEpochMs(detail?.current?.obs_time); + if (epochMs != null) { + const utc = new Date(epochMs); + const zText = `${String(utc.getUTCHours()).padStart(2, "0")}:${String( + utc.getUTCMinutes(), + ).padStart(2, "0")}Z`; + if (Number.isFinite(offsetSeconds)) { + const local = new Date(epochMs + offsetSeconds * 1000); + const localText = `${String(local.getUTCHours()).padStart(2, "0")}:${String( + local.getUTCMinutes(), + ).padStart(2, "0")}`; + return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`; + } + return zText; + } + + const rawToken = String(report || "").match(/\b(\d{2})(\d{2})(\d{2})Z\b/i); + if (!rawToken) return ""; + const zText = `${rawToken[2]}:${rawToken[3]}Z`; + if (!Number.isFinite(offsetSeconds)) return zText; + const utcMinutes = Number(rawToken[2]) * 60 + Number(rawToken[3]); + if (!Number.isFinite(utcMinutes)) return zText; + const localMinutes = Math.round( + ((utcMinutes + offsetSeconds / 60) % 1440 + 1440) % 1440, + ); + const localText = `${String(Math.floor(localMinutes / 60)).padStart(2, "0")}:${String( + localMinutes % 60, + ).padStart(2, "0")}`; + return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`; +} + +function normalizeMetarReadTime(text: string, displayTime: string, isEn: boolean) { + if (!text || !displayTime) return text; + const timeLabel = isEn ? `report time ${displayTime}` : `报文时间 ${displayTime}`; + return text + .replace(/报文时间\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel) + .replace(/report time\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel) + .replace(/\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, `at ${displayTime}`); +} + function AiPinnedCityCard({ item, detail, @@ -82,6 +138,7 @@ function AiPinnedCityCard({ ? "Waiting for intraday observations to compare against the DEB path." : "等待更多日内实测,用来对照 DEB 预测路径。"); const report = detail?.current?.raw_metar || detail?.airport_current?.raw_metar || ""; + const metarReportTimeDisplay = formatMetarReportTime(detail, report, isEn); const airportStation = detail?.risk?.icao || detail?.current?.station_code || @@ -104,16 +161,31 @@ function AiPinnedCityCard({ }); const aiCityForecast = aiForecast.payload?.city_forecast || null; - const localizedFinalJudgment = + const localizedFinalJudgmentRaw = (isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) || (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || ""; - const localizedMetarRead = + const localizedMetarReadRaw = (isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) || ""; - const localizedReasoning = + const localizedReasoningRaw = (isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) || ""; + const localizedFinalJudgment = normalizeMetarReadTime( + localizedFinalJudgmentRaw, + metarReportTimeDisplay, + isEn, + ); + const localizedMetarRead = normalizeMetarReadTime( + localizedMetarReadRaw, + metarReportTimeDisplay, + isEn, + ); + const localizedReasoning = normalizeMetarReadTime( + localizedReasoningRaw, + metarReportTimeDisplay, + isEn, + ); const localizedModelNote = (isEn ? aiCityForecast?.model_cluster_note_en @@ -349,13 +421,11 @@ function AiPinnedCityCard({ ? "Deepseek V4 pro is reading the latest airport bulletin..." : "Deepseek V4 pro 正在解读最新机场报文...")}
- {aiForecast.streamText ? ( -- {isEn - ? "Streaming airport read; final forecast is still being completed..." - : "机场报文解读正在流式输出,最终预报结论仍在补全..."} -
- ) : null} ++ {isEn + ? "Non-streaming mode is enabled to avoid truncated airport reads." + : "已停用流式输出,改用非流式 JSON 请求,避免报文解读被截断。"} +
> ) : aiForecast.status === "ready" && aiCityForecast ? ( <> diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts index 03bb287f..ceb351d1 100644 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -96,6 +96,16 @@ function getBucketAnchor(bucket: MarketTopBucket) { return toFiniteMarketNumber(bucket.temp ?? bucket.value ?? bucket.lower); } +function getRoundedWeatherBucketValue( + expectedHigh: number | null, + tempSymbol: string, + bucket: MarketTopBucket, +) { + const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); + if (comparable == null || !Number.isFinite(comparable)) return null; + return Math.round(comparable); +} + function getBucketModelProbability(bucket?: MarketTopBucket | null) { const model = normalizeMarketProbability(bucket?.model_probability); const probability = normalizeMarketProbability(bucket?.probability); @@ -164,24 +174,33 @@ export function pickMarketBucketForWeatherCenter( return isReasonableFallback(selectedBucket) ? selectedBucket : null; } + let roundedMatch: MarketTopBucket | null = null; let nearest: MarketTopBucket | null = null; let nearestDelta = Number.POSITIVE_INFINITY; for (const bucket of buckets) { const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); if (comparable == null) continue; + const roundedTarget = getRoundedWeatherBucketValue(expectedHigh, tempSymbol, bucket); const lower = bucket.lower != null ? Number(bucket.lower) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null; + const anchor = getBucketAnchor(bucket); + if (anchor != null && roundedTarget != null && Math.round(anchor) === roundedTarget) { + roundedMatch = bucket; + break; + } if ( + roundedMatch == null && lower != null && upper != null && Number.isFinite(lower) && Number.isFinite(upper) && - comparable >= lower - 0.01 && - comparable <= upper + 0.01 + roundedTarget != null && + roundedTarget >= lower - 0.01 && + roundedTarget <= upper + 0.01 ) { - return bucket; + roundedMatch = bucket; + break; } - const anchor = getBucketAnchor(bucket); if (anchor == null) continue; const delta = Math.abs(anchor - comparable); if (delta < nearestDelta) { @@ -189,6 +208,7 @@ export function pickMarketBucketForWeatherCenter( nearestDelta = delta; } } + if (roundedMatch) return roundedMatch; if (!nearest) return isReasonableFallback(selectedBucket) ? selectedBucket : null; const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, nearest); const unit = String(nearest.unit || "").toUpperCase(); diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 31d7d538..be0a3d98 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -1,11 +1,6 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { - enqueueAiCityFetch, - extractStreamingAirportRead, - parseSseBlock, -} from "@/components/dashboard/scan-terminal/ai-city-stream"; import type { AiCityForecastPayload, AiCityForecastState, @@ -14,9 +9,9 @@ import { useDashboardStore } from "@/hooks/useDashboardStore"; import type { CityDetail, MarketScan } from "@/lib/dashboard-types"; import { normalizeCityKey } from "./decision-utils"; -const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v1"; +const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v2"; const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; -const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v1"; +const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v2"; const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000; function getStorage() { @@ -62,56 +57,6 @@ function writeCachedPayload