Surface city-card decision reasons before the paragraph

City decision cards already had enough evidence, but the first screen still forced users to read the longer explanation before seeing why a card mattered. The header now caps status chips at three high-priority signals and uses product-facing labels for observed breakouts, stale METARs, AI loading, missing market prices, strong model agreement, and next-report waits.

Constraint: The card should stay lightweight and avoid another explanatory section.
Rejected: Keep six mixed freshness chips in the header | too much noise for first-glance scanning.
Confidence: high
Scope-risk: narrow
Reversibility: clean
Tested: npm run build
Not-tested: Browser visual QA across all city states.
This commit is contained in:
2569718930@qq.com
2026-04-28 07:17:24 +08:00
parent 89a71d1bc0
commit 30b1f5e256
3 changed files with 65 additions and 39 deletions
@@ -11334,7 +11334,7 @@
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 10px;
margin: -2px 0 10px;
}
.root :global(.scan-ai-city-status-tag) {
@@ -353,6 +353,15 @@ function AiPinnedCityCard({
String((row as { window_phase?: string | null } | null)?.window_phase || "").toLowerCase(),
),
);
const modelSpread = modelMax != null && modelMin != null ? modelMax - modelMin : null;
const modelHighlyConsistent =
modelEntries.length >= 4 && modelSpread != null && modelSpread <= 2;
const needsNextBulletin =
!observationStale &&
!observedHighBreak &&
!observedLowBreak &&
!peakHasPassed &&
(observedLowLag || paceTone === "neutral" || aiForecast.status === "loading");
const observationFreshnessLabel = buildObservationFreshnessLabel({
detail,
displayTime: metarReportTimeDisplay,
@@ -410,43 +419,59 @@ function AiPinnedCityCard({
const statusTags = uniqueStatusTags([
observedHighBreak
? {
label: isEn ? "Observed above models" : "实测突破模型",
label: isEn ? "Observed breakout" : "实测突破",
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);
observationStale
? {
label: isEn
? isHkoObservation
? "HKO stale"
: "METAR stale"
: isHkoObservation
? "观测过旧"
: "METAR 过旧",
tone: "amber",
}
: null,
observedLowBreak
? {
label: isEn ? "Peak revised down" : "峰值下修",
tone: "blue",
}
: null,
aiForecast.status === "loading"
? {
label: isEn ? "AI reading" : "AI 解读中",
tone: aiStatusTone,
}
: null,
marketDecisionView.status === "unavailable"
? {
label: isEn ? "Market missing" : "市场价格缺失",
tone: marketStatusTone,
}
: null,
modelHighlyConsistent
? {
label: isEn ? "Models agree" : "模型高度一致",
tone: "green",
}
: null,
observedLowLag || needsNextBulletin
? {
label: isEn ? "Wait next report" : "需要等待下一报文",
tone: "amber",
}
: null,
]).slice(0, 3);
const localizedRisksRaw =
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
const localizedRisks = Array.isArray(localizedRisksRaw)
@@ -475,6 +500,16 @@ function AiPinnedCityCard({
{isEn ? "Deep analysis" : "城市深度分析"}
</span>
<h3>{displayName}</h3>
<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-pills">
<span>{detail?.local_time || row?.local_time || "--"}</span>
<span>
@@ -486,16 +521,6 @@ 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>