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);
|
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) {
|
.root :global(.scan-ai-city-collapse svg) {
|
||||||
transition: transform 0.18s ease;
|
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-market-decision),
|
||||||
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-metrics span),
|
.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) {
|
.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;
|
background: #eef7ff !important;
|
||||||
border-color: rgba(37, 99, 235, 0.16) !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-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-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) {
|
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line b) {
|
||||||
color: #0f172a !important;
|
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-copy p),
|
||||||
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li),
|
.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-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) {
|
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
|
||||||
color: #334155 !important;
|
color: #334155 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import type { MouseEvent } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
|
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
|
||||||
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
|
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
|
||||||
import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
|
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 { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
||||||
import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand";
|
import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +15,10 @@ import {
|
|||||||
resolveExpectedHighCandidate,
|
resolveExpectedHighCandidate,
|
||||||
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
||||||
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
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 { getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
||||||
@@ -430,23 +436,10 @@ export function AiPinnedCityCard({
|
|||||||
aiForecast.payload?.degraded ||
|
aiForecast.payload?.degraded ||
|
||||||
aiMeta?.fallback,
|
aiMeta?.fallback,
|
||||||
);
|
);
|
||||||
const aiReadInProgressText = isEn
|
const aiReadCopy = getAiReadCopy({ isEn, isHkoObservation });
|
||||||
? isHkoObservation
|
const aiReadInProgressText = aiReadCopy.inProgress;
|
||||||
? "Fast read is ready; AI is adding HKO observation details..."
|
const aiReadCompleteText = aiReadCopy.complete;
|
||||||
: "Fast read is ready; AI is adding airport bulletin details..."
|
const aiRuleEvidenceText = aiReadCopy.ruleEvidence;
|
||||||
: 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 decisionState = buildCityDecisionState({
|
const decisionState = buildCityDecisionState({
|
||||||
aiCityForecast,
|
aiCityForecast,
|
||||||
aiForecast,
|
aiForecast,
|
||||||
@@ -519,109 +512,142 @@ export function AiPinnedCityCard({
|
|||||||
: "暂不可用";
|
: "暂不可用";
|
||||||
|
|
||||||
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
|
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 (
|
return (
|
||||||
<article
|
<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-ai-status={decisionState.aiStatus}
|
||||||
data-evidence-quality={decisionState.evidenceQuality}
|
data-evidence-quality={decisionState.evidenceQuality}
|
||||||
data-market-status={decisionState.marketStatus}
|
data-market-status={decisionState.marketStatus}
|
||||||
data-recommendation={decisionState.recommendation}
|
data-recommendation={decisionState.recommendation}
|
||||||
data-urgency={decisionState.urgency}
|
data-urgency={decisionState.urgency}
|
||||||
>
|
>
|
||||||
<CityCardHeader
|
{isCompactCard ? (
|
||||||
aiStatusLabel={decisionState.aiStatusLabel}
|
<MobileDecisionCard
|
||||||
aiStatusTone={decisionState.aiStatusTone}
|
aiBullets={aiBullets}
|
||||||
collapseId={collapseId}
|
aiCityForecast={aiCityForecast}
|
||||||
collapsed={collapsed}
|
aiForecast={aiForecast}
|
||||||
currentTempText={currentTempText}
|
aiReadCompleteText={aiReadCompleteText}
|
||||||
dataFreshnessRows={dataFreshnessRows}
|
aiReadInProgressText={aiReadInProgressText}
|
||||||
debText={debText}
|
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
||||||
detailLocalTime={detail?.local_time}
|
aiRuleEvidenceText={aiRuleEvidenceText}
|
||||||
displayName={displayName}
|
currentTempText={currentTempText}
|
||||||
expectedHighText={expectedHighText}
|
dataFreshnessRows={dataFreshnessRows}
|
||||||
freshnessSeparator={freshnessSeparator}
|
decisionState={decisionState}
|
||||||
isEn={isEn}
|
detail={detail}
|
||||||
isRefreshing={isRefreshing}
|
displayName={displayName}
|
||||||
modelRange={modelRange}
|
expectedHighText={expectedHighText}
|
||||||
onRefresh={(event) => {
|
fallbackAiReason={fallbackAiReason}
|
||||||
event.preventDefault();
|
freshnessSeparator={freshnessSeparator}
|
||||||
event.stopPropagation();
|
isEn={isEn}
|
||||||
if (refreshingDetail) return;
|
isHkoObservation={isHkoObservation}
|
||||||
setRefreshingDetail(true);
|
isRefreshing={isRefreshing}
|
||||||
void onRefreshCityDetail(detailCityName)
|
localModelSupportNote={localModelSupportNote}
|
||||||
.catch(() => {})
|
localizedFinalJudgment={localizedFinalJudgment}
|
||||||
.finally(() => {
|
marketDecisionView={marketDecisionView}
|
||||||
refreshAiForecast();
|
marketLineText={marketLineText}
|
||||||
setRefreshingDetail(false);
|
onRefresh={handleRefresh}
|
||||||
});
|
onRemove={handleRemove}
|
||||||
}}
|
peakWindow={peakWindow}
|
||||||
onRemove={(event) => {
|
rawObservationText={rawObservationText}
|
||||||
event.preventDefault();
|
removing={removing}
|
||||||
event.stopPropagation();
|
/>
|
||||||
onRemove();
|
) : (
|
||||||
}}
|
<>
|
||||||
onToggleCollapsed={onToggleCollapsed}
|
<CityCardHeader
|
||||||
peakWindow={peakWindow}
|
aiStatusLabel={decisionState.aiStatusLabel}
|
||||||
removing={removing}
|
aiStatusTone={decisionState.aiStatusTone}
|
||||||
rowLocalTime={row?.local_time}
|
collapseId={collapseId}
|
||||||
statusTags={decisionState.badges}
|
collapsed={collapsed}
|
||||||
/>
|
|
||||||
|
|
||||||
{detail && !collapsed ? (
|
|
||||||
<div className="scan-ai-city-body" id={collapseId}>
|
|
||||||
<WeatherDecisionBand
|
|
||||||
currentTempText={currentTempText}
|
currentTempText={currentTempText}
|
||||||
decisionView={decisionView}
|
dataFreshnessRows={dataFreshnessRows}
|
||||||
decisionWhyText={decisionState.primaryReason}
|
debText={debText}
|
||||||
|
detailLocalTime={detail?.local_time}
|
||||||
|
displayName={displayName}
|
||||||
|
expectedHighText={expectedHighText}
|
||||||
|
freshnessSeparator={freshnessSeparator}
|
||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
longText={localizedFinalJudgment || paceText}
|
isRefreshing={isRefreshing}
|
||||||
marketDecisionView={marketDecisionView}
|
modelRange={modelRange}
|
||||||
marketLineText={marketLineText}
|
onRefresh={handleRefresh}
|
||||||
paceDeltaText={paceView?.deltaText || "--"}
|
onRemove={handleRemove}
|
||||||
|
onToggleCollapsed={onToggleCollapsed}
|
||||||
peakWindow={peakWindow}
|
peakWindow={peakWindow}
|
||||||
|
removing={removing}
|
||||||
|
rowLocalTime={row?.local_time}
|
||||||
|
statusTags={decisionState.badges}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="scan-ai-city-analysis-grid">
|
{detail && !collapsed ? (
|
||||||
<AiCityTemperatureChart detail={detail} />
|
<div className="scan-ai-city-body" id={collapseId}>
|
||||||
<AiEvidencePanel
|
<WeatherDecisionBand
|
||||||
aiBullets={aiBullets}
|
currentTempText={currentTempText}
|
||||||
aiCityForecast={aiCityForecast}
|
decisionView={decisionView}
|
||||||
aiForecast={aiForecast}
|
decisionWhyText={decisionState.primaryReason}
|
||||||
aiReadCompleteText={aiReadCompleteText}
|
isEn={isEn}
|
||||||
aiReadInProgressText={aiReadInProgressText}
|
longText={localizedFinalJudgment || paceText}
|
||||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
marketDecisionView={marketDecisionView}
|
||||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
marketLineText={marketLineText}
|
||||||
fallbackAiReason={fallbackAiReason}
|
paceDeltaText={paceView?.deltaText || "--"}
|
||||||
isCompactCard={isCompactCard}
|
peakWindow={peakWindow}
|
||||||
isEn={isEn}
|
/>
|
||||||
isHkoObservation={isHkoObservation}
|
|
||||||
localModelSupportNote={localModelSupportNote}
|
|
||||||
localizedFinalJudgment={localizedFinalJudgment}
|
|
||||||
rawObservationText={rawObservationText}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
<ModelEvidencePanel detail={detail} isEn={isEn} />
|
||||||
) : !detail ? (
|
</div>
|
||||||
<div className="scan-ai-city-loading">
|
) : !detail ? (
|
||||||
<LoadingSignal
|
<div className="scan-ai-city-loading">
|
||||||
title={isEn ? "Loading city decision data" : "正在加载城市决策数据"}
|
<LoadingSignal
|
||||||
description={
|
title={loadingCopy.title}
|
||||||
isEn
|
description={loadingCopy.description}
|
||||||
? isHkoObservation
|
compact
|
||||||
? "Hydrating today’s model stack, HKO observation context and market layer."
|
/>
|
||||||
: "Hydrating today’s model stack, METAR context and market layer."
|
</div>
|
||||||
: isHkoObservation
|
) : null}
|
||||||
? "正在补全今日模型、香港天文台观测和市场价格层。"
|
</>
|
||||||
: "正在补全今日模型、机场报文和市场价格层。"
|
)}
|
||||||
}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</article>
|
</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