From fbd98dd59c3604250d36d0e00113138374893f42 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 28 Apr 2026 13:00:40 +0800 Subject: [PATCH] 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. --- .../dashboard/ScanTerminal.module.css | 124 +++++++++- .../scan-terminal/AiPinnedCityCard.tsx | 234 ++++++++++-------- .../scan-terminal/MobileDecisionCard.tsx | 208 ++++++++++++++++ .../dashboard/scan-terminal/decision-copy.ts | 67 +++++ 4 files changed, 528 insertions(+), 105 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx create mode 100644 frontend/components/dashboard/scan-terminal/decision-copy.ts diff --git a/frontend/components/dashboard/ScanTerminal.module.css b/frontend/components/dashboard/ScanTerminal.module.css index 92993633..483fd743 100644 --- a/frontend/components/dashboard/ScanTerminal.module.css +++ b/frontend/components/dashboard/ScanTerminal.module.css @@ -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; } diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx index 1d8f9652..43d117d9 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx @@ -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) => { + event.preventDefault(); + event.stopPropagation(); + if (refreshingDetail) return; + setRefreshingDetail(true); + void onRefreshCityDetail(detailCityName) + .catch(() => {}) + .finally(() => { + refreshAiForecast(); + setRefreshingDetail(false); + }); + }; + const handleRemove = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onRemove(); + }; return (
- { - 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 ? ( -
- + ) : ( + <> + -
- - -
+ {detail && !collapsed ? ( +
+ - +
+ + +
-
- ) : !detail ? ( -
- -
- ) : null} + +
+ ) : !detail ? ( +
+ +
+ ) : null} + + )}
); } diff --git a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx new file mode 100644 index 00000000..617da6f2 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx @@ -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) => void; + onRemove: (event: MouseEvent) => 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 ( + <> +
+
+ + {isEn ? "Mobile action card" : "移动端行动卡"} + +

{displayName}

+
+
+ + +
+
+ +
+ + {copy.currentTemp} + {currentTempText} + + + {copy.expectedHigh} + {expectedHighText} + + + {copy.peakWindow} + {peakWindow} + +
+ +

{decisionState.primaryReason}

+ + + + + {!detail ? ( +
+ +
+ ) : ( +
+ + +
setModelOpen(event.currentTarget.open)} + > + {copy.modelEvidence} + {modelOpen ? : null} +
+ +
setChartOpen(event.currentTarget.open)} + > + {copy.chart} + {chartOpen ? : null} +
+
+ )} + + ); +} diff --git a/frontend/components/dashboard/scan-terminal/decision-copy.ts b/frontend/components/dashboard/scan-terminal/decision-copy.ts new file mode 100644 index 00000000..525ed24a --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/decision-copy.ts @@ -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 today’s model stack, HKO observation context and market layer." + : "Hydrating today’s 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" : "移除", + }; +}