From 326bfe258ea94ba00040f87a41a1acbe761b3e58 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 07:33:46 +0800 Subject: [PATCH] Make freshness and timing drive city-card trust Users need to know whether a city can be acted on before reading the full explanation. City cards now expose METAR or official-observation freshness, model update timing, market quote freshness, and AI state in a dedicated trust block, while the calendar view groups rows into action-oriented timing buckets with a single reason per city. Constraint: Keep the existing card and calendar data contracts; derive freshness and action reasons from fields already present in the frontend payload. Rejected: Add another long explanatory paragraph | it would repeat the same trust problem instead of making the first glance clearer. Confidence: high Scope-risk: moderate Reversibility: clean Tested: npm run build Not-tested: Live browser visual QA with stale METAR and delayed quote examples. --- CHANGELOG.md | 2 + .../components/dashboard/Dashboard.module.css | 65 ++++++- .../scan-terminal/AiPinnedForecastView.tsx | 126 ++++++++++--- .../dashboard/scan-terminal/CalendarView.tsx | 167 ++++++++++++++++-- 4 files changed, 315 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56eb5d8a..60c520a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - 新增 Qingdao / 青岛城市,结算锚点接入 Wunderground 青岛胶东国际机场 `ZSQD` 历史页,并补齐别名、时区、预热、官方来源和前端地区归类 - 城市决策卡顶部状态标签收口为 2-3 个高优先级信号,优先展示“实测突破 / 峰值窗口已过 / METAR 过旧 / AI 解读中 / 市场价格缺失 / 模型高度一致 / 需要等待下一报文”,让用户第一眼看到重点 - 城市决策卡 AI 机场报文区明确拆分“快速判断已完成,AI 正在补充机场报文细节… / AI 机场报文解读已完成 / AI 解读未完整返回,当前使用规则证据”三种状态,减少 fallback 与流式返回造成的误解 +- 城市决策卡新增“数据新鲜度”区块,分别展示 METAR/官方观测、模型、市场价格和 AI 状态;过旧观测会标明“仅作背景参考” +- 日历视图升级为行动视图,按“现在可看 / 1-3 小时内 / 今天稍后 / 已过峰值,等待确认”分组,并为每个城市显示一句核心原因 - 城市决策卡新增 AI 机场报文解读缓存说明:页面内存缓存保留 loading / 流式片段 / 最终结果,`localStorage` 保存最终成功 payload,后端 AI 缓存不再因 `local_time` 变化失效 - 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常” - 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间 diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 0e7be769..57456eb4 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -11377,21 +11377,64 @@ } .root :global(.scan-ai-city-freshness) { - display: flex; - flex-wrap: wrap; - gap: 7px; - margin-top: 8px; - color: #8fa4bd; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + max-width: 620px; + margin-top: 10px; + color: #9fb2c7; font-size: 11px; font-weight: 760; } +.root :global(.scan-ai-city-freshness strong) { + grid-column: 1 / -1; + color: #d8e7f8; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .root :global(.scan-ai-city-freshness span) { + display: inline-flex; + align-items: center; + gap: 2px; border-radius: 8px; background: rgba(148, 163, 184, 0.08); padding: 4px 7px; } +.root :global(.scan-ai-city-freshness b) { + color: #d6e2f0; + font-style: normal; + font-weight: 900; +} + +.root :global(.scan-ai-city-freshness em) { + color: #9fb2c7; + font-style: normal; +} + +.root :global(.scan-ai-city-freshness span.fresh em), +.root :global(.scan-ai-city-freshness span.green em) { + color: #86efac; +} + +.root :global(.scan-ai-city-freshness span.loading em), +.root :global(.scan-ai-city-freshness span.blue em) { + color: #9ecbff; +} + +.root :global(.scan-ai-city-freshness span.stale em), +.root :global(.scan-ai-city-freshness span.amber em) { + color: #fcd34d; +} + +.root :global(.scan-ai-city-freshness span.red em) { + color: #fca5a5; +} + .root :global(.scan-ai-city-hero-side) { min-width: 180px; display: grid; @@ -12104,6 +12147,14 @@ font-weight: 800; } +.root :global(.scan-calendar-reason) { + margin: 10px 0 0; + color: #d6e4f5; + font-size: 13px; + font-weight: 760; + line-height: 1.45; +} + .root :global(.scan-summary-grid) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -13469,6 +13520,10 @@ color: #122033; } +.root :global(.scan-terminal.light .scan-calendar-reason) { + color: #243b5a; +} + .root :global(.scan-terminal.light .scan-calendar-countdown) { border-color: rgba(10, 160, 100, 0.18); background: rgba(220, 252, 239, 0.68); diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx index f1434c27..b909ad2f 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx @@ -115,7 +115,29 @@ function formatFreshnessAge(value: unknown, isEn: boolean) { return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`; } -function buildObservationFreshnessLabel({ +function formatUpdateTime(value: unknown, locale: string) { + const epochMs = parseEpochMs(value); + if (epochMs == null) return ""; + const date = new Date(epochMs); + const now = new Date(); + const sameDay = date.toDateString() === now.toDateString(); + const time = date.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + if (locale === "en-US") { + const day = sameDay + ? "today" + : date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + return `${day} ${time} updated`; + } + const day = sameDay + ? "今日" + : date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); + return `${day} ${time} 更新`; +} + +function buildObservationFreshnessValue({ detail, displayTime, isEn, @@ -126,20 +148,50 @@ function buildObservationFreshnessLabel({ isEn: boolean; isHkoObservation: boolean; }) { - const source = isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR"; const stale = Boolean( detail?.metar_status?.stale_for_today || detail?.airport_current?.stale_for_today || detail?.current?.observation_status === "stale", ); - if (stale) return isEn ? `${source} stale` : `${source} 过旧`; + if (stale) { + return isEn ? "stale; background only" : "已过旧,仅作背景参考"; + } const ageLabel = formatFreshnessAge( isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min, isEn, ); - if (ageLabel) return `${source} ${ageLabel}`; - if (displayTime) return `${source} ${displayTime}`; - return isEn ? `${source} time pending` : `${source} 时间待确认`; + if (ageLabel) return ageLabel; + if (displayTime) return displayTime; + return isEn ? "time pending" : "时间待确认"; +} + +function buildModelFreshnessValue(detail: CityDetail | null, locale: string, isEn: boolean) { + return ( + formatUpdateTime(detail?.updated_at, locale) || + (isEn ? "latest run loaded" : "已加载最新模型") + ); +} + +function buildMarketFreshnessValue({ + isEn, + marketScan, + marketStatus, +}: { + isEn: boolean; + marketScan: ReturnType["marketScan"]; + marketStatus: ReturnType["marketStatus"]; +}) { + if (marketStatus === "loading") return isEn ? "syncing" : "同步中"; + if (!marketScan?.available) return isEn ? "missing" : "缺失"; + const quoteAgeMs = Number( + marketScan.quote_age_ms ?? + marketScan.yes_token?.quote_age_ms ?? + marketScan.no_token?.quote_age_ms, + ); + if (Number.isFinite(quoteAgeMs) && quoteAgeMs >= 0) { + return formatFreshnessAge(quoteAgeMs / 60_000, isEn); + } + return isEn ? "synced" : "已同步"; } function uniqueStatusTags(tags: Array) { @@ -362,12 +414,6 @@ function AiPinnedCityCard({ !observedLowBreak && !peakHasPassed && (observedLowLag || paceTone === "neutral" || aiForecast.status === "loading"); - const observationFreshnessLabel = buildObservationFreshnessLabel({ - detail, - displayTime: metarReportTimeDisplay, - isEn, - isHkoObservation, - }); const aiRuleEvidenceMode = Boolean( aiForecast.status === "failed" || (aiForecast.status === "ready" && !aiCityForecast) || @@ -421,24 +467,40 @@ function AiPinnedCityCard({ : aiRuleEvidenceMode ? "amber" : "muted"; - const marketFreshnessLabel = - marketDecisionView.status === "ready" - ? isEn - ? "Market synced" - : "市场已同步" - : marketDecisionView.status === "loading" - ? isEn - ? "Market loading" - : "市场同步中" - : isEn - ? "No market price" - : "暂无市场价"; const marketStatusTone: StatusTagTone = marketDecisionView.status === "ready" ? "green" : marketDecisionView.status === "loading" ? "blue" : "muted"; + const dataFreshnessRows = [ + { + label: isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR", + value: buildObservationFreshnessValue({ + detail, + displayTime: metarReportTimeDisplay, + isEn, + isHkoObservation, + }), + tone: observationStale ? "stale" : "fresh", + }, + { + label: isEn ? "Models" : "模型", + value: buildModelFreshnessValue(detail, locale, isEn), + tone: "fresh", + }, + { + label: isEn ? "Market" : "市场价格", + value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }), + tone: + marketDecisionView.status === "ready" + ? "fresh" + : marketDecisionView.status === "loading" + ? "loading" + : "stale", + }, + ]; + const freshnessSeparator = isEn ? ": " : ":"; const statusTags = uniqueStatusTags([ observedHighBreak ? { @@ -544,10 +606,18 @@ function AiPinnedCityCard({ {isEn ? "Model" : "模型"} {modelRange} {isEn ? "Peak" : "峰值"} {peakWindow} -
- {observationFreshnessLabel} - {marketFreshnessLabel} - {aiStatusLabel} +
+ {isEn ? "Data freshness" : "数据新鲜度"} + {dataFreshnessRows.map((freshness) => ( + + {freshness.label}{freshnessSeparator} + {freshness.value} + + ))} + + AI{freshnessSeparator} + {aiStatusLabel} +
diff --git a/frontend/components/dashboard/scan-terminal/CalendarView.tsx b/frontend/components/dashboard/scan-terminal/CalendarView.tsx index 72c08bcd..d76fee56 100644 --- a/frontend/components/dashboard/scan-terminal/CalendarView.tsx +++ b/frontend/components/dashboard/scan-terminal/CalendarView.tsx @@ -3,7 +3,10 @@ import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue } from "@/lib/dashboard-utils"; -import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-utils"; +import { + formatShortDate, + getPeakCountdownMeta, +} from "@/components/dashboard/scan-terminal/decision-utils"; const CALENDAR_UPCOMING_HORIZON_MINUTES = 12 * 60; const CALENDAR_POST_PEAK_GRACE_MINUTES = 3 * 60; @@ -16,6 +19,13 @@ type CalendarMeta = ReturnType & { endAtMs?: number | null; }; +type CalendarActionGroup = { + key: "now" | "next" | "later" | "past"; + label: string; + subtitle: string; + sort: number; +}; + function normalizeCalendarCityKey(value?: string | null) { return String(value || "") .trim() @@ -154,6 +164,133 @@ function isCalendarActionable(row: ScanOpportunityRow, meta: CalendarMeta, nowMs return startDelta >= 0 && startDelta <= CALENDAR_UPCOMING_HORIZON_MINUTES; } +function getCalendarActionGroup( + row: ScanOpportunityRow, + meta: CalendarMeta, + nowMs: number, + locale: string, +): CalendarActionGroup { + const isEn = locale === "en-US"; + const phase = String(row.window_phase || "").toLowerCase(); + const startDelta = + meta.startAtMs != null ? (meta.startAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_start); + const endDelta = + meta.endAtMs != null ? (meta.endAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_end); + const isPast = + phase === "post_peak" || + (endDelta != null && endDelta < 0) || + meta.key === "past"; + if (isPast) { + return { + key: "past", + label: isEn ? "Past peak · confirm" : "已过峰值,等待确认", + subtitle: isEn ? "Check whether a new high printed; avoid chasing if it did not." : "确认是否刷新高点;若无新高,避免追高。", + sort: 3, + }; + } + const isNow = + phase === "active_peak" || + (startDelta != null && startDelta <= 60) || + meta.key === "active"; + if (isNow) { + return { + key: "now", + label: isEn ? "Watch now" : "现在可看", + subtitle: isEn ? "Peak window is live or close enough to require immediate checks." : "峰值窗口正在进行或即将开始,需要马上核对。", + sort: 0, + }; + } + if (startDelta != null && startDelta <= 180) { + return { + key: "next", + label: isEn ? "In 1-3 hours" : "1-3 小时内", + subtitle: isEn ? "Prepare the setup and wait for the next observation." : "提前准备,只等下一轮观测确认。", + sort: 1, + }; + } + return { + key: "later", + label: isEn ? "Later today" : "今天稍后", + subtitle: isEn ? "Keep on the board, but do not spend attention yet." : "先放在行动面板,不需要立刻盯盘。", + sort: 2, + }; +} + +function getCalendarModelUpper(row: ScanOpportunityRow) { + const values = [ + finiteCalendarNumber(row.cluster_core_high), + ...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)), + ].filter((value): value is number => value != null); + return values.length ? Math.max(...values) : null; +} + +function getCalendarModelLower(row: ScanOpportunityRow) { + const values = [ + finiteCalendarNumber(row.cluster_core_low), + ...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)), + ].filter((value): value is number => value != null); + return values.length ? Math.min(...values) : null; +} + +function firstSentence(value?: string | null) { + const text = String(value || "").trim(); + if (!text) return ""; + const match = text.match(/^.*?[。.!??](?:\s|$)/); + return (match?.[0] || text).trim(); +} + +function buildCalendarCoreReason( + row: ScanOpportunityRow, + group: CalendarActionGroup, + locale: string, +) { + const isEn = locale === "en-US"; + const tempSymbol = row.temp_symbol || "°C"; + const currentTemp = finiteCalendarNumber(row.current_temp ?? row.metar_context?.last_temp); + const modelUpper = getCalendarModelUpper(row); + const modelLower = getCalendarModelLower(row); + const modelSpread = + modelUpper != null && modelLower != null ? modelUpper - modelLower : null; + if (currentTemp != null && modelUpper != null && currentTemp > modelUpper + 0.2) { + return isEn + ? `Observed ${formatTemperatureValue(currentTemp, tempSymbol)} is above the model upper bound; watch whether the high keeps revising up.` + : `实测已高于模型上沿,需关注是否继续上修`; + } + if (group.key === "past") { + return isEn + ? "Peak window has passed; avoid chasing if no new high prints." + : "峰值窗口已过,若无新高应避免追高"; + } + if (row.metar_status?.stale_for_today || row.metar_context?.stale_for_today) { + return isEn + ? "METAR is stale, so use it as background only." + : "METAR 已过旧,仅作背景参考"; + } + if (currentTemp != null && modelLower != null && currentTemp < modelLower - 0.5) { + return isEn + ? "Observed temperature is still below the model core; wait for the next report." + : "实测仍低于模型核心区,等待下一报文确认"; + } + if (Number(row.cluster_model_count || 0) >= 4 && modelSpread != null && modelSpread <= 2) { + return isEn + ? "Models are tightly aligned; the next observation should decide direction." + : "模型高度一致,等待下一报文确认方向"; + } + const aiReason = firstSentence( + isEn + ? row.ai_watchlist_reason_en || row.ai_forecast_match_reason_en || row.ai_reason_en || row.ai_city_thesis_en + : row.ai_watchlist_reason_zh || row.ai_forecast_match_reason_zh || row.ai_reason_zh || row.ai_city_thesis_zh, + ); + if (aiReason) return aiReason; + return group.key === "now" + ? isEn + ? "Peak timing is close; open the card and verify live evidence." + : "峰值时间接近,打开卡片核对实况证据" + : isEn + ? "Keep it on the action board until the next observation." + : "先放入行动面板,等待下一轮观测"; +} + export function CalendarView({ rows, locale, @@ -181,31 +318,38 @@ export function CalendarView({ }, []); const groups = useMemo(() => { - const order = ["active", "next", "today", "later", "past"]; const byPhase = new Map< string, { label: string; + subtitle: string; sort: number; - items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta }>; + items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta; reason: string }>; } >(); dedupeCalendarRows(rows).forEach((row) => { const meta = buildCalendarMeta(row, locale, snapshotMs, nowMs); if (!isCalendarActionable(row, meta, nowMs)) return; - const current = byPhase.get(meta.key) || { - label: meta.groupLabel, - sort: order.indexOf(meta.key) >= 0 ? order.indexOf(meta.key) : order.length, + const actionGroup = getCalendarActionGroup(row, meta, nowMs, locale); + const current = byPhase.get(actionGroup.key) || { + label: actionGroup.label, + subtitle: actionGroup.subtitle, + sort: actionGroup.sort, items: [], }; - current.items.push({ row, meta }); - byPhase.set(meta.key, current); + current.items.push({ + row, + meta, + reason: buildCalendarCoreReason(row, actionGroup, locale), + }); + byPhase.set(actionGroup.key, current); }); return Array.from(byPhase.entries()) .sort(([, left], [, right]) => left.sort - right.sort) .map(([key, group]) => ({ key, label: group.label, + subtitle: group.subtitle, items: group.items.sort((left, right) => { if (left.meta.sort !== right.meta.sort) return left.meta.sort - right.meta.sort; return Number(right.row.edge_percent || 0) - Number(left.row.edge_percent || 0); @@ -233,9 +377,7 @@ export function CalendarView({
{group.label}
- {locale === "en-US" - ? "Ordered by DEB peak-window countdown" - : "按 DEB 峰值窗口倒计时排序"} + {group.subtitle}
@@ -243,7 +385,7 @@ export function CalendarView({
- {group.items.map(({ row, meta }) => ( + {group.items.map(({ row, meta, reason }) => (
+

{reason}

{locale === "en-US" ? "DEB high" : "DEB 预测高点"}