Make city decision cards easier to trust at a glance

The decision card now exposes deterministic guard state, data freshness, AI readiness and market sync directly in the card header so users can understand whether they are looking at fresh evidence, a fallback, or a stale/exception case before reading the full explanation.

Constraint: Keep the first useful read available while DeepSeek airport/HKO details are still streaming.\nRejected: Add another expanded evidence panel | header-level badges are faster to scan and avoid increasing card depth.\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nTested: npm run build\nNot-tested: Browser visual QA on production data.
This commit is contained in:
2569718930@qq.com
2026-04-28 06:52:00 +08:00
parent de0effc40b
commit e6010d8242
4 changed files with 289 additions and 10 deletions
@@ -11330,6 +11330,68 @@
padding: 7px 10px;
}
.root :global(.scan-ai-city-status-tags) {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 10px;
}
.root :global(.scan-ai-city-status-tag) {
border: 1px solid rgba(159, 178, 199, 0.16);
border-radius: 999px;
background: rgba(15, 23, 42, 0.62);
color: #cbd5e1;
font-size: 11px;
font-weight: 900;
letter-spacing: 0.01em;
padding: 5px 9px;
}
.root :global(.scan-ai-city-status-tag.green) {
border-color: rgba(34, 197, 94, 0.34);
background: rgba(34, 197, 94, 0.12);
color: #86efac;
}
.root :global(.scan-ai-city-status-tag.blue) {
border-color: rgba(77, 163, 255, 0.36);
background: rgba(77, 163, 255, 0.13);
color: #9ecbff;
}
.root :global(.scan-ai-city-status-tag.amber) {
border-color: rgba(245, 158, 11, 0.38);
background: rgba(245, 158, 11, 0.13);
color: #fcd34d;
}
.root :global(.scan-ai-city-status-tag.red) {
border-color: rgba(248, 113, 113, 0.4);
background: rgba(239, 68, 68, 0.13);
color: #fca5a5;
}
.root :global(.scan-ai-city-status-tag.muted) {
color: #94a3b8;
}
.root :global(.scan-ai-city-freshness) {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 8px;
color: #8fa4bd;
font-size: 11px;
font-weight: 760;
}
.root :global(.scan-ai-city-freshness span) {
border-radius: 8px;
background: rgba(148, 163, 184, 0.08);
padding: 4px 7px;
}
.root :global(.scan-ai-city-hero-side) {
min-width: 180px;
display: grid;
@@ -13688,7 +13750,8 @@
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket),
.root :global(.scan-terminal.light .scan-ai-city-pills span) {
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness span) {
background: #eef2f7;
border-color: #e2e8f0;
}
@@ -13710,6 +13773,7 @@
.root :global(.scan-terminal.light .scan-ai-city-section p),
.root :global(.scan-terminal.light .scan-ai-decision-band p),
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket span),
.root :global(.scan-terminal.light .scan-ai-market-decision span),
@@ -14174,7 +14238,8 @@
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-decision),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span) {
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-freshness span) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
}
@@ -14185,3 +14250,19 @@
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) {
color: #334155 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.green) {
color: #047857 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.blue) {
color: #1d4ed8 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.amber) {
color: #b45309 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.red) {
color: #b91c1c !important;
}
@@ -94,6 +94,63 @@ function isHkoObservationCity(detail?: CityDetail | null) {
return source === "hko";
}
type StatusTagTone = "green" | "blue" | "amber" | "red" | "muted";
type StatusTag = {
label: string;
tone: StatusTagTone;
};
function formatFreshnessAge(value: unknown, isEn: boolean) {
const minutes = Number(value);
if (!Number.isFinite(minutes) || minutes < 0) return "";
if (minutes < 1) return isEn ? "just now" : "刚刚";
if (minutes < 60) {
const rounded = Math.max(1, Math.round(minutes));
return isEn ? `${rounded}m ago` : `${rounded} 分钟前`;
}
const hours = Math.floor(minutes / 60);
const remaining = Math.round(minutes % 60);
if (remaining <= 0) return isEn ? `${hours}h ago` : `${hours} 小时前`;
return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`;
}
function buildObservationFreshnessLabel({
detail,
displayTime,
isEn,
isHkoObservation,
}: {
detail: CityDetail | null;
displayTime: string;
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} 过旧`;
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} 时间待确认`;
}
function uniqueStatusTags(tags: Array<StatusTag | null | undefined>) {
const seen = new Set<string>();
return tags.filter((tag): tag is StatusTag => {
if (!tag?.label || seen.has(tag.label)) return false;
seen.add(tag.label);
return true;
});
}
function AiPinnedCityCard({
item,
detail,
@@ -274,6 +331,122 @@ function AiPinnedCityCard({
marketStatus,
tempSymbol,
});
const aiMeta = aiCityForecast?._polyweather_meta || null;
const guardReason = aiMeta?.deterministic_guard_reason || {};
const observationStale = Boolean(
detail?.metar_status?.stale_for_today ||
detail?.airport_current?.stale_for_today ||
detail?.current?.observation_status === "stale" ||
guardReason.observation_stale,
);
const observedHighBreak = Boolean(
guardReason.observed_high_break ||
(currentTempNumber != null &&
modelMax != null &&
currentTempNumber > modelMax + 0.2),
);
const observedLowBreak = Boolean(guardReason.observed_low_break);
const observedLowLag = Boolean(guardReason.observed_low_lag);
const peakHasPassed = Boolean(
guardReason.peak_has_passed ||
["past", "post_peak", "after_peak"].includes(
String((row as { window_phase?: string | null } | null)?.window_phase || "").toLowerCase(),
),
);
const observationFreshnessLabel = buildObservationFreshnessLabel({
detail,
displayTime: metarReportTimeDisplay,
isEn,
isHkoObservation,
});
const aiStatusLabel =
aiForecast.status === "loading"
? isEn
? "AI reading"
: "AI 解读中"
: aiForecast.status === "ready" && aiCityForecast
? aiForecast.payload?.degraded || aiMeta?.fallback
? isEn
? "Rule fallback"
: "规则兜底"
: isEn
? "AI ready"
: "AI 已完成"
: aiForecast.status === "failed"
? isEn
? "AI failed"
: "AI 失败"
: isEn
? "AI pending"
: "AI 待返回";
const aiStatusTone: StatusTagTone =
aiForecast.status === "loading"
? "blue"
: aiForecast.status === "ready" && aiCityForecast
? aiForecast.payload?.degraded || aiMeta?.fallback
? "amber"
: "green"
: aiForecast.status === "failed"
? "red"
: "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 statusTags = uniqueStatusTags([
observedHighBreak
? {
label: isEn ? "Observed above models" : "实测突破模型",
tone: "red",
}
: null,
observedLowBreak
? {
label: isEn ? "Peak revised down" : "峰值下修",
tone: "blue",
}
: null,
observedLowLag
? {
label: isEn ? "Warm-up needs proof" : "等待升温确认",
tone: "amber",
}
: null,
observationStale
? {
label: isEn ? "Stale observation" : "观测过旧",
tone: "amber",
}
: null,
peakHasPassed
? {
label: isEn ? "Peak window passed" : "峰值窗口已过",
tone: "muted",
}
: null,
{
label: aiStatusLabel,
tone: aiStatusTone,
},
{
label: marketFreshnessLabel,
tone: marketStatusTone,
},
]).slice(0, 6);
const localizedRisksRaw =
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
const localizedRisks = Array.isArray(localizedRisksRaw)
@@ -313,6 +486,21 @@ function AiPinnedCityCard({
<span>{isEn ? "Model" : "模型"} {modelRange}</span>
<span>{isEn ? "Peak" : "峰值"} {peakWindow}</span>
</div>
<div className="scan-ai-city-status-tags">
{statusTags.map((tag) => (
<span
key={tag.label}
className={clsx("scan-ai-city-status-tag", tag.tone)}
>
{tag.label}
</span>
))}
</div>
<div className="scan-ai-city-freshness">
<span>{observationFreshnessLabel}</span>
<span>{marketFreshnessLabel}</span>
<span>{aiStatusLabel}</span>
</div>
</div>
<div className="scan-ai-city-hero-side">
<span>{isEn ? "Expected high" : "预计最高温"}</span>
@@ -477,20 +665,20 @@ function AiPinnedCityCard({
aiForecast.streamText ||
(isEn
? isHkoObservation
? "DeepSeek is reading the HKO observation and city context..."
: "DeepSeek is reading the airport bulletin and city context..."
? "Fast read is ready; AI is adding HKO observation details..."
: "Fast read is ready; AI is adding airport bulletin details..."
: isHkoObservation
? "DeepSeek 正在统一解读香港天文台观测和城市上下文…"
: "DeepSeek 正在统一解读机场报文和城市上下文…")}
? "快速判断已完成,AI 正在补充香港天文台观测细节…"
: "快速判断已完成,AI 正在补充机场报文细节…")}
</p>
<p className="scan-ai-city-muted">
{isEn
? isHkoObservation
? "One v4-flash stream now drives both the HKO observation read and city judgment."
: "One v4-flash stream now drives both the airport read and city judgment."
? "Rule evidence is shown first; the full HKO AI read will merge automatically."
: "Rule evidence is shown first; the full airport AI read will merge automatically."
: isHkoObservation
? "现在由 v4-flash 一条流同时生成香港天文台观测解读和城市判断。"
: "现在由 v4-flash 一条流同时生成机场报文解读和城市判断。"}
? "先展示规则证据,完整香港天文台 AI 解读返回后会自动合并。"
: "先展示规则证据,完整机场 AI 解读返回后会自动合并。"}
</p>
</>
) : aiForecast.status === "ready" && aiCityForecast ? (
@@ -3,6 +3,14 @@ export type AiPinnedCity = {
displayName?: string | null;
addedAt: number;
};
export type AiCityForecastMeta = {
fallback?: boolean | null;
deterministic_guard_fields?: string[] | null;
deterministic_guard_reason?: Record<string, unknown> | null;
[key: string]: unknown;
};
export type AiCityForecastPayload = {
status?: string | null;
reason?: string | null;
@@ -29,6 +37,7 @@ export type AiCityForecastPayload = {
risks_en?: string[] | null;
model_cluster_note_zh?: string | null;
model_cluster_note_en?: string | null;
_polyweather_meta?: AiCityForecastMeta | null;
} | null;
};
export type AiCityForecastState = {