Give mobile city cards their own action layout

Phone users need the city card to answer what matters first instead of inheriting the full desktop analysis hierarchy. This adds a MobileDecisionCard that leads with city, observed temperature, expected high, peak window, one decision reason, status tags, freshness, and a separate market-price row, while keeping AI, model evidence, and the chart behind collapsible sections.

Constraint: Preserve the existing desktop city-card layout and decision state semantics.

Rejected: Continue relying only on CSS hide/show | it keeps mobile coupled to the desktop information architecture.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: TypeScript diagnostics for AiPinnedCityCard and MobileDecisionCard

Tested: npm run build

Not-tested: Device screenshot QA across iOS/Android viewport sizes.
This commit is contained in:
2569718930@qq.com
2026-04-28 13:00:40 +08:00
parent a77d613c2b
commit fbd98dd59c
4 changed files with 528 additions and 105 deletions
@@ -3437,6 +3437,122 @@
transform: rotate(-90deg);
}
.root :global(.scan-mobile-decision-card) {
display: grid;
gap: 12px;
padding-bottom: 14px;
}
.root :global(.scan-mobile-decision-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 0;
}
.root :global(.scan-mobile-decision-head h3) {
margin: 6px 0 0;
color: #e6edf3;
font-size: 24px;
line-height: 1.08;
}
.root :global(.scan-mobile-decision-metrics) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 0 14px;
}
.root :global(.scan-mobile-decision-metrics span) {
display: grid;
gap: 4px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(77, 163, 255, 0.09);
padding: 9px;
}
.root :global(.scan-mobile-decision-metrics small) {
color: #9fb2c7;
font-size: 10px;
font-weight: 900;
}
.root :global(.scan-mobile-decision-metrics b) {
color: #f3f8ff;
font-size: 13px;
line-height: 1.15;
}
.root :global(.scan-mobile-decision-reason) {
margin: 0 14px;
border: 1px solid rgba(77, 163, 255, 0.24);
border-radius: 13px;
background: rgba(77, 163, 255, 0.1);
color: #d8e7f8;
font-size: 14px;
font-weight: 900;
line-height: 1.45;
padding: 11px 12px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-status-tags),
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness),
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line),
.root :global(.scan-mobile-decision-folds) {
margin-right: 14px;
margin-left: 14px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness) {
grid-template-columns: 1fr;
max-width: none;
margin-top: 0;
}
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 0;
}
.root :global(.scan-mobile-decision-card .scan-ai-market-decision) {
display: none;
}
.root :global(.scan-mobile-decision-folds) {
display: grid;
gap: 10px;
}
.root :global(.scan-mobile-fold) {
padding: 13px 14px;
}
.root :global(.scan-mobile-fold summary) {
cursor: pointer;
list-style: none;
margin-bottom: 0;
}
.root :global(.scan-mobile-fold summary::-webkit-details-marker) {
display: none;
}
.root :global(.scan-mobile-fold[open] summary) {
margin-bottom: 10px;
}
.root :global(.scan-mobile-fold .scan-ai-city-section.models) {
border: 0;
background: transparent;
padding: 0;
}
.root :global(.scan-ai-city-collapse svg) {
transition: transform 0.18s ease;
}
@@ -6404,7 +6520,9 @@
.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-freshness span) {
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-freshness span),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics span),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
}
@@ -6442,6 +6560,9 @@
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority b),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics b),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-head h3),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line b) {
color: #0f172a !important;
}
@@ -6449,6 +6570,7 @@
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority small),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics small),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
color: #334155 !important;
}
@@ -1,10 +1,12 @@
"use client";
import clsx from "clsx";
import type { MouseEvent } from "react";
import { useEffect, useState } from "react";
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand";
import {
@@ -13,6 +15,10 @@ import {
resolveExpectedHighCandidate,
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
import {
getAiReadCopy,
getCityLoadingCopy,
} from "@/components/dashboard/scan-terminal/decision-copy";
import { getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
@@ -430,23 +436,10 @@ export function AiPinnedCityCard({
aiForecast.payload?.degraded ||
aiMeta?.fallback,
);
const aiReadInProgressText = isEn
? isHkoObservation
? "Fast read is ready; AI is adding HKO observation details..."
: "Fast read is ready; AI is adding airport bulletin details..."
: isHkoObservation
? "快速判断已完成,AI 正在补充香港天文台观测细节…"
: "快速判断已完成,AI 正在补充机场报文细节…";
const aiReadCompleteText = isEn
? isHkoObservation
? "AI HKO observation read is complete."
: "AI airport bulletin read is complete."
: isHkoObservation
? "AI 香港天文台观测解读已完成"
: "AI 机场报文解读已完成";
const aiRuleEvidenceText = isEn
? "AI read did not return completely; rule evidence is being used."
: "AI 解读未完整返回,当前使用规则证据";
const aiReadCopy = getAiReadCopy({ isEn, isHkoObservation });
const aiReadInProgressText = aiReadCopy.inProgress;
const aiReadCompleteText = aiReadCopy.complete;
const aiRuleEvidenceText = aiReadCopy.ruleEvidence;
const decisionState = buildCityDecisionState({
aiCityForecast,
aiForecast,
@@ -519,109 +512,142 @@ export function AiPinnedCityCard({
: "暂不可用";
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
const handleRefresh = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (refreshingDetail) return;
setRefreshingDetail(true);
void onRefreshCityDetail(detailCityName)
.catch(() => {})
.finally(() => {
refreshAiForecast();
setRefreshingDetail(false);
});
};
const handleRemove = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onRemove();
};
return (
<article
className={clsx("scan-ai-city-card", collapsed && "collapsed", removing && "removing")}
className={clsx(
"scan-ai-city-card",
isCompactCard && "scan-mobile-decision-card",
collapsed && !isCompactCard && "collapsed",
removing && "removing",
)}
data-ai-status={decisionState.aiStatus}
data-evidence-quality={decisionState.evidenceQuality}
data-market-status={decisionState.marketStatus}
data-recommendation={decisionState.recommendation}
data-urgency={decisionState.urgency}
>
<CityCardHeader
aiStatusLabel={decisionState.aiStatusLabel}
aiStatusTone={decisionState.aiStatusTone}
collapseId={collapseId}
collapsed={collapsed}
currentTempText={currentTempText}
dataFreshnessRows={dataFreshnessRows}
debText={debText}
detailLocalTime={detail?.local_time}
displayName={displayName}
expectedHighText={expectedHighText}
freshnessSeparator={freshnessSeparator}
isEn={isEn}
isRefreshing={isRefreshing}
modelRange={modelRange}
onRefresh={(event) => {
event.preventDefault();
event.stopPropagation();
if (refreshingDetail) return;
setRefreshingDetail(true);
void onRefreshCityDetail(detailCityName)
.catch(() => {})
.finally(() => {
refreshAiForecast();
setRefreshingDetail(false);
});
}}
onRemove={(event) => {
event.preventDefault();
event.stopPropagation();
onRemove();
}}
onToggleCollapsed={onToggleCollapsed}
peakWindow={peakWindow}
removing={removing}
rowLocalTime={row?.local_time}
statusTags={decisionState.badges}
/>
{detail && !collapsed ? (
<div className="scan-ai-city-body" id={collapseId}>
<WeatherDecisionBand
{isCompactCard ? (
<MobileDecisionCard
aiBullets={aiBullets}
aiCityForecast={aiCityForecast}
aiForecast={aiForecast}
aiReadCompleteText={aiReadCompleteText}
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
currentTempText={currentTempText}
dataFreshnessRows={dataFreshnessRows}
decisionState={decisionState}
detail={detail}
displayName={displayName}
expectedHighText={expectedHighText}
fallbackAiReason={fallbackAiReason}
freshnessSeparator={freshnessSeparator}
isEn={isEn}
isHkoObservation={isHkoObservation}
isRefreshing={isRefreshing}
localModelSupportNote={localModelSupportNote}
localizedFinalJudgment={localizedFinalJudgment}
marketDecisionView={marketDecisionView}
marketLineText={marketLineText}
onRefresh={handleRefresh}
onRemove={handleRemove}
peakWindow={peakWindow}
rawObservationText={rawObservationText}
removing={removing}
/>
) : (
<>
<CityCardHeader
aiStatusLabel={decisionState.aiStatusLabel}
aiStatusTone={decisionState.aiStatusTone}
collapseId={collapseId}
collapsed={collapsed}
currentTempText={currentTempText}
decisionView={decisionView}
decisionWhyText={decisionState.primaryReason}
dataFreshnessRows={dataFreshnessRows}
debText={debText}
detailLocalTime={detail?.local_time}
displayName={displayName}
expectedHighText={expectedHighText}
freshnessSeparator={freshnessSeparator}
isEn={isEn}
longText={localizedFinalJudgment || paceText}
marketDecisionView={marketDecisionView}
marketLineText={marketLineText}
paceDeltaText={paceView?.deltaText || "--"}
isRefreshing={isRefreshing}
modelRange={modelRange}
onRefresh={handleRefresh}
onRemove={handleRemove}
onToggleCollapsed={onToggleCollapsed}
peakWindow={peakWindow}
removing={removing}
rowLocalTime={row?.local_time}
statusTags={decisionState.badges}
/>
<div className="scan-ai-city-analysis-grid">
<AiCityTemperatureChart detail={detail} />
<AiEvidencePanel
aiBullets={aiBullets}
aiCityForecast={aiCityForecast}
aiForecast={aiForecast}
aiReadCompleteText={aiReadCompleteText}
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
fallbackAiReason={fallbackAiReason}
isCompactCard={isCompactCard}
isEn={isEn}
isHkoObservation={isHkoObservation}
localModelSupportNote={localModelSupportNote}
localizedFinalJudgment={localizedFinalJudgment}
rawObservationText={rawObservationText}
/>
</div>
{detail && !collapsed ? (
<div className="scan-ai-city-body" id={collapseId}>
<WeatherDecisionBand
currentTempText={currentTempText}
decisionView={decisionView}
decisionWhyText={decisionState.primaryReason}
isEn={isEn}
longText={localizedFinalJudgment || paceText}
marketDecisionView={marketDecisionView}
marketLineText={marketLineText}
paceDeltaText={paceView?.deltaText || "--"}
peakWindow={peakWindow}
/>
<ModelEvidencePanel detail={detail} isEn={isEn} />
<div className="scan-ai-city-analysis-grid">
<AiCityTemperatureChart detail={detail} />
<AiEvidencePanel
aiBullets={aiBullets}
aiCityForecast={aiCityForecast}
aiForecast={aiForecast}
aiReadCompleteText={aiReadCompleteText}
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
fallbackAiReason={fallbackAiReason}
isCompactCard={isCompactCard}
isEn={isEn}
isHkoObservation={isHkoObservation}
localModelSupportNote={localModelSupportNote}
localizedFinalJudgment={localizedFinalJudgment}
rawObservationText={rawObservationText}
/>
</div>
</div>
) : !detail ? (
<div className="scan-ai-city-loading">
<LoadingSignal
title={isEn ? "Loading city decision data" : "正在加载城市决策数据"}
description={
isEn
? isHkoObservation
? "Hydrating todays model stack, HKO observation context and market layer."
: "Hydrating todays model stack, METAR context and market layer."
: isHkoObservation
? "正在补全今日模型、香港天文台观测和市场价格层。"
: "正在补全今日模型、机场报文和市场价格层。"
}
compact
/>
</div>
) : null}
<ModelEvidencePanel detail={detail} isEn={isEn} />
</div>
) : !detail ? (
<div className="scan-ai-city-loading">
<LoadingSignal
title={loadingCopy.title}
description={loadingCopy.description}
compact
/>
</div>
) : null}
</>
)}
</article>
);
}
@@ -0,0 +1,208 @@
"use client";
import { RefreshCw, X } from "lucide-react";
import type { MouseEvent } from "react";
import { useState } from "react";
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
import {
CityStatusTags,
type CityStatusTag,
type StatusTone,
} from "@/components/dashboard/scan-terminal/CityStatusTags";
import {
DataFreshnessBar,
type DataFreshnessRow,
} from "@/components/dashboard/scan-terminal/DataFreshnessBar";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import { MarketDecisionLine } from "@/components/dashboard/scan-terminal/MarketDecisionLine";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import type { CityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
import {
getCityLoadingCopy,
getMobileDecisionCopy,
} from "@/components/dashboard/scan-terminal/decision-copy";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
import type { CityDetail } from "@/lib/dashboard-types";
export function MobileDecisionCard({
aiBullets,
aiCityForecast,
aiForecast,
aiReadCompleteText,
aiReadInProgressText,
aiRuleEvidenceMode,
aiRuleEvidenceText,
currentTempText,
dataFreshnessRows,
decisionState,
detail,
displayName,
expectedHighText,
fallbackAiReason,
freshnessSeparator,
isEn,
isHkoObservation,
isRefreshing,
localModelSupportNote,
localizedFinalJudgment,
marketDecisionView,
marketLineText,
onRefresh,
onRemove,
peakWindow,
rawObservationText,
removing,
}: {
aiBullets: string[];
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
aiForecast: AiCityForecastState;
aiReadCompleteText: string;
aiReadInProgressText: string;
aiRuleEvidenceMode: boolean;
aiRuleEvidenceText: string;
currentTempText: string;
dataFreshnessRows: DataFreshnessRow[];
decisionState: CityDecisionState;
detail: CityDetail | null;
displayName: string;
expectedHighText: string;
fallbackAiReason: string;
freshnessSeparator: string;
isEn: boolean;
isHkoObservation: boolean;
isRefreshing: boolean;
localModelSupportNote: string;
localizedFinalJudgment: string;
marketDecisionView: MarketDecisionView;
marketLineText: string;
onRefresh: (event: MouseEvent<HTMLButtonElement>) => void;
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
peakWindow: string;
rawObservationText: string;
removing?: boolean;
}) {
const copy = getMobileDecisionCopy(isEn);
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
const [modelOpen, setModelOpen] = useState(false);
const [chartOpen, setChartOpen] = useState(false);
const statusTags: CityStatusTag[] = decisionState.badges.length
? decisionState.badges
: [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }];
return (
<>
<header className="scan-mobile-decision-head">
<div>
<span className="scan-ai-city-kicker">
{isEn ? "Mobile action card" : "移动端行动卡"}
</span>
<h3>{displayName}</h3>
</div>
<div className="scan-ai-city-actions">
<button
type="button"
className="scan-ai-city-icon-button"
onClick={onRefresh}
aria-label={`${copy.refresh} ${displayName}`}
title={copy.refresh}
disabled={isRefreshing}
>
<RefreshCw size={15} className={isRefreshing ? "spin" : undefined} />
</button>
<button
type="button"
className="scan-ai-city-icon-button danger"
onClick={onRemove}
aria-label={`${copy.remove} ${displayName}`}
title={copy.remove}
disabled={removing}
>
<X size={15} />
</button>
</div>
</header>
<div className="scan-mobile-decision-metrics">
<span>
<small>{copy.currentTemp}</small>
<b>{currentTempText}</b>
</span>
<span>
<small>{copy.expectedHigh}</small>
<b>{expectedHighText}</b>
</span>
<span>
<small>{copy.peakWindow}</small>
<b>{peakWindow}</b>
</span>
</div>
<p className="scan-mobile-decision-reason">{decisionState.primaryReason}</p>
<CityStatusTags tags={statusTags} />
<DataFreshnessBar
aiStatusLabel={decisionState.aiStatusLabel}
aiStatusTone={decisionState.aiStatusTone}
freshnessSeparator={freshnessSeparator}
isEn={isEn}
rows={dataFreshnessRows}
/>
<MarketDecisionLine
isEn={isEn}
marketDecisionView={marketDecisionView}
marketLineText={marketLineText}
/>
{!detail ? (
<div className="scan-ai-city-loading">
<LoadingSignal
title={loadingCopy.title}
description={loadingCopy.description}
compact
/>
</div>
) : (
<div className="scan-mobile-decision-folds">
<AiEvidencePanel
aiBullets={aiBullets}
aiCityForecast={aiCityForecast}
aiForecast={aiForecast}
aiReadCompleteText={aiReadCompleteText}
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
fallbackAiReason={fallbackAiReason}
isCompactCard
isEn={isEn}
isHkoObservation={isHkoObservation}
localModelSupportNote={localModelSupportNote}
localizedFinalJudgment={localizedFinalJudgment}
rawObservationText={rawObservationText}
/>
<details
className="scan-ai-city-section scan-mobile-fold"
open={modelOpen}
onToggle={(event) => setModelOpen(event.currentTarget.open)}
>
<summary className="scan-ai-city-section-title">{copy.modelEvidence}</summary>
{modelOpen ? <ModelEvidencePanel detail={detail} isEn={isEn} /> : null}
</details>
<details
className="scan-ai-city-section scan-mobile-fold"
open={chartOpen}
onToggle={(event) => setChartOpen(event.currentTarget.open)}
>
<summary className="scan-ai-city-section-title">{copy.chart}</summary>
{chartOpen ? <AiCityTemperatureChart detail={detail} /> : null}
</details>
</div>
)}
</>
);
}
@@ -0,0 +1,67 @@
export type DecisionCopyLocale = "zh-CN" | "en-US";
function isEnglishLocale(localeOrIsEn: DecisionCopyLocale | string | boolean) {
return localeOrIsEn === true || localeOrIsEn === "en-US";
}
export function getAiReadCopy({
isEn,
isHkoObservation,
}: {
isEn: boolean;
isHkoObservation: boolean;
}) {
return {
complete: isEn
? isHkoObservation
? "AI HKO observation read is complete."
: "AI airport bulletin read is complete."
: isHkoObservation
? "AI 香港天文台观测解读已完成"
: "AI 机场报文解读已完成",
inProgress: isEn
? isHkoObservation
? "Fast read is ready; AI is adding HKO observation details..."
: "Fast read is ready; AI is adding airport bulletin details..."
: isHkoObservation
? "快速判断已完成,AI 正在补充香港天文台观测细节…"
: "快速判断已完成,AI 正在补充机场报文细节…",
ruleEvidence: isEn
? "AI read did not return completely; rule evidence is being used."
: "AI 解读未完整返回,当前使用规则证据",
};
}
export function getCityLoadingCopy({
isEn,
isHkoObservation,
}: {
isEn: boolean;
isHkoObservation: boolean;
}) {
return {
description: isEn
? isHkoObservation
? "Hydrating todays model stack, HKO observation context and market layer."
: "Hydrating todays model stack, METAR context and market layer."
: isHkoObservation
? "正在补全今日模型、香港天文台观测和市场价格层。"
: "正在补全今日模型、机场报文和市场价格层。",
title: isEn ? "Loading city decision data" : "正在加载城市决策数据",
};
}
export function getMobileDecisionCopy(localeOrIsEn: DecisionCopyLocale | string | boolean) {
const isEn = isEnglishLocale(localeOrIsEn);
return {
aiDetails: isEn ? "AI read" : "AI 解读",
chart: isEn ? "Light trend chart" : "轻量走势图",
currentTemp: isEn ? "Observed" : "当前温度",
expectedHigh: isEn ? "Expected high" : "预测高点",
marketPrice: isEn ? "Market price" : "市场价格",
modelEvidence: isEn ? "Model evidence" : "模型证据",
peakWindow: isEn ? "Peak window" : "峰值窗口",
refresh: isEn ? "Refresh" : "刷新",
remove: isEn ? "Remove" : "移除",
};
}