feat: implement mobile scan terminal dashboard and city picker with backend forum automation scripts
This commit is contained in:
@@ -44,7 +44,6 @@ export function AiEvidencePanel({
|
||||
isHkoObservation,
|
||||
localModelSupportNote,
|
||||
localizedFinalJudgment,
|
||||
rawObservationText,
|
||||
tempSymbol,
|
||||
}: {
|
||||
aiBullets: string[];
|
||||
@@ -64,7 +63,6 @@ export function AiEvidencePanel({
|
||||
isHkoObservation: boolean;
|
||||
localModelSupportNote: string;
|
||||
localizedFinalJudgment: string;
|
||||
rawObservationText: string;
|
||||
tempSymbol: string;
|
||||
}) {
|
||||
const aiConfidenceMeta = confidenceBadge(aiConfidence, isEn);
|
||||
@@ -173,7 +171,6 @@ export function AiEvidencePanel({
|
||||
<li key={`${line}-${index}`}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="scan-ai-raw-metar">{rawObservationText}</p>
|
||||
</>
|
||||
) : aiForecast.status === "ready" ? (
|
||||
<>
|
||||
@@ -181,7 +178,6 @@ export function AiEvidencePanel({
|
||||
<ul className="scan-ai-weather-bullets">
|
||||
{fallbackAiReason ? <li>{fallbackAiReason}</li> : null}
|
||||
<li>{localModelSupportNote}</li>
|
||||
<li>{rawObservationText}</li>
|
||||
</ul>
|
||||
</>
|
||||
) : aiForecast.status === "failed" ? (
|
||||
@@ -190,7 +186,6 @@ export function AiEvidencePanel({
|
||||
<ul className="scan-ai-weather-bullets">
|
||||
{aiForecast.error ? <li>{aiForecast.error}</li> : null}
|
||||
<li>{localModelSupportNote}</li>
|
||||
<li>{rawObservationText}</li>
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
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 { AirportEvidencePanel } from "@/components/dashboard/scan-terminal/AirportEvidencePanel";
|
||||
import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
|
||||
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
|
||||
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
||||
@@ -267,27 +265,8 @@ export function AiPinnedCityCard({
|
||||
const report = isHkoObservation
|
||||
? ""
|
||||
: detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
|
||||
const metarReportTimeDisplay = formatMetarReportTime(detail, report, isEn);
|
||||
const observationStation = isHkoObservation
|
||||
? detail?.current?.station_name ||
|
||||
detail?.current?.station_code ||
|
||||
detail?.settlement_station?.settlement_station_label ||
|
||||
detail?.settlement_station?.settlement_station_code ||
|
||||
"香港天文台"
|
||||
: detail?.risk?.icao ||
|
||||
detail?.current?.station_code ||
|
||||
detail?.airport_current?.station_code ||
|
||||
displayAirportPrimary?.station_code ||
|
||||
"";
|
||||
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测";
|
||||
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations";
|
||||
const rawObservationText = isHkoObservation
|
||||
? `${isEn ? "Observation source" : "观测来源"}:${observationStation || (isEn ? "Hong Kong Observatory" : "香港天文台")}${metarReportTimeDisplay ? `,${metarReportTimeDisplay}` : ""}`
|
||||
: report
|
||||
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${observationStation} ${report}`.trim()}`
|
||||
: isEn
|
||||
? "Raw METAR: unavailable."
|
||||
: "原始 METAR:暂无。";
|
||||
const detailCityName = detail?.name || item.cityName;
|
||||
const [refreshingDetail, setRefreshingDetail] = useState(false);
|
||||
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
||||
@@ -316,31 +295,16 @@ export function AiPinnedCityCard({
|
||||
}, []);
|
||||
|
||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||
const localizedFinalJudgmentRaw =
|
||||
const localizedFinalJudgment =
|
||||
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
|
||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
||||
"";
|
||||
const localizedMetarReadRaw =
|
||||
const localizedMetarRead =
|
||||
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
|
||||
"";
|
||||
const localizedReasoningRaw =
|
||||
const localizedReasoning =
|
||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
||||
"";
|
||||
const localizedFinalJudgment = normalizeMetarReadTime(
|
||||
localizedFinalJudgmentRaw,
|
||||
metarReportTimeDisplay,
|
||||
isEn,
|
||||
);
|
||||
const localizedMetarRead = normalizeMetarReadTime(
|
||||
localizedMetarReadRaw,
|
||||
metarReportTimeDisplay,
|
||||
isEn,
|
||||
);
|
||||
const localizedReasoning = normalizeMetarReadTime(
|
||||
localizedReasoningRaw,
|
||||
metarReportTimeDisplay,
|
||||
isEn,
|
||||
);
|
||||
const localizedModelNote =
|
||||
(isEn
|
||||
? aiCityForecast?.model_cluster_note_en
|
||||
@@ -390,11 +354,6 @@ export function AiPinnedCityCard({
|
||||
decisionExpectedHighNumber != null
|
||||
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
|
||||
: "--";
|
||||
const observedLabel = undefined;
|
||||
const currentTempText =
|
||||
currentTempNumber != null
|
||||
? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 })
|
||||
: "--";
|
||||
const debText =
|
||||
debNumber != null
|
||||
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
|
||||
@@ -463,33 +422,20 @@ export function AiPinnedCityCard({
|
||||
peakHasPassed,
|
||||
});
|
||||
const dataFreshnessRows = [
|
||||
{
|
||||
label: isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR",
|
||||
labelTitle: isHkoObservation
|
||||
? (isEn ? "Hong Kong Observatory official readings" : "香港天文台官方实测")
|
||||
: (isEn ? "Meteorological Aerodrome Report — airport weather observation" : "机场气象观测报文"),
|
||||
value: buildObservationFreshnessValue({
|
||||
detail,
|
||||
displayTime: metarReportTimeDisplay,
|
||||
isEn,
|
||||
isHkoObservation,
|
||||
}),
|
||||
tone: observationStale ? "stale" : "fresh",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Models" : "模型",
|
||||
value: buildModelFreshnessValue(detail, locale, isEn),
|
||||
tone: "fresh",
|
||||
tone: "fresh" as const,
|
||||
},
|
||||
{
|
||||
label: isEn ? "Market" : "市场价格",
|
||||
value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }),
|
||||
tone:
|
||||
marketDecisionView.status === "ready"
|
||||
? "fresh"
|
||||
? ("fresh" as const)
|
||||
: marketDecisionView.status === "loading"
|
||||
? "loading"
|
||||
: "stale",
|
||||
? ("loading" as const)
|
||||
: ("stale" as const),
|
||||
},
|
||||
];
|
||||
const freshnessSeparator = isEn ? ": " : ":";
|
||||
@@ -569,15 +515,12 @@ export function AiPinnedCityCard({
|
||||
aiReadInProgressText={aiReadInProgressText}
|
||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
||||
currentTempText={currentTempText}
|
||||
dataFreshnessRows={dataFreshnessRows}
|
||||
debPrediction={debNumber}
|
||||
decisionState={decisionState}
|
||||
detail={detail}
|
||||
displayName={displayName}
|
||||
expectedHighText={expectedHighText}
|
||||
fallbackAiReason={fallbackAiReason}
|
||||
freshnessSeparator={freshnessSeparator}
|
||||
isEn={isEn}
|
||||
isHkoObservation={isHkoObservation}
|
||||
isRefreshing={isRefreshing}
|
||||
@@ -588,7 +531,6 @@ export function AiPinnedCityCard({
|
||||
onRefresh={handleRefresh}
|
||||
onRemove={handleRemove}
|
||||
peakWindow={peakWindow}
|
||||
rawObservationText={rawObservationText}
|
||||
removing={removing}
|
||||
tempSymbol={tempSymbol}
|
||||
/>
|
||||
@@ -599,14 +541,10 @@ export function AiPinnedCityCard({
|
||||
aiStatusTone={decisionState.aiStatusTone}
|
||||
collapseId={collapseId}
|
||||
collapsed={collapsed}
|
||||
currentTempText={currentTempText}
|
||||
observedLabel={observedLabel}
|
||||
dataFreshnessRows={dataFreshnessRows}
|
||||
debText={debText}
|
||||
detailLocalTime={detail?.local_time}
|
||||
displayName={displayName}
|
||||
expectedHighText={expectedHighText}
|
||||
freshnessSeparator={freshnessSeparator}
|
||||
isEn={isEn}
|
||||
isRefreshing={isRefreshing}
|
||||
modelRange={modelRange}
|
||||
@@ -631,7 +569,6 @@ export function AiPinnedCityCard({
|
||||
/>
|
||||
|
||||
<div className="scan-ai-city-analysis-grid">
|
||||
<AiCityTemperatureChart detail={detail} />
|
||||
<AiEvidencePanel
|
||||
aiBullets={aiBullets}
|
||||
aiCityForecast={aiCityForecast}
|
||||
@@ -650,12 +587,10 @@ export function AiPinnedCityCard({
|
||||
isHkoObservation={isHkoObservation}
|
||||
localModelSupportNote={localModelSupportNote}
|
||||
localizedFinalJudgment={localizedFinalJudgment}
|
||||
rawObservationText={rawObservationText}
|
||||
tempSymbol={tempSymbol}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AirportEvidencePanel detail={detail} isEn={isEn} />
|
||||
<ModelEvidencePanel detail={detail} isEn={isEn} />
|
||||
</div>
|
||||
) : !detail ? (
|
||||
|
||||
@@ -7,24 +7,16 @@ import {
|
||||
type CityStatusTag,
|
||||
type StatusTone,
|
||||
} from "@/components/dashboard/scan-terminal/CityStatusTags";
|
||||
import {
|
||||
DataFreshnessBar,
|
||||
type DataFreshnessRow,
|
||||
} from "@/components/dashboard/scan-terminal/DataFreshnessBar";
|
||||
|
||||
export function CityCardHeader({
|
||||
aiStatusLabel,
|
||||
aiStatusTone,
|
||||
collapseId,
|
||||
collapsed,
|
||||
currentTempText,
|
||||
observedLabel,
|
||||
dataFreshnessRows,
|
||||
debText,
|
||||
detailLocalTime,
|
||||
displayName,
|
||||
expectedHighText,
|
||||
freshnessSeparator,
|
||||
isEn,
|
||||
isRefreshing,
|
||||
modelRange,
|
||||
@@ -40,14 +32,10 @@ export function CityCardHeader({
|
||||
aiStatusTone: StatusTone;
|
||||
collapseId: string;
|
||||
collapsed: boolean;
|
||||
currentTempText: string;
|
||||
observedLabel?: string;
|
||||
dataFreshnessRows: DataFreshnessRow[];
|
||||
debText: string;
|
||||
detailLocalTime?: string | null;
|
||||
displayName: string;
|
||||
expectedHighText: string;
|
||||
freshnessSeparator: string;
|
||||
isEn: boolean;
|
||||
isRefreshing: boolean;
|
||||
modelRange: string;
|
||||
@@ -67,20 +55,9 @@ export function CityCardHeader({
|
||||
</span>
|
||||
<h3>{displayName}</h3>
|
||||
<CityStatusTags tags={statusTags} />
|
||||
<DataFreshnessBar
|
||||
aiStatusLabel={aiStatusLabel}
|
||||
aiStatusTone={aiStatusTone}
|
||||
freshnessSeparator={freshnessSeparator}
|
||||
isEn={isEn}
|
||||
rows={dataFreshnessRows}
|
||||
/>
|
||||
</div>
|
||||
<div className="scan-ai-city-hero-side">
|
||||
<div className="scan-ai-city-metrics">
|
||||
<span>
|
||||
<small>{observedLabel || (isEn ? "Observed" : "当前温度")}</small>
|
||||
<b>{currentTempText}</b>
|
||||
</span>
|
||||
<span className="primary">
|
||||
<small>{isEn ? "Expected high" : "预计最高温"}</small>
|
||||
<b>{expectedHighText}</b>
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { Search, ChevronDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
|
||||
type Locale = "zh-CN" | "en-US";
|
||||
|
||||
type CityRegionGroup = {
|
||||
key: string;
|
||||
label: { en: string; zh: string };
|
||||
cities: string[];
|
||||
};
|
||||
|
||||
const CITY_REGION_GROUPS: CityRegionGroup[] = [
|
||||
{
|
||||
key: "eastAsia",
|
||||
label: { en: "East Asia", zh: "东亚" },
|
||||
cities: [
|
||||
"tokyo", "seoul", "busan", "beijing", "shanghai", "chengdu",
|
||||
"chongqing", "wuhan", "shenzhen", "guangzhou", "qingdao",
|
||||
"hong kong", "lau fau shan", "taipei",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "southeastAsia",
|
||||
label: { en: "Southeast Asia", zh: "东南亚" },
|
||||
cities: ["singapore", "kuala lumpur", "jakarta", "manila"],
|
||||
},
|
||||
{
|
||||
key: "southAsia",
|
||||
label: { en: "South Asia", zh: "南亚" },
|
||||
cities: ["karachi", "lucknow"],
|
||||
},
|
||||
{
|
||||
key: "middleEast",
|
||||
label: { en: "Middle East", zh: "中东" },
|
||||
cities: ["tel aviv", "jeddah", "istanbul", "ankara"],
|
||||
},
|
||||
{
|
||||
key: "europe",
|
||||
label: { en: "Europe", zh: "欧洲" },
|
||||
cities: [
|
||||
"london", "paris", "moscow", "munich", "milan", "warsaw",
|
||||
"helsinki", "amsterdam", "madrid",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "northAmerica",
|
||||
label: { en: "North America", zh: "北美" },
|
||||
cities: [
|
||||
"new york", "los angeles", "san francisco", "denver", "austin",
|
||||
"houston", "chicago", "dallas", "miami", "atlanta", "seattle",
|
||||
"toronto", "mexico city", "panama city",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "southAmerica",
|
||||
label: { en: "South America", zh: "南美" },
|
||||
cities: ["buenos aires", "são paulo"],
|
||||
},
|
||||
{
|
||||
key: "africa",
|
||||
label: { en: "Africa", zh: "非洲" },
|
||||
cities: ["lagos", "cape town"],
|
||||
},
|
||||
{
|
||||
key: "oceania",
|
||||
label: { en: "Oceania", zh: "大洋洲" },
|
||||
cities: ["wellington"],
|
||||
},
|
||||
];
|
||||
|
||||
const CITY_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"tokyo": ["东京", "東京", "RJTT", "tok", "tyo", "haneda", "羽田"],
|
||||
"seoul": ["首尔", "RKSI", "sel", "seo", "incheon", "仁川"],
|
||||
"busan": ["釜山", "RKPK", "pus", "bus", "gimhae", "金海"],
|
||||
"beijing": ["北京", "ZBAA", "pek", "bjs", "bj", "capital", "首都"],
|
||||
"shanghai": ["上海", "ZSPD", "sha", "sh", "pudong", "浦东"],
|
||||
"chengdu": ["成都", "ZUUU", "ctu", "cd", "shuangliu", "双流"],
|
||||
"chongqing": ["重庆", "ZUCK", "ckg", "cq", "jiangbei", "江北"],
|
||||
"wuhan": ["武汉", "ZHHH", "wuh", "wh", "tianhe", "天河"],
|
||||
"shenzhen": ["深圳", "ZGSZ", "szx", "sz", "baoan", "宝安"],
|
||||
"guangzhou": ["广州", "ZGGG", "can", "gz", "baiyun", "白云"],
|
||||
"qingdao": ["青岛", "青島", "ZSQD", "tao", "qdo", "jiaodong", "胶东"],
|
||||
"hong kong": ["香港", "VHHH", "hkg", "hk", "observatory", "天文台"],
|
||||
"lau fau shan": ["流浮山", "LFS", "lfs"],
|
||||
"taipei": ["台北", "臺北", "RCSS", "tpe", "tp", "台湾", "臺灣"],
|
||||
"singapore": ["新加坡", "WSSS", "sin", "sg", "changi", "樟宜"],
|
||||
"kuala lumpur": ["吉隆坡", "WMKK", "kul", "sepang", "雪邦"],
|
||||
"jakarta": ["雅加达", "雅加達", "WIHH", "jkt", "halim"],
|
||||
"manila": ["马尼拉", "馬尼拉", "RPLL", "mnl", "ninoy"],
|
||||
"karachi": ["卡拉奇", "OPKC", "khi", "jinnah", "真纳"],
|
||||
"lucknow": ["勒克瑙", "VILK", "luc"],
|
||||
"tel aviv": ["特拉维夫", "LLBG", "tlv", "ben gurion"],
|
||||
"jeddah": ["吉达", "吉達", "OEJN", "jed", "king abdulaziz", "阿卜杜勒阿齐兹"],
|
||||
"istanbul": ["伊斯坦布尔", "LTFM", "ist", "ltfm"],
|
||||
"ankara": ["安卡拉", "LTAC", "ank", "esenboğa"],
|
||||
"london": ["伦敦", "EGLC", "lon", "city airport"],
|
||||
"paris": ["巴黎", "LFPB", "par", "le bourget"],
|
||||
"moscow": ["莫斯科", "UUWW", "mos", "mow", "vnukovo"],
|
||||
"munich": ["慕尼黑", "EDDM", "mun"],
|
||||
"milan": ["米兰", "米蘭", "LIMC", "mil", "mxp", "malpensa", "马尔彭萨"],
|
||||
"warsaw": ["华沙", "華沙", "EPWA", "waw", "war", "chopin", "肖邦"],
|
||||
"helsinki": ["赫尔辛基", "赫爾辛基", "EFHK", "hel", "vantaa"],
|
||||
"amsterdam": ["阿姆斯特丹", "EHAM", "ams", "schiphol", "史基浦"],
|
||||
"madrid": ["马德里", "馬德里", "LEMD", "mad", "barajas"],
|
||||
"new york": ["纽约", "KLGA", "nyc", "ny", "laguardia"],
|
||||
"los angeles": ["洛杉矶", "KLAX", "la", "lax"],
|
||||
"san francisco": ["旧金山", "KSFO", "sf", "sfo"],
|
||||
"denver": ["丹佛", "奥罗拉", "KBKF", "aur", "buckley"],
|
||||
"austin": ["奥斯汀", "KAUS", "aus"],
|
||||
"houston": ["休斯顿", "KHOU", "hou", "hobby"],
|
||||
"chicago": ["芝加哥", "KORD", "chi", "ohare"],
|
||||
"dallas": ["达拉斯", "KDAL", "dal", "love field"],
|
||||
"miami": ["迈阿密", "KMIA", "mia"],
|
||||
"atlanta": ["亚特兰大", "KATL", "atl", "hartsfield"],
|
||||
"seattle": ["西雅图", "KSEA", "sea", "seatac"],
|
||||
"toronto": ["多伦多", "CYYZ", "tor", "pearson"],
|
||||
"mexico city": ["墨西哥城", "MMMX", "cdmx"],
|
||||
"panama city": ["巴拿马城", "MPMG", "pty"],
|
||||
"buenos aires": ["布宜诺斯艾利斯", "SAEZ", "ba", "ezeiza"],
|
||||
"são paulo": ["圣保罗", "SBGR", "sp", "guarulhos"],
|
||||
"lagos": ["拉各斯", "DNMM", "los", "murtala"],
|
||||
"cape town": ["开普敦", "開普敦", "FACT", "cpt"],
|
||||
"wellington": ["惠灵顿", "NZWN", "wel"],
|
||||
};
|
||||
|
||||
function normalizeCityKey(name: string): string {
|
||||
return String(name || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchesCitySearch(
|
||||
cityKey: string,
|
||||
displayName: string,
|
||||
query: string,
|
||||
): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
if (cityKey.includes(q)) return true;
|
||||
if (displayName.toLowerCase().includes(q)) return true;
|
||||
const aliases = CITY_SEARCH_INDEX[cityKey];
|
||||
if (!aliases) return false;
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function pickCityRow(
|
||||
rows: ScanOpportunityRow[],
|
||||
cityKey: string,
|
||||
): ScanOpportunityRow | null {
|
||||
return (
|
||||
rows.find(
|
||||
(row) => normalizeCityKey(row.city) === cityKey,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileCityPicker({
|
||||
isEn,
|
||||
rows,
|
||||
onSelectCity,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
rows: ScanOpportunityRow[];
|
||||
onSelectCity: (row: ScanOpportunityRow) => void;
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(
|
||||
() => new Set(CITY_REGION_GROUPS.map((g) => g.key)),
|
||||
);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
return CITY_REGION_GROUPS
|
||||
.map((group) => {
|
||||
const matched = group.cities.filter((cityKey) => {
|
||||
const row = pickCityRow(rows, cityKey);
|
||||
if (!row) return false;
|
||||
const display =
|
||||
row.city_display_name || row.display_name || cityKey;
|
||||
return matchesCitySearch(cityKey, display, q);
|
||||
});
|
||||
return { ...group, matched };
|
||||
})
|
||||
.filter((group) => group.matched.length > 0);
|
||||
}, [rows, searchQuery]);
|
||||
|
||||
const totalMatched = useMemo(
|
||||
() => filteredGroups.reduce((sum, g) => sum + g.matched.length, 0),
|
||||
[filteredGroups],
|
||||
);
|
||||
|
||||
const toggleRegion = (key: string) => {
|
||||
setExpandedRegions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scan-mobile-city-picker">
|
||||
<div className="scan-mobile-picker-search">
|
||||
<Search size={16} className="scan-mobile-picker-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="scan-mobile-picker-search-input"
|
||||
placeholder={
|
||||
isEn
|
||||
? "Search city, airport code, or Chinese name..."
|
||||
: "搜索城市、机场代码或中文名..."
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scan-mobile-picker-search-clear"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{searchQuery ? (
|
||||
<div className="scan-mobile-picker-result-count">
|
||||
{isEn
|
||||
? `${totalMatched} city${totalMatched !== 1 ? "ies" : "y"} found`
|
||||
: `找到 ${totalMatched} 个城市`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filteredGroups.length === 0 ? (
|
||||
<div className="scan-mobile-picker-empty">
|
||||
{isEn ? "No cities match your search" : "没有匹配的城市"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="scan-mobile-picker-regions">
|
||||
{filteredGroups.map((group) => {
|
||||
const isExpanded = expandedRegions.has(group.key) || !!searchQuery;
|
||||
return (
|
||||
<div key={group.key} className="scan-mobile-picker-region">
|
||||
<button
|
||||
type="button"
|
||||
className="scan-mobile-picker-region-head"
|
||||
onClick={() => toggleRegion(group.key)}
|
||||
>
|
||||
<span>
|
||||
{isEn ? group.label.en : group.label.zh}
|
||||
<small>{group.matched.length}</small>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={isExpanded ? "expanded" : ""}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div className="scan-mobile-picker-region-cities">
|
||||
{group.matched.map((cityKey) => {
|
||||
const row = pickCityRow(rows, cityKey);
|
||||
if (!row) return null;
|
||||
const display =
|
||||
row.city_display_name ||
|
||||
row.display_name ||
|
||||
cityKey;
|
||||
const currentTemp =
|
||||
row.current_temp ?? row.current_max_so_far ?? null;
|
||||
const deb = row.deb_prediction ?? null;
|
||||
const tempUnit = row.temp_symbol || "°C";
|
||||
return (
|
||||
<button
|
||||
key={cityKey}
|
||||
type="button"
|
||||
className="scan-mobile-picker-city-row"
|
||||
onClick={() => onSelectCity(row)}
|
||||
>
|
||||
<span className="scan-mobile-picker-city-name">
|
||||
<b>{display}</b>
|
||||
<small>
|
||||
{row.airport || row.local_time || ""}
|
||||
</small>
|
||||
</span>
|
||||
<span className="scan-mobile-picker-city-temp">
|
||||
<b>
|
||||
{currentTemp != null &&
|
||||
Number.isFinite(Number(currentTemp))
|
||||
? `${Number(currentTemp).toFixed(1)}${tempUnit}`
|
||||
: "--"}
|
||||
</b>
|
||||
<small>
|
||||
DEB{" "}
|
||||
{deb != null && Number.isFinite(Number(deb))
|
||||
? `${Number(deb).toFixed(1)}${tempUnit}`
|
||||
: "--"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,12 @@
|
||||
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 { AirportEvidencePanel } from "@/components/dashboard/scan-terminal/AirportEvidencePanel";
|
||||
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";
|
||||
@@ -42,15 +36,12 @@ export function MobileDecisionCard({
|
||||
aiReadInProgressText,
|
||||
aiRuleEvidenceMode,
|
||||
aiRuleEvidenceText,
|
||||
currentTempText,
|
||||
dataFreshnessRows,
|
||||
debPrediction,
|
||||
decisionState,
|
||||
detail,
|
||||
displayName,
|
||||
expectedHighText,
|
||||
fallbackAiReason,
|
||||
freshnessSeparator,
|
||||
isEn,
|
||||
isHkoObservation,
|
||||
isRefreshing,
|
||||
@@ -61,7 +52,6 @@ export function MobileDecisionCard({
|
||||
onRefresh,
|
||||
onRemove,
|
||||
peakWindow,
|
||||
rawObservationText,
|
||||
removing,
|
||||
tempSymbol,
|
||||
}: {
|
||||
@@ -76,15 +66,12 @@ export function MobileDecisionCard({
|
||||
aiReadInProgressText: string;
|
||||
aiRuleEvidenceMode: boolean;
|
||||
aiRuleEvidenceText: string;
|
||||
currentTempText: string;
|
||||
dataFreshnessRows: DataFreshnessRow[];
|
||||
debPrediction: number | null;
|
||||
decisionState: CityDecisionState;
|
||||
detail: CityDetail | null;
|
||||
displayName: string;
|
||||
expectedHighText: string;
|
||||
fallbackAiReason: string;
|
||||
freshnessSeparator: string;
|
||||
isEn: boolean;
|
||||
isHkoObservation: boolean;
|
||||
isRefreshing: boolean;
|
||||
@@ -95,15 +82,12 @@ export function MobileDecisionCard({
|
||||
onRefresh: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
peakWindow: string;
|
||||
rawObservationText: string;
|
||||
removing?: boolean;
|
||||
tempSymbol: string;
|
||||
}) {
|
||||
const copy = getMobileDecisionCopy(isEn);
|
||||
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [chartOpen, setChartOpen] = useState(false);
|
||||
const [airportOpen, setAirportOpen] = useState(false);
|
||||
const statusTags: CityStatusTag[] = decisionState.badges.length
|
||||
? decisionState.badges
|
||||
: [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }];
|
||||
@@ -142,11 +126,7 @@ export function MobileDecisionCard({
|
||||
</header>
|
||||
|
||||
<div className="scan-mobile-decision-metrics">
|
||||
<span>
|
||||
<small>{copy.currentTemp}</small>
|
||||
<b>{currentTempText}</b>
|
||||
</span>
|
||||
<span>
|
||||
<span className="primary">
|
||||
<small>{copy.expectedHigh}</small>
|
||||
<b>{expectedHighText}</b>
|
||||
</span>
|
||||
@@ -158,13 +138,6 @@ export function MobileDecisionCard({
|
||||
|
||||
<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}
|
||||
@@ -199,21 +172,9 @@ export function MobileDecisionCard({
|
||||
isHkoObservation={isHkoObservation}
|
||||
localModelSupportNote={localModelSupportNote}
|
||||
localizedFinalJudgment={localizedFinalJudgment}
|
||||
rawObservationText={rawObservationText}
|
||||
tempSymbol={tempSymbol}
|
||||
/>
|
||||
|
||||
<details
|
||||
className="scan-ai-city-section scan-mobile-fold"
|
||||
open={airportOpen}
|
||||
onToggle={(event) => setAirportOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="scan-ai-city-section-title">
|
||||
{isEn ? "Airport live evidence" : "机场实时证据"}
|
||||
</summary>
|
||||
{airportOpen ? <AirportEvidencePanel detail={detail} isEn={isEn} /> : null}
|
||||
</details>
|
||||
|
||||
<details
|
||||
className="scan-ai-city-section scan-mobile-fold"
|
||||
open={modelOpen}
|
||||
@@ -222,15 +183,6 @@ export function MobileDecisionCard({
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user