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:
@@ -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 today’s model stack, HKO observation context and market layer."
|
||||
: "Hydrating today’s 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 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" : "移除",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user