feat: implement mobile scan terminal dashboard and city picker with backend forum automation scripts

This commit is contained in:
2569718930@qq.com
2026-05-17 17:05:47 +08:00
parent 2d9aa576dd
commit 58e6557c66
12 changed files with 942 additions and 242 deletions
@@ -219,6 +219,7 @@ const TELEGRAM_BOT_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
).trim();
const TELEGRAM_MARKET_CHANNEL_URL = "https://t.me/+hGAk7JsjtdhiOTUx";
const TELEGRAM_TOPICS_GROUP_URL = "https://t.me/+8vel7rwjZagxODUx";
const SUBSCRIPTION_HELP_HREF = "/subscription-help";
const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery";
const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000;
@@ -708,6 +709,16 @@ export function AccountCenter() {
telegramHint: isEn
? "Send the command below to the polyweather bot to sync notifications and access."
: "将下方命令发送给polyweather机器人,实现全平台气象查询与权限同步。",
telegramUpgradeTitle: isEn
? "Channel Upgrade: New Topics Group Live"
: "频道升级通知:新话题群组已上线",
telegramUpgradeBody: isEn
? "Real-time airport temperature pushes are migrating to the new PolyWeather Topics group, organized by city for better tracking. Current channel will stop updating and be deleted in 3 days — please join the new group now."
: "为了更好地按城市分类推送,后续实时机场温度推送将迁移至新的 PolyWeather Topics 群组。当前频道将在 3 天后停止更新并删除,请及时迁移。",
telegramUpgradeFeatures: isEn
? "Independent city topics:"
: "支持独立城市话题订阅:",
telegramUpgradeJoin: isEn ? "Join New Group" : "加入新群",
paymentManualSupport: isEn
? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases."
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
@@ -2755,6 +2766,34 @@ export function AccountCenter() {
<p className="text-slate-400 text-sm mb-6">
{copy.telegramHint}
</p>
{/* Channel Upgrade / Migration Notice — Pro users only */}
{isSubscribed ? (
<div className="mb-6 rounded-2xl border border-cyan-400/30 bg-gradient-to-br from-cyan-500/12 to-blue-500/8 px-5 py-4">
<h4 className="text-sm font-bold text-cyan-300 mb-2 flex items-center gap-1.5">
<Sparkles size={15} /> {copy.telegramUpgradeTitle}
</h4>
<p className="text-slate-300 text-xs leading-relaxed mb-3">
{copy.telegramUpgradeBody}
</p>
<p className="text-slate-400 text-[11px] font-semibold mb-1.5">
{copy.telegramUpgradeFeatures}
</p>
<p className="text-slate-300 text-xs leading-relaxed mb-3">
🇯🇵 Tokyo · 🇰🇷 Seoul · 🇰🇷 Busan · 🇨🇳 Beijing · 🇨🇳 Shanghai · 🇨🇳 Guangzhou · 🇨🇳 Chengdu · 🇨🇳 Wuhan · 🇭🇰 Hong Kong · 🇹🇼 Taipei · 🇸🇬 Singapore · 🇹🇷 Istanbul · 🇫🇮 Helsinki · 🇳🇱 Amsterdam · 🇫🇷 Paris · 🇺🇸 New York · 🇺🇸 Los Angeles · 🇺🇸 Chicago
</p>
<Link
href={TELEGRAM_TOPICS_GROUP_URL}
target="_blank"
rel="noreferrer"
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-cyan-400/40 bg-cyan-500/15 px-4 py-2 text-xs font-bold text-cyan-200 hover:bg-cyan-500/25 transition-colors"
>
{copy.telegramUpgradeJoin}
<ExternalLink size={12} />
</Link>
</div>
) : null}
<div className="mb-4 flex flex-wrap gap-2">
{TELEGRAM_BOT_URL ? (
<Link
@@ -19,10 +19,10 @@ import {
} from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import type {
CityDetail,
ScanOpportunityRow,
} from "@/lib/dashboard-types";
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
import { MobileCityPicker } from "@/components/dashboard/scan-terminal/MobileCityPicker";
import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay";
import {
ScanPaywallModal,
@@ -102,6 +102,8 @@ function ScanTerminalScreen() {
setIsMobileViewport(media.matches);
if (media.matches) {
setActiveView((current) => (current === "map" ? "city-list" : current));
} else {
setActiveView((current) => (current === "city-list" ? "map" : current));
}
};
syncMobileViewport();
@@ -343,72 +345,11 @@ function ScanTerminalScreen() {
const renderMainView = () => {
if (resolvedView === "city-list") {
return (
<div className="scan-mobile-city-list-view">
<div className="scan-mobile-city-list-head">
<div>
<span>{isEn ? "Lightweight mobile entry" : "移动端轻量入口"}</span>
<strong>{isEn ? "Pick one city to inspect" : "选择一个城市进入决策卡"}</strong>
</div>
<p>
{isEn
? "Cached scan rows are used first. The map and heavier evidence load only when opened."
: "优先使用缓存扫描结果;地图和重证据只在打开后加载。"}
</p>
</div>
<div className="scan-mobile-city-list">
{timeSortedRows.slice(0, 24).map((row, index) => {
const cityName = row.city || row.city_display_name || row.display_name || "";
const display = row.city_display_name || row.display_name || cityName;
const currentTemp = row.current_temp ?? row.current_max_so_far ?? null;
const deb = row.deb_prediction ?? null;
return (
<button
key={row.id || `${cityName}-${index}`}
type="button"
className="scan-mobile-city-row"
onClick={() => handleOpenDecisionRow(row)}
>
<span className="scan-mobile-city-rank">{String(index + 1).padStart(2, "0")}</span>
<span className="scan-mobile-city-main">
<b>{display}</b>
<small>{row.market_question || row.target_label || row.local_time || "--"}</small>
</span>
<span className="scan-mobile-city-metrics">
<b>
{currentTemp != null && Number.isFinite(Number(currentTemp))
? `${Number(currentTemp).toFixed(1)}${row.temp_symbol || "°C"}`
: "--"}
</b>
<small>
DEB{" "}
{deb != null && Number.isFinite(Number(deb))
? `${Number(deb).toFixed(1)}${row.temp_symbol || "°C"}`
: "--"}
</small>
</span>
</button>
);
})}
</div>
{!timeSortedRows.length ? (
<div className="scan-empty-state">
<div className="scan-empty-title">
{scanLoading
? isEn
? "Loading cached scan"
: "正在读取缓存扫描"
: isEn
? "No city rows yet"
: "暂无城市列表"}
</div>
<div className="scan-empty-copy">
{isEn
? "Use refresh only when you need a new server-side scan."
: "只有需要新的服务端扫描时再手动刷新。"}
</div>
</div>
) : null}
</div>
<MobileCityPicker
isEn={isEn}
rows={timeSortedRows}
onSelectCity={handleOpenDecisionRow}
/>
);
}
if (resolvedView === "map") {
@@ -508,29 +449,32 @@ function ScanTerminalScreen() {
<section className="scan-list-section">
<div className="scan-list-header">
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
<button
type="button"
role="tab"
aria-selected={resolvedView === "city-list"}
className={resolvedView === "city-list" ? "active" : ""}
onClick={() => {
setActiveView("city-list");
}}
>
{isEn ? "City List" : "城市列表"}
</button>
<button
type="button"
role="tab"
aria-selected={resolvedView === "map"}
className={resolvedView === "map" ? "active" : ""}
onClick={() => {
lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
setActiveView("map");
}}
>
{isEn ? "Distribution View" : "分布视图"}
</button>
{isMobileViewport ? (
<button
type="button"
role="tab"
aria-selected={resolvedView === "city-list"}
className={resolvedView === "city-list" ? "active" : ""}
onClick={() => {
setActiveView("city-list");
}}
>
{isEn ? "City List" : "城市列表"}
</button>
) : (
<button
type="button"
role="tab"
aria-selected={resolvedView === "map"}
className={resolvedView === "map" ? "active" : ""}
onClick={() => {
lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
setActiveView("map");
}}
>
{isEn ? "Distribution View" : "分布视图"}
</button>
)}
<button
type="button"
role="tab"
@@ -629,3 +629,216 @@
height: 38px;
}
}
/* ── Mobile City Picker ── */
.root :global(.scan-mobile-city-picker) {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0;
min-height: 0;
flex: 1;
}
.root :global(.scan-mobile-picker-search) {
position: relative;
display: flex;
align-items: center;
border: 1px solid rgba(77, 163, 255, 0.22);
border-radius: 16px;
background: rgba(12, 24, 42, 0.88);
padding: 0 14px;
transition: border-color 0.18s;
}
.root :global(.scan-mobile-picker-search:focus-within) {
border-color: rgba(77, 163, 255, 0.48);
box-shadow: 0 0 0 3px rgba(77, 163, 255, 0.08);
}
.root :global(.scan-mobile-picker-search-icon) {
flex: 0 0 auto;
color: #7a9cc6;
}
.root :global(.scan-mobile-picker-search-input) {
flex: 1;
min-width: 0;
height: 48px;
border: none;
background: transparent;
color: var(--color-text-primary);
font-size: 15px;
font-weight: 500;
padding: 0 10px;
outline: none;
}
.root :global(.scan-mobile-picker-search-input::placeholder) {
color: #5b7a9e;
}
.root :global(.scan-mobile-picker-search-clear) {
flex: 0 0 auto;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: rgba(148, 163, 184, 0.12);
color: #8fa4c3;
font-size: 13px;
cursor: pointer;
}
.root :global(.scan-mobile-picker-result-count) {
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0 2px;
}
.root :global(.scan-mobile-picker-empty) {
display: grid;
place-items: center;
padding: 48px 16px;
color: var(--color-text-muted);
font-size: 14px;
}
.root :global(.scan-mobile-picker-regions) {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.root :global(.scan-mobile-picker-regions::-webkit-scrollbar) {
display: none;
}
.root :global(.scan-mobile-picker-region) {
border: 1px solid rgba(90, 123, 166, 0.1);
border-radius: 16px;
background: rgba(8, 18, 35, 0.68);
overflow: hidden;
}
.root :global(.scan-mobile-picker-region-head) {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
border: none;
background: transparent;
color: inherit;
padding: 13px 15px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.root :global(.scan-mobile-picker-region-head span) {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--color-text-primary);
}
.root :global(.scan-mobile-picker-region-head small) {
display: inline-grid;
place-items: center;
min-width: 22px;
height: 22px;
border-radius: 8px;
background: rgba(77, 163, 255, 0.14);
color: var(--color-accent-primary);
font-size: 11px;
font-weight: 800;
padding: 0 6px;
}
.root :global(.scan-mobile-picker-region-head svg) {
color: #5b7a9e;
transition: transform 0.2s ease;
}
.root :global(.scan-mobile-picker-region-head svg.expanded) {
transform: rotate(180deg);
}
.root :global(.scan-mobile-picker-region-cities) {
display: grid;
gap: 6px;
padding: 0 12px 12px;
}
.root :global(.scan-mobile-picker-city-row) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
border: 1px solid rgba(118, 150, 192, 0.12);
border-radius: 14px;
background: rgba(14, 26, 45, 0.64);
color: inherit;
padding: 11px 13px;
text-align: left;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.root :global(.scan-mobile-picker-city-row:active) {
border-color: rgba(77, 163, 255, 0.32);
background: rgba(77, 163, 255, 0.08);
}
.root :global(.scan-mobile-picker-city-name),
.root :global(.scan-mobile-picker-city-temp) {
display: grid;
gap: 3px;
min-width: 0;
}
.root :global(.scan-mobile-picker-city-name b) {
color: var(--color-text-primary);
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.root :global(.scan-mobile-picker-city-name small) {
color: var(--color-text-muted);
font-size: 11px;
font-weight: 500;
}
.root :global(.scan-mobile-picker-city-temp) {
justify-items: end;
flex: 0 0 auto;
}
.root :global(.scan-mobile-picker-city-temp b) {
color: var(--color-text-primary);
font-size: 15px;
font-weight: 700;
white-space: nowrap;
}
.root :global(.scan-mobile-picker-city-temp small) {
color: var(--color-text-muted);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
@@ -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>
)}
</>