2026-04-23 23:49:52 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import clsx from "clsx";
|
2026-04-24 00:34:09 +08:00
|
|
|
|
import Link from "next/link";
|
2026-04-24 00:13:11 +08:00
|
|
|
|
import {
|
2026-04-25 03:59:02 +08:00
|
|
|
|
LogIn,
|
2026-04-24 05:39:14 +08:00
|
|
|
|
Moon,
|
|
|
|
|
|
Sun,
|
2026-04-24 00:34:09 +08:00
|
|
|
|
UserRound,
|
2026-04-24 00:13:11 +08:00
|
|
|
|
} from "lucide-react";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
import {
|
2026-04-24 05:39:14 +08:00
|
|
|
|
useCallback,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
useEffect,
|
|
|
|
|
|
useMemo,
|
2026-04-25 02:41:38 +08:00
|
|
|
|
useRef,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
useState,
|
|
|
|
|
|
} from "react";
|
|
|
|
|
|
import styles from "./Dashboard.module.css";
|
|
|
|
|
|
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
|
|
|
|
|
import modalChromeStyles from "./ModalChrome.module.css";
|
2026-04-24 13:28:38 +08:00
|
|
|
|
import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel";
|
2026-04-24 06:31:29 +08:00
|
|
|
|
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
|
2026-04-24 03:30:55 +08:00
|
|
|
|
import { MapCanvas } from "@/components/dashboard/MapCanvas";
|
2026-04-25 00:19:30 +08:00
|
|
|
|
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
2026-04-24 00:34:09 +08:00
|
|
|
|
import {
|
|
|
|
|
|
DashboardStoreProvider,
|
|
|
|
|
|
useDashboardStore,
|
|
|
|
|
|
} from "@/hooks/useDashboardStore";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
|
|
|
|
|
import type {
|
2026-04-26 00:24:11 +08:00
|
|
|
|
CityDetail,
|
2026-04-23 23:49:52 +08:00
|
|
|
|
ScanOpportunityRow,
|
|
|
|
|
|
ScanTerminalResponse,
|
|
|
|
|
|
} from "@/lib/dashboard-types";
|
2026-04-24 13:43:05 +08:00
|
|
|
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
2026-04-26 09:13:18 +08:00
|
|
|
|
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
|
|
|
|
|
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
|
|
|
|
|
|
import { AiForecastKPIBar } from "@/components/dashboard/scan-terminal/AiForecastKPIBar";
|
|
|
|
|
|
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
|
|
|
|
|
import { OpportunityOverview } from "@/components/dashboard/scan-terminal/OpportunityOverview";
|
2026-04-25 03:59:02 +08:00
|
|
|
|
import {
|
2026-04-26 09:13:18 +08:00
|
|
|
|
findDetailForCity,
|
|
|
|
|
|
isFullEnoughForDeepAnalysis,
|
|
|
|
|
|
waitForDeepAnalysisQueue,
|
|
|
|
|
|
} from "@/components/dashboard/scan-terminal/city-detail-utils";
|
|
|
|
|
|
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
2026-04-25 04:25:17 +08:00
|
|
|
|
import {
|
2026-04-26 09:13:18 +08:00
|
|
|
|
findRowForCity,
|
|
|
|
|
|
formatUserLocalTime,
|
|
|
|
|
|
normalizeCityKey,
|
|
|
|
|
|
prettifyCityName,
|
|
|
|
|
|
rowMatchesCity,
|
|
|
|
|
|
sortRowsByUserTime,
|
|
|
|
|
|
} from "@/components/dashboard/scan-terminal/decision-utils";
|
|
|
|
|
|
|
|
|
|
|
|
type ContentView = "opportunities" | "analysis" | "map" | "calendar";
|
2026-04-24 05:39:14 +08:00
|
|
|
|
type ThemeMode = "dark" | "light";
|
2026-04-23 23:49:52 +08:00
|
|
|
|
function ScanTerminalScreen() {
|
2026-04-24 00:34:09 +08:00
|
|
|
|
const store = useDashboardStore();
|
|
|
|
|
|
const { locale, toggleLocale } = useI18n();
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const isEn = locale === "en-US";
|
2026-04-25 00:08:21 +08:00
|
|
|
|
const isPro = store.proAccess.subscriptionActive;
|
2026-04-24 00:34:09 +08:00
|
|
|
|
const accountHref = store.proAccess.authenticated
|
|
|
|
|
|
? "/account"
|
|
|
|
|
|
: "/auth/login?next=%2Faccount";
|
2026-04-26 09:13:18 +08:00
|
|
|
|
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
|
|
|
|
|
const [scanLoading, setScanLoading] = useState(false);
|
|
|
|
|
|
const [scanError, setScanError] = useState<string | null>(null);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
2026-04-26 09:13:18 +08:00
|
|
|
|
const [activeView, setActiveView] = useState<ContentView>("opportunities");
|
2026-04-24 05:09:19 +08:00
|
|
|
|
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
2026-04-26 00:24:11 +08:00
|
|
|
|
const [aiPinnedCities, setAiPinnedCities] = useState<AiPinnedCity[]>([]);
|
2026-04-25 00:19:30 +08:00
|
|
|
|
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
const [userLocalTime, setUserLocalTime] = useState("--");
|
|
|
|
|
|
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
2026-04-26 00:24:11 +08:00
|
|
|
|
const lastMapSelectedCityRef = useRef<string>("");
|
2026-04-26 05:58:07 +08:00
|
|
|
|
const aiFullHydrationRef = useRef<Set<string>>(new Set());
|
2026-04-26 06:55:16 +08:00
|
|
|
|
const aiHydrationQueueRef = useRef<string[]>([]);
|
|
|
|
|
|
const aiHydrationRunningRef = useRef(false);
|
2026-04-25 01:09:08 +08:00
|
|
|
|
|
2026-04-24 05:09:19 +08:00
|
|
|
|
const timeSortedRows = useMemo(
|
2026-04-26 00:24:11 +08:00
|
|
|
|
() => sortRowsByUserTime(terminalData?.rows || []),
|
|
|
|
|
|
[terminalData?.rows],
|
2026-04-24 05:09:19 +08:00
|
|
|
|
);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
const selectedRow = useMemo(() => {
|
2026-04-24 05:09:19 +08:00
|
|
|
|
if (!timeSortedRows.length) return null;
|
|
|
|
|
|
return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null;
|
|
|
|
|
|
}, [timeSortedRows, selectedRowId]);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-26 09:13:18 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!timeSortedRows.length) return;
|
|
|
|
|
|
if (selectedRowId && timeSortedRows.some((row) => row.id === selectedRowId)) return;
|
|
|
|
|
|
setSelectedRowId(timeSortedRows[0].id);
|
|
|
|
|
|
}, [selectedRowId, timeSortedRows]);
|
|
|
|
|
|
|
2026-04-24 05:39:14 +08:00
|
|
|
|
const mapFocusedRow = useMemo(() => {
|
2026-04-24 13:09:31 +08:00
|
|
|
|
return findRowForCity(
|
|
|
|
|
|
timeSortedRows,
|
|
|
|
|
|
mapSelectedCityName || store.selectedCity,
|
2026-04-24 05:39:14 +08:00
|
|
|
|
);
|
|
|
|
|
|
}, [mapSelectedCityName, store.selectedCity, timeSortedRows]);
|
2026-04-26 00:24:11 +08:00
|
|
|
|
const kpiCityName =
|
|
|
|
|
|
mapSelectedCityName ||
|
|
|
|
|
|
store.selectedCity ||
|
|
|
|
|
|
aiPinnedCities[0]?.cityName ||
|
|
|
|
|
|
null;
|
|
|
|
|
|
const kpiDetail =
|
|
|
|
|
|
findDetailForCity(store.cityDetailsByName, kpiCityName) ||
|
|
|
|
|
|
(store.selectedDetail &&
|
|
|
|
|
|
normalizeCityKey(store.selectedDetail.name) === normalizeCityKey(kpiCityName)
|
|
|
|
|
|
? store.selectedDetail
|
|
|
|
|
|
: null);
|
|
|
|
|
|
const kpiRow = findRowForCity(timeSortedRows, kpiCityName);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
|
2026-04-24 13:09:31 +08:00
|
|
|
|
const mapFallbackRow = useMemo(() => {
|
|
|
|
|
|
const rawCityName = mapSelectedCityName || store.selectedCity;
|
|
|
|
|
|
const cityKey = normalizeCityKey(rawCityName);
|
|
|
|
|
|
if (!cityKey || mapFocusedRow) return null;
|
|
|
|
|
|
const selectedDetail =
|
|
|
|
|
|
store.selectedDetail && normalizeCityKey(store.selectedDetail.name) === cityKey
|
|
|
|
|
|
? store.selectedDetail
|
|
|
|
|
|
: Object.values(store.cityDetailsByName).find(
|
|
|
|
|
|
(detail) => normalizeCityKey(detail?.name) === cityKey,
|
|
|
|
|
|
) || null;
|
|
|
|
|
|
const selectedSummary =
|
|
|
|
|
|
Object.values(store.citySummariesByName).find(
|
|
|
|
|
|
(summary) => normalizeCityKey(summary?.name) === cityKey,
|
|
|
|
|
|
) || null;
|
|
|
|
|
|
const selectedCityItem =
|
|
|
|
|
|
store.cities.find(
|
|
|
|
|
|
(city) =>
|
|
|
|
|
|
normalizeCityKey(city.name) === cityKey ||
|
|
|
|
|
|
normalizeCityKey(city.display_name) === cityKey,
|
|
|
|
|
|
) || null;
|
|
|
|
|
|
const canonicalCity =
|
|
|
|
|
|
selectedDetail?.name ||
|
|
|
|
|
|
selectedSummary?.name ||
|
|
|
|
|
|
selectedCityItem?.name ||
|
|
|
|
|
|
String(rawCityName || "").trim();
|
|
|
|
|
|
if (!canonicalCity) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const tempSymbol =
|
|
|
|
|
|
selectedDetail?.temp_symbol ||
|
|
|
|
|
|
selectedSummary?.temp_symbol ||
|
|
|
|
|
|
(selectedCityItem?.temp_unit === "fahrenheit" ? "°F" : "°C");
|
|
|
|
|
|
const displayName =
|
|
|
|
|
|
selectedDetail?.display_name ||
|
|
|
|
|
|
selectedSummary?.display_name ||
|
|
|
|
|
|
selectedCityItem?.display_name ||
|
|
|
|
|
|
canonicalCity;
|
|
|
|
|
|
const currentTemp =
|
|
|
|
|
|
selectedDetail?.current?.temp ?? selectedSummary?.current?.temp ?? null;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: `map-city:${canonicalCity}`,
|
|
|
|
|
|
city: canonicalCity,
|
|
|
|
|
|
city_display_name: displayName,
|
|
|
|
|
|
display_name: displayName,
|
|
|
|
|
|
selected_date: selectedDetail?.local_date || null,
|
|
|
|
|
|
local_date: selectedDetail?.local_date || null,
|
|
|
|
|
|
local_time: selectedDetail?.local_time || selectedSummary?.local_time || null,
|
|
|
|
|
|
temp_symbol: tempSymbol,
|
|
|
|
|
|
current_temp: currentTemp,
|
|
|
|
|
|
current_max_so_far:
|
|
|
|
|
|
selectedDetail?.current?.max_so_far ?? currentTemp ?? null,
|
|
|
|
|
|
deb_prediction:
|
|
|
|
|
|
selectedDetail?.deb?.prediction ??
|
|
|
|
|
|
selectedSummary?.deb?.prediction ??
|
|
|
|
|
|
null,
|
|
|
|
|
|
airport:
|
|
|
|
|
|
selectedDetail?.risk?.airport ||
|
|
|
|
|
|
selectedCityItem?.airport ||
|
|
|
|
|
|
selectedCityItem?.settlement_station_label ||
|
|
|
|
|
|
null,
|
|
|
|
|
|
risk_level:
|
|
|
|
|
|
selectedDetail?.risk?.level ||
|
|
|
|
|
|
selectedSummary?.risk?.level ||
|
|
|
|
|
|
selectedCityItem?.risk_level ||
|
|
|
|
|
|
"low",
|
|
|
|
|
|
market_slug: null,
|
2026-04-24 13:43:05 +08:00
|
|
|
|
market_question: isEn ? "City briefing" : "城市简报",
|
2026-04-24 13:09:31 +08:00
|
|
|
|
target_label: isEn ? "City snapshot" : "城市概况",
|
|
|
|
|
|
side: null,
|
|
|
|
|
|
edge_percent: null,
|
|
|
|
|
|
final_score: null,
|
|
|
|
|
|
window_phase: "city_snapshot",
|
|
|
|
|
|
tradable: false,
|
|
|
|
|
|
active: false,
|
|
|
|
|
|
closed: false,
|
|
|
|
|
|
accepting_orders: false,
|
|
|
|
|
|
} satisfies ScanOpportunityRow;
|
|
|
|
|
|
}, [
|
|
|
|
|
|
isEn,
|
|
|
|
|
|
mapFocusedRow,
|
|
|
|
|
|
mapSelectedCityName,
|
|
|
|
|
|
store.cityDetailsByName,
|
|
|
|
|
|
store.citySummariesByName,
|
|
|
|
|
|
store.cities,
|
|
|
|
|
|
store.selectedCity,
|
|
|
|
|
|
store.selectedDetail,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
useEffect(() => {
|
2026-04-26 00:24:11 +08:00
|
|
|
|
if (!store.proAccess.loading && !isPro && activeView === "calendar") {
|
2026-04-25 00:08:21 +08:00
|
|
|
|
setActiveView("map");
|
|
|
|
|
|
}
|
2026-04-25 03:26:28 +08:00
|
|
|
|
}, [activeView, isPro, store.proAccess.loading]);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-26 09:13:18 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (store.proAccess.loading) return;
|
|
|
|
|
|
if (!isPro) {
|
|
|
|
|
|
setScanLoading(false);
|
|
|
|
|
|
setScanError(null);
|
|
|
|
|
|
setTerminalData(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
setScanLoading(true);
|
|
|
|
|
|
setScanError(null);
|
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
|
scan_mode: "tradable",
|
|
|
|
|
|
min_price: "0.05",
|
|
|
|
|
|
max_price: "0.95",
|
|
|
|
|
|
min_edge_pct: "2",
|
|
|
|
|
|
min_liquidity: "500",
|
|
|
|
|
|
market_type: "maxtemp",
|
|
|
|
|
|
time_range: "today",
|
|
|
|
|
|
limit: "36",
|
|
|
|
|
|
});
|
|
|
|
|
|
fetch(`/api/scan/terminal?${params.toString()}`, {
|
|
|
|
|
|
cache: "no-store",
|
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(async (response) => {
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
let message = `HTTP ${response.status}`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await response.json();
|
|
|
|
|
|
message = String(payload?.error || payload?.detail || message);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Keep HTTP status message.
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
return response.json() as Promise<ScanTerminalResponse>;
|
|
|
|
|
|
})
|
|
|
|
|
|
.then((payload) => {
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
setTerminalData(payload);
|
|
|
|
|
|
setScanError(null);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
|
if (cancelled || controller.signal.aborted) return;
|
|
|
|
|
|
setScanError(error instanceof Error ? error.message : String(error));
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setScanLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
controller.abort();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [isPro, store.proAccess.loading]);
|
|
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
useEffect(() => {
|
2026-04-24 06:31:29 +08:00
|
|
|
|
setUserLocalTime(formatUserLocalTime());
|
2026-04-24 05:39:14 +08:00
|
|
|
|
const intervalId = window.setInterval(() => {
|
2026-04-24 06:31:29 +08:00
|
|
|
|
setUserLocalTime(formatUserLocalTime());
|
|
|
|
|
|
}, 10_000);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
return () => window.clearInterval(intervalId);
|
2026-04-24 06:31:29 +08:00
|
|
|
|
}, []);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const stored = window.localStorage.getItem("polyweather_scan_theme");
|
|
|
|
|
|
if (stored === "light") {
|
|
|
|
|
|
setThemeMode("light");
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
|
|
|
|
|
}, [themeMode]);
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-24 10:19:16 +08:00
|
|
|
|
const resolvedView: ContentView = activeView;
|
2026-04-24 05:09:19 +08:00
|
|
|
|
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
2026-04-24 13:09:31 +08:00
|
|
|
|
const activeDetailRow =
|
|
|
|
|
|
resolvedView === "map" && mapFocusedCity
|
|
|
|
|
|
? mapFocusedRow || mapFallbackRow
|
|
|
|
|
|
: selectedRow;
|
2026-04-26 00:24:11 +08:00
|
|
|
|
const scanStatus = terminalData?.status || "ready";
|
|
|
|
|
|
const staleReason = terminalData?.stale_reason || null;
|
2026-04-24 05:39:14 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!activeDetailRow) return;
|
2026-04-26 08:12:31 +08:00
|
|
|
|
if (!findDetailForCity(store.cityDetailsByName, activeDetailRow.city)) {
|
2026-04-24 06:31:29 +08:00
|
|
|
|
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
|
|
|
|
|
|
}
|
2026-04-24 13:35:36 +08:00
|
|
|
|
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
|
2026-04-26 06:55:16 +08:00
|
|
|
|
const runAiHydrationQueue = useCallback(async () => {
|
|
|
|
|
|
if (aiHydrationRunningRef.current) return;
|
|
|
|
|
|
aiHydrationRunningRef.current = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
while (aiHydrationQueueRef.current.length > 0) {
|
|
|
|
|
|
const nextCity = aiHydrationQueueRef.current.shift();
|
|
|
|
|
|
const key = normalizeCityKey(nextCity || "");
|
|
|
|
|
|
if (!nextCity || !key) continue;
|
|
|
|
|
|
const existingDetail = findDetailForCity(store.cityDetailsByName, nextCity);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await store.ensureCityDetail(
|
|
|
|
|
|
nextCity,
|
|
|
|
|
|
Boolean(existingDetail) && !isFullEnoughForDeepAnalysis(existingDetail),
|
|
|
|
|
|
"full",
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!isFullEnoughForDeepAnalysis(detail)) {
|
|
|
|
|
|
aiFullHydrationRef.current.delete(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
aiFullHydrationRef.current.delete(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
await waitForDeepAnalysisQueue(1200);
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
aiHydrationRunningRef.current = false;
|
|
|
|
|
|
if (aiHydrationQueueRef.current.length > 0) {
|
|
|
|
|
|
void runAiHydrationQueue();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [store.cityDetailsByName, store.ensureCityDetail]);
|
|
|
|
|
|
|
|
|
|
|
|
const queueAiFullHydration = useCallback(
|
|
|
|
|
|
(cityName: string) => {
|
|
|
|
|
|
const key = normalizeCityKey(cityName);
|
|
|
|
|
|
if (!key || aiFullHydrationRef.current.has(key)) return;
|
|
|
|
|
|
aiFullHydrationRef.current.add(key);
|
|
|
|
|
|
aiHydrationQueueRef.current.push(cityName);
|
|
|
|
|
|
void runAiHydrationQueue();
|
|
|
|
|
|
},
|
|
|
|
|
|
[runAiHydrationQueue],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-26 00:24:11 +08:00
|
|
|
|
const addAiPinnedCity = useCallback((cityName: string) => {
|
|
|
|
|
|
const cleanName = String(cityName || "").trim();
|
|
|
|
|
|
const key = normalizeCityKey(cleanName);
|
|
|
|
|
|
if (!key) return;
|
|
|
|
|
|
const matchedRow = findRowForCity(timeSortedRows, cleanName);
|
|
|
|
|
|
const prettyName = prettifyCityName(cleanName);
|
|
|
|
|
|
const displayName =
|
|
|
|
|
|
matchedRow?.city_display_name ||
|
|
|
|
|
|
matchedRow?.display_name ||
|
|
|
|
|
|
getLocalizedCityName(cleanName, prettyName || cleanName, locale) ||
|
|
|
|
|
|
prettyName ||
|
|
|
|
|
|
cleanName;
|
|
|
|
|
|
setAiPinnedCities((current) => {
|
|
|
|
|
|
const existing = current.findIndex(
|
|
|
|
|
|
(item) => normalizeCityKey(item.cityName) === key,
|
|
|
|
|
|
);
|
|
|
|
|
|
const nextItem = {
|
|
|
|
|
|
cityName: matchedRow?.city || cleanName,
|
|
|
|
|
|
displayName,
|
|
|
|
|
|
addedAt: Date.now(),
|
|
|
|
|
|
};
|
|
|
|
|
|
if (existing >= 0) {
|
|
|
|
|
|
const next = [...current];
|
|
|
|
|
|
next[existing] = { ...next[existing], ...nextItem };
|
|
|
|
|
|
return [
|
|
|
|
|
|
next[existing],
|
|
|
|
|
|
...next.filter((_, index) => index !== existing),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [nextItem, ...current].slice(0, 8);
|
|
|
|
|
|
});
|
2026-04-26 06:55:16 +08:00
|
|
|
|
queueAiFullHydration(matchedRow?.city || cleanName);
|
|
|
|
|
|
}, [locale, queueAiFullHydration, timeSortedRows]);
|
2026-04-26 00:24:11 +08:00
|
|
|
|
|
|
|
|
|
|
const removeAiPinnedCity = useCallback((cityName: string) => {
|
|
|
|
|
|
const key = normalizeCityKey(cityName);
|
2026-04-26 06:55:16 +08:00
|
|
|
|
aiFullHydrationRef.current.delete(key);
|
|
|
|
|
|
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
|
|
|
|
|
|
(queuedCity) => normalizeCityKey(queuedCity) !== key,
|
|
|
|
|
|
);
|
2026-04-26 00:24:11 +08:00
|
|
|
|
setAiPinnedCities((current) =>
|
|
|
|
|
|
current.filter((item) => normalizeCityKey(item.cityName) !== key),
|
|
|
|
|
|
);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
aiPinnedCities.forEach((item) => {
|
2026-04-26 05:58:07 +08:00
|
|
|
|
const key = normalizeCityKey(item.cityName);
|
|
|
|
|
|
if (!key || aiFullHydrationRef.current.has(key)) return;
|
|
|
|
|
|
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
|
|
|
|
|
|
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
|
|
|
|
|
|
if (!needsFullHydration) return;
|
2026-04-26 06:55:16 +08:00
|
|
|
|
queueAiFullHydration(item.cityName);
|
2026-04-26 00:24:11 +08:00
|
|
|
|
});
|
2026-04-26 06:55:16 +08:00
|
|
|
|
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
|
2026-04-26 00:24:11 +08:00
|
|
|
|
|
2026-04-24 05:39:14 +08:00
|
|
|
|
const handleMapCitySelect = useCallback((cityName: string) => {
|
|
|
|
|
|
setMapSelectedCityName(cityName);
|
2026-04-26 00:24:11 +08:00
|
|
|
|
lastMapSelectedCityRef.current = normalizeCityKey(cityName);
|
2026-04-24 13:09:31 +08:00
|
|
|
|
const matchedRow = findRowForCity(timeSortedRows, cityName);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
setSelectedRowId(matchedRow?.id || null);
|
2026-04-26 00:24:11 +08:00
|
|
|
|
addAiPinnedCity(cityName);
|
2026-04-26 09:13:18 +08:00
|
|
|
|
setActiveView("analysis");
|
2026-04-26 00:24:11 +08:00
|
|
|
|
}, [addAiPinnedCity, timeSortedRows]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (activeView !== "map") return;
|
|
|
|
|
|
const selectedCity = String(store.selectedCity || "").trim();
|
|
|
|
|
|
const selectedKey = normalizeCityKey(selectedCity);
|
|
|
|
|
|
if (!selectedKey || selectedKey === lastMapSelectedCityRef.current) return;
|
|
|
|
|
|
lastMapSelectedCityRef.current = selectedKey;
|
|
|
|
|
|
setMapSelectedCityName(selectedCity);
|
|
|
|
|
|
const matchedRow = findRowForCity(timeSortedRows, selectedCity);
|
|
|
|
|
|
setSelectedRowId(matchedRow?.id || null);
|
|
|
|
|
|
addAiPinnedCity(selectedCity);
|
|
|
|
|
|
}, [activeView, addAiPinnedCity, store.selectedCity, timeSortedRows]);
|
2026-04-24 05:39:14 +08:00
|
|
|
|
|
|
|
|
|
|
const handleSelectRow = useCallback((row: ScanOpportunityRow) => {
|
2026-04-25 06:42:24 +08:00
|
|
|
|
const cityName = row.city || row.city_display_name || row.display_name || "";
|
|
|
|
|
|
if (!cityName) return;
|
2026-04-24 05:39:14 +08:00
|
|
|
|
setSelectedRowId(row.id);
|
2026-04-25 06:42:24 +08:00
|
|
|
|
const selectedCityKey = normalizeCityKey(store.selectedCity);
|
|
|
|
|
|
const rowCityKey = normalizeCityKey(cityName);
|
|
|
|
|
|
const hasCachedDetail =
|
2026-04-26 08:12:31 +08:00
|
|
|
|
Boolean(findDetailForCity(store.cityDetailsByName, cityName)) ||
|
2026-04-25 06:42:24 +08:00
|
|
|
|
Object.values(store.cityDetailsByName).some((detail) =>
|
|
|
|
|
|
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (store.isPanelOpen && selectedCityKey === rowCityKey) {
|
|
|
|
|
|
if (!hasCachedDetail) {
|
|
|
|
|
|
void store.ensureCityDetail(cityName, false, "panel").catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
void store.selectCity(cityName);
|
2026-04-24 13:28:38 +08:00
|
|
|
|
}, [store]);
|
2026-04-24 03:30:55 +08:00
|
|
|
|
|
2026-04-26 09:13:18 +08:00
|
|
|
|
const handleOpenDecisionRow = useCallback((row: ScanOpportunityRow) => {
|
|
|
|
|
|
const cityName = row.city || row.city_display_name || row.display_name || "";
|
|
|
|
|
|
if (!cityName) return;
|
|
|
|
|
|
setSelectedRowId(row.id);
|
|
|
|
|
|
addAiPinnedCity(cityName);
|
|
|
|
|
|
setActiveView("analysis");
|
|
|
|
|
|
void store.selectCity(cityName);
|
|
|
|
|
|
}, [addAiPinnedCity, store]);
|
|
|
|
|
|
|
2026-04-25 00:19:30 +08:00
|
|
|
|
const openScanPaywall = useCallback(() => {
|
|
|
|
|
|
setShowScanPaywall(true);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-04-24 03:30:55 +08:00
|
|
|
|
const renderMainView = () => {
|
2026-04-26 09:13:18 +08:00
|
|
|
|
if (resolvedView === "opportunities") {
|
|
|
|
|
|
if (!isPro) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-opportunity-overview empty">
|
|
|
|
|
|
<strong>{isEn ? "Opportunity board is Pro" : "今日机会榜需 Pro 权限"}</strong>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
{isEn
|
|
|
|
|
|
? "Map exploration and city briefing are still available."
|
|
|
|
|
|
: "地图探索和城市简报仍可使用。"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<button type="button" onClick={openScanPaywall}>
|
|
|
|
|
|
{isEn ? "Unlock opportunity board" : "解锁机会榜"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<OpportunityOverview
|
|
|
|
|
|
rows={timeSortedRows}
|
|
|
|
|
|
terminalData={terminalData}
|
|
|
|
|
|
loading={scanLoading}
|
|
|
|
|
|
error={scanError}
|
|
|
|
|
|
locale={locale}
|
|
|
|
|
|
selectedRowId={selectedRowId}
|
|
|
|
|
|
onOpenDecision={handleOpenDecisionRow}
|
|
|
|
|
|
onSelectRow={handleSelectRow}
|
|
|
|
|
|
onOpenMap={() => setActiveView("map")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
if (resolvedView === "map") {
|
2026-04-24 05:09:19 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-map-view">
|
|
|
|
|
|
<div className="scan-map-shell">
|
2026-04-24 13:28:38 +08:00
|
|
|
|
<MapCanvas
|
|
|
|
|
|
onCitySelect={handleMapCitySelect}
|
|
|
|
|
|
selectionMode="select"
|
|
|
|
|
|
/>
|
2026-04-24 05:09:19 +08:00
|
|
|
|
</div>
|
2026-04-25 00:08:21 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-26 09:13:18 +08:00
|
|
|
|
if (resolvedView === "analysis") {
|
2026-04-26 00:24:11 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<AiPinnedForecastView
|
|
|
|
|
|
items={aiPinnedCities}
|
|
|
|
|
|
rows={timeSortedRows}
|
|
|
|
|
|
detailsByName={store.cityDetailsByName}
|
|
|
|
|
|
locale={locale}
|
|
|
|
|
|
onRemoveCity={removeAiPinnedCity}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-25 00:08:21 +08:00
|
|
|
|
if (!isPro) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="scan-table-shell empty">
|
|
|
|
|
|
<div className="scan-empty-state">
|
|
|
|
|
|
<div className="scan-empty-title">
|
|
|
|
|
|
{isEn ? "Scan is available on Pro" : "扫描功能需 Pro 权限"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="scan-empty-copy">
|
|
|
|
|
|
{isEn
|
|
|
|
|
|
? "Distribution view and city briefing remain available."
|
|
|
|
|
|
: "分布视图和右侧城市简报仍可查看。"}
|
|
|
|
|
|
</div>
|
2026-04-24 05:09:19 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-04-24 03:30:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (resolvedView === "calendar") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CalendarView
|
2026-04-24 05:09:19 +08:00
|
|
|
|
rows={timeSortedRows}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
locale={locale}
|
|
|
|
|
|
selectedRowId={selectedRowId}
|
2026-04-24 05:39:14 +08:00
|
|
|
|
onSelectRow={handleSelectRow}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-04-26 00:24:11 +08:00
|
|
|
|
return null;
|
2026-04-24 03:30:55 +08:00
|
|
|
|
};
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-24 23:32:32 +08:00
|
|
|
|
if (store.proAccess.loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
|
|
|
|
|
|
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
|
|
|
|
|
|
<main className="scan-data-grid">
|
2026-04-26 09:13:18 +08:00
|
|
|
|
<div className="scan-loading-state">
|
|
|
|
|
|
<LoadingSignal
|
|
|
|
|
|
title={isEn ? "Preparing decision workspace" : "正在准备决策工作台"}
|
|
|
|
|
|
description={
|
|
|
|
|
|
isEn
|
|
|
|
|
|
? "Checking access, city context and today’s tradable weather windows."
|
|
|
|
|
|
: "正在检查权限、城市上下文和今日可交易天气窗口。"
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-04-24 23:32:32 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 23:49:52 +08:00
|
|
|
|
return (
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
|
2026-04-24 05:39:14 +08:00
|
|
|
|
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
<main className="scan-data-grid">
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-topbar">
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-topbar-title">
|
2026-04-26 09:13:18 +08:00
|
|
|
|
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<span>
|
2026-04-26 00:24:11 +08:00
|
|
|
|
{isEn
|
2026-04-26 09:13:18 +08:00
|
|
|
|
? "Start from opportunities, then open city cards to verify weather evidence"
|
|
|
|
|
|
: "先看今日机会榜,再打开城市决策卡验证天气证据"}
|
2026-04-24 10:19:16 +08:00
|
|
|
|
</span>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-topbar-actions">
|
2026-04-24 00:34:09 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="scan-locale-switch"
|
|
|
|
|
|
aria-label={isEn ? "Switch to Chinese" : "切换到英文"}
|
|
|
|
|
|
title={isEn ? "Switch to Chinese" : "切换到英文"}
|
|
|
|
|
|
onClick={toggleLocale}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className={clsx(locale === "zh-CN" && "active")}>中文</span>
|
|
|
|
|
|
<span className={clsx(locale === "en-US" && "active")}>EN</span>
|
|
|
|
|
|
</button>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<span className="scan-topbar-time">
|
2026-04-24 05:39:14 +08:00
|
|
|
|
{userLocalTime}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</span>
|
2026-04-25 03:59:02 +08:00
|
|
|
|
{isPro ? null : store.proAccess.authenticated ? (
|
2026-04-25 00:19:30 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="scan-primary-button"
|
|
|
|
|
|
onClick={openScanPaywall}
|
|
|
|
|
|
>
|
2026-04-25 00:08:21 +08:00
|
|
|
|
<UserRound size={14} />
|
2026-04-25 03:59:02 +08:00
|
|
|
|
{isEn ? "Upgrade Pro" : "升级 Pro"}
|
2026-04-25 00:19:30 +08:00
|
|
|
|
</button>
|
2026-04-25 03:59:02 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<Link href={accountHref} className="scan-primary-button">
|
|
|
|
|
|
<LogIn size={14} />
|
|
|
|
|
|
{isEn ? "Sign in" : "登录"}
|
|
|
|
|
|
</Link>
|
2026-04-25 00:08:21 +08:00
|
|
|
|
)}
|
2026-04-24 05:39:14 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="scan-theme-button"
|
|
|
|
|
|
aria-label={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
|
|
|
|
|
|
title={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
|
|
|
|
|
|
onClick={() => setThemeMode((current) => (current === "light" ? "dark" : "light"))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{themeMode === "light" ? <Moon size={15} /> : <Sun size={15} />}
|
|
|
|
|
|
</button>
|
2026-04-25 03:59:02 +08:00
|
|
|
|
{store.proAccess.authenticated ? (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={accountHref}
|
|
|
|
|
|
className="scan-account-button"
|
|
|
|
|
|
aria-label={isEn ? "Account" : "账户"}
|
|
|
|
|
|
title={isEn ? "Account" : "账户"}
|
|
|
|
|
|
>
|
|
|
|
|
|
<UserRound size={15} />
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
) : null}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-26 00:24:11 +08:00
|
|
|
|
<AiForecastKPIBar
|
|
|
|
|
|
pinnedCount={aiPinnedCities.length}
|
|
|
|
|
|
activeCityName={kpiCityName}
|
|
|
|
|
|
activeDetail={kpiDetail}
|
|
|
|
|
|
activeRow={kpiRow}
|
|
|
|
|
|
locale={locale}
|
|
|
|
|
|
/>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<section className="scan-list-section">
|
|
|
|
|
|
<div className="scan-list-header">
|
|
|
|
|
|
<div className="scan-list-tabs">
|
2026-04-26 09:13:18 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={resolvedView === "opportunities" ? "active" : ""}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setActiveView("opportunities");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isEn ? "Opportunity Board" : "今日机会榜"}
|
|
|
|
|
|
</button>
|
2026-04-24 03:30:55 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-04-24 22:37:56 +08:00
|
|
|
|
className={resolvedView === "map" ? "active" : ""}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
onClick={() => {
|
2026-04-26 00:24:11 +08:00
|
|
|
|
lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
|
2026-04-24 22:37:56 +08:00
|
|
|
|
setActiveView("map");
|
2026-04-24 03:30:55 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-24 22:37:56 +08:00
|
|
|
|
{isEn ? "Distribution View" : "分布视图"}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</button>
|
2026-04-24 03:30:55 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-04-26 09:13:18 +08:00
|
|
|
|
className={resolvedView === "analysis" ? "active" : ""}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
onClick={() => {
|
2026-04-26 09:13:18 +08:00
|
|
|
|
setActiveView("analysis");
|
2026-04-24 03:30:55 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-26 09:13:18 +08:00
|
|
|
|
{isEn ? "Decision Cards" : "城市决策卡"}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={resolvedView === "calendar" ? "active" : ""}
|
2026-04-26 00:45:16 +08:00
|
|
|
|
title={!isPro ? (isEn ? "Pro forecast calendar required" : "日历预测需 Pro") : undefined}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
onClick={() => {
|
2026-04-25 00:19:30 +08:00
|
|
|
|
if (!isPro) {
|
|
|
|
|
|
openScanPaywall();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-24 03:30:55 +08:00
|
|
|
|
setActiveView("calendar");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isEn ? "Calendar View" : "日历视图"}
|
|
|
|
|
|
</button>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-list-status">
|
|
|
|
|
|
{terminalData?.stale ? (
|
|
|
|
|
|
<span className="scan-status-chip stale">
|
|
|
|
|
|
{isEn ? "Delayed snapshot" : "延迟快照"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
|
2026-04-24 10:19:16 +08:00
|
|
|
|
{scanStatus === "failed" && !terminalData ? (
|
2026-04-24 00:13:11 +08:00
|
|
|
|
<div className="scan-empty-state">
|
|
|
|
|
|
<div className="scan-empty-title">
|
|
|
|
|
|
{isEn ? "Scan failed" : "扫描失败"}
|
|
|
|
|
|
</div>
|
2026-04-24 10:19:16 +08:00
|
|
|
|
<div className="scan-empty-copy">{staleReason}</div>
|
2026-04-24 00:13:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-04-26 00:24:11 +08:00
|
|
|
|
renderMainView()
|
2026-04-24 00:13:11 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
|
2026-04-24 13:35:36 +08:00
|
|
|
|
<CityDetailPanel variant="rail" />
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
2026-04-24 13:47:12 +08:00
|
|
|
|
<FutureForecastModal />
|
2026-04-25 00:19:30 +08:00
|
|
|
|
{showScanPaywall ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="modal-overlay"
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-label={isEn ? "Unlock market scan" : "解锁市场扫描"}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
if (event.target === event.currentTarget) {
|
|
|
|
|
|
setShowScanPaywall(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ProFeaturePaywall
|
|
|
|
|
|
feature="scan"
|
|
|
|
|
|
onClose={() => setShowScanPaywall(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-04-23 23:49:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function ScanTerminalDashboard() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<I18nProvider>
|
|
|
|
|
|
<DashboardStoreProvider>
|
|
|
|
|
|
<ScanTerminalScreen />
|
|
|
|
|
|
</DashboardStoreProvider>
|
|
|
|
|
|
</I18nProvider>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|