feat: implement METAR data collection service and dashboard infrastructure

This commit is contained in:
2569718930@qq.com
2026-04-11 20:11:01 +08:00
parent 69168d2fdf
commit 77b4d7b341
14 changed files with 660 additions and 148 deletions
+115 -53
View File
@@ -30,10 +30,14 @@ interface DashboardStoreValue extends DashboardState {
closeFutureModal: () => void;
closeHistory: () => void;
closePanel: () => void;
ensureCityDetail: (cityName: string, force?: boolean) => Promise<CityDetail>;
ensureCityDetail: (
cityName: string,
force?: boolean,
depth?: "panel" | "full",
) => Promise<CityDetail>;
futureModalDate: string | null;
loadCities: () => Promise<void>;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void;
@@ -90,6 +94,7 @@ const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
const EAGER_CITY_SUMMARIES_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES === "true";
type CityDetailDepth = "panel" | "full";
function countAvailableModels(
detail?: CityDetail | null,
@@ -128,6 +133,19 @@ function hasSparseDetailCoverage(
);
}
function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth {
return detail?.detail_depth === "panel" ? "panel" : "full";
}
function detailSatisfiesDepth(
detail: CityDetail | null | undefined,
depth: CityDetailDepth,
) {
if (!detail) return false;
if (depth === "panel") return true;
return normalizeDetailDepth(detail) === "full";
}
export function DashboardStoreProvider({
children,
}: {
@@ -171,6 +189,7 @@ export function DashboardStoreProvider({
const hydratedProCacheRef = useRef(false);
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
const citySummariesRef = useRef<Record<string, CitySummary>>({});
const selectedCityRef = useRef<string | null>(null);
const selectedDetail =
selectedCity && proAccess.subscriptionActive
? cityDetailsByName[selectedCity] || null
@@ -210,6 +229,10 @@ export function DashboardStoreProvider({
citySummariesRef.current = citySummariesByName;
}, [citySummariesByName]);
useEffect(() => {
selectedCityRef.current = selectedCity;
}, [selectedCity]);
useEffect(() => {
proAccessRef.current = proAccess;
}, [proAccess]);
@@ -273,6 +296,7 @@ export function DashboardStoreProvider({
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: false,
depth: normalizeDetailDepth(cached),
});
const detail = latestDetail;
@@ -295,13 +319,19 @@ export function DashboardStoreProvider({
.catch(() => {});
};
const ensureCityDetail = async (cityName: string, force = false) => {
const ensureCityDetail = async (
cityName: string,
force = false,
depth: CityDetailDepth = "panel",
) => {
const cached = cityDetailsByName[cityName];
const cachedMeta = cityDetailMetaByName[cityName];
const hasRequestedDepth = detailSatisfiesDepth(cached, depth);
const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date);
if (
!force &&
cached &&
hasRequestedDepth &&
!cachedIsSparse &&
dashboardClient.isCityDetailFresh(cachedMeta)
) {
@@ -309,7 +339,7 @@ export function DashboardStoreProvider({
return cached;
}
if (!force && cached) {
if (!force && cached && hasRequestedDepth) {
try {
const summary = await dashboardClient.getCitySummary(cityName);
const revision = getCityRevision(summary);
@@ -317,6 +347,7 @@ export function DashboardStoreProvider({
if (cachedIsSparse) {
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force: true,
depth,
});
const detail = latestDetail;
setCityDetailsByName((current) => ({
@@ -352,6 +383,7 @@ export function DashboardStoreProvider({
const latestDetail = await dashboardClient.getCityDetail(cityName, {
force,
depth,
});
const detail = latestDetail;
setCityDetailsByName((current) => ({
@@ -381,7 +413,7 @@ export function DashboardStoreProvider({
let cancelled = false;
setLoadingState((current) => ({ ...current, cityDetail: true }));
void ensureCityDetail(selectedCity, false)
void ensureCityDetail(selectedCity, false, "panel")
.then((detail) => {
if (cancelled) return;
setSelectedForecastDate(detail.local_date);
@@ -577,26 +609,35 @@ export function DashboardStoreProvider({
};
}, [cities]);
const ensureCitySummary = async (cityName: string, force = false) => {
const existing = citySummariesRef.current[cityName];
if (!force && existing) {
return existing;
}
const summary = await dashboardClient.getCitySummary(cityName, { force });
setCitySummariesByName((current) => ({
...current,
[cityName]: summary,
}));
return summary;
};
const selectCity = async (cityName: string) => {
setSelectedCity(cityName);
setIsPanelOpen(true);
setSelectedForecastDate(null);
setFutureModalDate(null);
const summaryPromise = !citySummariesRef.current[cityName]
? ensureCitySummary(cityName).catch(() => null)
: Promise.resolve(citySummariesRef.current[cityName]);
if (proAccessRef.current.loading) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
if (!citySummariesRef.current[cityName]) {
try {
const summary = await dashboardClient.getCitySummary(cityName);
setCitySummariesByName((current) => ({
...current,
[cityName]: summary,
}));
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
} else {
try {
await summaryPromise;
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
return;
@@ -605,18 +646,10 @@ export function DashboardStoreProvider({
const access = proAccessRef.current;
if (!access.authenticated || !access.subscriptionActive) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
if (!citySummariesRef.current[cityName]) {
try {
const summary = await dashboardClient.getCitySummary(cityName);
setCitySummariesByName((current) => ({
...current,
[cityName]: summary,
}));
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
} else {
try {
await summaryPromise;
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
return;
@@ -629,20 +662,28 @@ export function DashboardStoreProvider({
);
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
const detail = await ensureCityDetail(cityName, needsDetailRefresh);
setSelectedForecastDate(detail.local_date);
if (access.authenticated && access.subscriptionActive) {
// 预热市场数据,不做 await 阻塞,后台静默拉取
void ensureCityMarketScan(
cityName,
false,
null,
detail.local_date,
).catch(() => {});
}
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
await summaryPromise;
} catch {
}
void ensureCityDetail(cityName, needsDetailRefresh, "panel")
.then((detail) => {
if (selectedCityRef.current !== cityName) return;
setSelectedForecastDate(detail.local_date);
if (access.authenticated && access.subscriptionActive) {
// 预热市场数据,不做 await 阻塞,后台静默拉取
void ensureCityMarketScan(
cityName,
false,
null,
detail.local_date,
).catch(() => {});
}
})
.finally(() => {
if (selectedCityRef.current !== cityName) return;
setLoadingState((current) => ({ ...current, cityDetail: false }));
});
};
useEffect(() => {
@@ -678,7 +719,7 @@ export function DashboardStoreProvider({
if (!selectedCity) return;
setLoadingState((current) => ({ ...current, refresh: true }));
try {
const detail = await ensureCityDetail(selectedCity, true);
const detail = await ensureCityDetail(selectedCity, true, "panel");
setSelectedForecastDate(detail.local_date);
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
@@ -696,6 +737,7 @@ export function DashboardStoreProvider({
if (access.authenticated && access.subscriptionActive) {
const latestDetail = await dashboardClient.getCityDetail(selectedCity, {
force: true,
depth: "panel",
});
const detail = latestDetail;
setCityDetailsByName({ [selectedCity]: detail });
@@ -779,16 +821,31 @@ export function DashboardStoreProvider({
loadCities,
loadingState,
proAccess,
openFutureModal: (dateStr: string, forceRefresh = false) => {
openFutureModal: async (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current();
setFutureModalDate(dateStr);
if (!selectedCity || !proAccess.subscriptionActive) return;
const cachedDetail = cityDetailsByName[selectedCity];
const needsDetailRefresh =
!forceRefresh && hasSparseDetailCoverage(cachedDetail, dateStr);
if (needsDetailRefresh) {
void ensureCityDetail(selectedCity, true).catch(() => {});
const hasFullCachedDetail =
detailSatisfiesDepth(cachedDetail, "full") &&
!hasSparseDetailCoverage(cachedDetail, dateStr);
if (!hasFullCachedDetail || forceRefresh) {
setLoadingState((current) => ({
...current,
refresh: true,
}));
try {
await ensureCityDetail(selectedCity, true, "full");
} catch {
} finally {
setLoadingState((current) => ({
...current,
refresh: false,
}));
}
}
setFutureModalDate(dateStr);
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
setLoadingState((current) => ({ ...current, marketScan: true }));
void ensureCityMarketScan(
@@ -810,25 +867,30 @@ export function DashboardStoreProvider({
mapStopMotionRef.current();
const cachedDetail = cityDetailsByName[selectedCity];
if (cachedDetail?.local_date) {
const hasFullCachedDetail =
detailSatisfiesDepth(cachedDetail, "full") &&
!hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
if (hasFullCachedDetail && cachedDetail?.local_date) {
setSelectedForecastDate(cachedDetail.local_date);
setFutureModalDate(cachedDetail.local_date);
}
if (!proAccess.subscriptionActive) return;
const needsDetailRefresh =
!forceRefresh &&
forceRefresh ||
!detailSatisfiesDepth(cachedDetail, "full") ||
hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
setLoadingState((current) => ({
...current,
refresh: !cachedDetail?.local_date,
refresh: needsDetailRefresh,
marketScan: true,
}));
try {
const detail = await ensureCityDetail(
selectedCity,
Boolean(forceRefresh || needsDetailRefresh),
needsDetailRefresh,
"full",
);
setSelectedForecastDate(detail.local_date);
setFutureModalDate(detail.local_date);