feat: add multi-model Open-Meteo data collection and implement scan terminal dashboard service

This commit is contained in:
2569718930@qq.com
2026-04-26 06:55:16 +08:00
parent 901b870240
commit b25c9312da
4 changed files with 369 additions and 52 deletions
@@ -466,6 +466,10 @@ function isFullEnoughForDeepAnalysis(detail?: CityDetail | null) {
);
}
function waitForDeepAnalysisQueue(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale } = useI18n();
const chartData = useMemo(
@@ -674,7 +678,7 @@ function AiPinnedCityCard({
body: JSON.stringify({
city: detailCityName,
force_refresh: aiRefreshToken > 0,
locale,
locale: "zh-CN",
}),
})
.then(async (response) => {
@@ -715,7 +719,7 @@ function AiPinnedCityCard({
return () => {
cancelled = true;
};
}, [aiForecastKey, aiRefreshToken, detailCityName, locale]);
}, [aiForecastKey, aiRefreshToken, detailCityName]);
const aiCityForecast = aiForecast.payload?.city_forecast || null;
const localizedFinalJudgment =
@@ -1215,6 +1219,8 @@ function ScanTerminalScreen() {
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
const lastMapSelectedCityRef = useRef<string>("");
const aiFullHydrationRef = useRef<Set<string>>(new Set());
const aiHydrationQueueRef = useRef<string[]>([]);
const aiHydrationRunningRef = useRef(false);
const timeSortedRows = useMemo(
() => sortRowsByUserTime(terminalData?.rows || []),
@@ -1373,6 +1379,48 @@ function ScanTerminalScreen() {
}
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
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],
);
const addAiPinnedCity = useCallback((cityName: string) => {
const cleanName = String(cityName || "").trim();
const key = normalizeCityKey(cleanName);
@@ -1404,22 +1452,15 @@ function ScanTerminalScreen() {
}
return [nextItem, ...current].slice(0, 8);
});
aiFullHydrationRef.current.delete(key);
aiFullHydrationRef.current.add(key);
void store
.ensureCityDetail(cleanName, true, "full")
.then((detail) => {
if (!isFullEnoughForDeepAnalysis(detail)) {
aiFullHydrationRef.current.delete(key);
}
})
.catch(() => {
aiFullHydrationRef.current.delete(key);
});
}, [locale, store.ensureCityDetail, timeSortedRows]);
queueAiFullHydration(matchedRow?.city || cleanName);
}, [locale, queueAiFullHydration, timeSortedRows]);
const removeAiPinnedCity = useCallback((cityName: string) => {
const key = normalizeCityKey(cityName);
aiFullHydrationRef.current.delete(key);
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
(queuedCity) => normalizeCityKey(queuedCity) !== key,
);
setAiPinnedCities((current) =>
current.filter((item) => normalizeCityKey(item.cityName) !== key),
);
@@ -1432,14 +1473,9 @@ function ScanTerminalScreen() {
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
if (!needsFullHydration) return;
aiFullHydrationRef.current.add(key);
void store
.ensureCityDetail(item.cityName, Boolean(detail), "full")
.catch(() => {
aiFullHydrationRef.current.delete(key);
});
queueAiFullHydration(item.cityName);
});
}, [aiPinnedCities, store.cityDetailsByName, store.ensureCityDetail]);
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
const handleMapCitySelect = useCallback((cityName: string) => {
setMapSelectedCityName(cityName);
+234 -18
View File
@@ -101,6 +101,10 @@ function getInitialProAccessState(): ProAccessState {
if (isBrowserLocalFullAccess()) {
return getLocalDevProAccessState();
}
const cached = readStoredProAccess();
if (cached) {
return cached;
}
return {
loading: true,
authenticated: false,
@@ -116,13 +120,124 @@ function getInitialProAccessState(): ProAccessState {
}
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const PRO_ACCESS_STORAGE_KEY = "polyWeather_pro_access_v1";
const CITY_LOAD_RETRY_DELAYS_MS = [700, 1600];
const PRO_ACCESS_FALLBACK_TTL_MS = 24 * 60 * 60 * 1000;
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
type StoredProAccessState = ProAccessState & {
cachedAt: number;
expiresAtMs: number;
version: 1;
};
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function getSubscriptionExpiryMs(access: Pick<
ProAccessState,
"subscriptionExpiresAt" | "subscriptionTotalExpiresAt"
>) {
const raw =
access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt || "";
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : 0;
}
function clearStoredProAccess() {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(PRO_ACCESS_STORAGE_KEY);
} catch {
// Ignore storage failures; backend auth remains the source of truth.
}
}
function readStoredProAccess(): ProAccessState | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(PRO_ACCESS_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredProAccessState>;
if (parsed.version !== 1) {
clearStoredProAccess();
return null;
}
if (!parsed.authenticated || !parsed.subscriptionActive || !parsed.userId) {
clearStoredProAccess();
return null;
}
const expiresAtMs = Number(parsed.expiresAtMs || 0);
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
clearStoredProAccess();
return null;
}
return {
loading: false,
authenticated: true,
userId: String(parsed.userId),
subscriptionActive: true,
subscriptionPlanCode: parsed.subscriptionPlanCode ?? null,
subscriptionExpiresAt: parsed.subscriptionExpiresAt ?? null,
subscriptionTotalExpiresAt: parsed.subscriptionTotalExpiresAt ?? null,
subscriptionQueuedDays: Math.max(
0,
Number(parsed.subscriptionQueuedDays ?? 0),
),
points: Number(parsed.points ?? 0),
error: null,
};
} catch {
clearStoredProAccess();
return null;
}
}
function writeStoredProAccess(access: ProAccessState) {
if (typeof window === "undefined") return;
if (!access.authenticated || !access.subscriptionActive || !access.userId) {
clearStoredProAccess();
return;
}
const explicitExpiryMs = getSubscriptionExpiryMs(access);
const expiresAtMs =
explicitExpiryMs > Date.now()
? explicitExpiryMs
: Date.now() + PRO_ACCESS_FALLBACK_TTL_MS;
const payload: StoredProAccessState = {
...access,
loading: false,
error: null,
cachedAt: Date.now(),
expiresAtMs,
version: 1,
};
try {
window.localStorage.setItem(PRO_ACCESS_STORAGE_KEY, JSON.stringify(payload));
} catch {
// Storage can be unavailable in private mode; keep the in-memory state.
}
}
function mergeWithStoredProAccess(
next: ProAccessState,
reason: string,
): ProAccessState {
if (next.subscriptionActive || !next.authenticated) return next;
const cached = readStoredProAccess();
if (!cached) return next;
if (next.userId && cached.userId !== next.userId) return next;
const payloadExpiryMs = getSubscriptionExpiryMs(next);
if (payloadExpiryMs > 0 && payloadExpiryMs <= Date.now()) return next;
return {
...cached,
loading: false,
points: Math.max(cached.points, next.points),
error: reason,
};
}
async function buildAuthMeHeaders(): Promise<HeadersInit> {
const headers: Record<string, string> = {
Accept: "application/json",
@@ -247,6 +362,52 @@ function hasMeaningfulDailyModelMap(
);
}
function countModelMapEntries(value: Record<string, number | null> | undefined) {
if (!value || typeof value !== "object") return 0;
return Object.values(value).filter((entry) => Number.isFinite(Number(entry))).length;
}
function pickRicherModelMap(
currentValue: CityDetail["multi_model"] | undefined,
incomingValue: CityDetail["multi_model"] | undefined,
) {
return countModelMapEntries(incomingValue) >= countModelMapEntries(currentValue)
? incomingValue || currentValue
: currentValue;
}
function mergeDailyModelMap(
currentValue: CityDetail["multi_model_daily"] | undefined,
incomingValue: CityDetail["multi_model_daily"] | undefined,
) {
if (!hasMeaningfulDailyModelMap(incomingValue)) return currentValue;
if (!hasMeaningfulDailyModelMap(currentValue)) return incomingValue;
const merged = { ...(currentValue || {}) };
Object.entries(incomingValue || {}).forEach(([date, incomingDay]) => {
const currentDay = merged[date];
const incomingCount = countModelMapEntries(incomingDay?.models || undefined);
const currentCount = countModelMapEntries(currentDay?.models || undefined);
if (incomingCount >= currentCount) {
merged[date] = {
...(currentDay || {}),
...(incomingDay || {}),
models: incomingDay?.models || currentDay?.models,
};
}
});
return merged;
}
function pickRicherForecast(
currentValue: CityDetail["forecast"] | undefined,
incomingValue: CityDetail["forecast"] | undefined,
) {
return countForecastDays({ forecast: incomingValue } as CityDetail) >=
countForecastDays({ forecast: currentValue } as CityDetail)
? incomingValue || currentValue
: currentValue;
}
function pickPreferredNearbyStations(
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
@@ -264,12 +425,17 @@ function mergeCityDetail(
incoming: CityDetail,
): CityDetail {
if (!current) return incoming;
if (incoming.detail_depth !== "market") return incoming;
const currentDepth = normalizeDetailDepth(current);
const incomingDepth = normalizeDetailDepth(incoming);
const mergedDepth =
current.detail_depth === "full" || current.detail_depth === "nearby"
? current.detail_depth
: incoming.detail_depth;
currentDepth === "full" || incomingDepth === "full"
? "full"
: currentDepth === "nearby" || incomingDepth === "nearby"
? "nearby"
: currentDepth === "market" || incomingDepth === "market"
? "market"
: "panel";
return {
...current,
@@ -280,16 +446,12 @@ function mergeCityDetail(
deb: incoming.deb || current.deb,
probabilities: incoming.probabilities || current.probabilities,
trend: incoming.trend || current.trend,
multi_model: hasMeaningfulModelMap(incoming.multi_model)
? incoming.multi_model
: current.multi_model,
multi_model_daily: hasMeaningfulDailyModelMap(incoming.multi_model_daily)
? {
...(current.multi_model_daily || {}),
...(incoming.multi_model_daily || {}),
}
: current.multi_model_daily,
forecast: current.forecast || incoming.forecast,
multi_model: pickRicherModelMap(current.multi_model, incoming.multi_model),
multi_model_daily: mergeDailyModelMap(
current.multi_model_daily,
incoming.multi_model_daily,
),
forecast: pickRicherForecast(current.forecast, incoming.forecast),
official_nearby: pickPreferredNearbyStations(
current.official_nearby,
incoming.official_nearby,
@@ -641,12 +803,14 @@ export function DashboardStoreProvider({
const refreshProAccess = async () => {
if (isBrowserLocalFullAccess()) {
setProAccess(getLocalDevProAccessState());
const localAccess = getLocalDevProAccessState();
writeStoredProAccess(localAccess);
setProAccess(localAccess);
return;
}
setProAccess((current) => ({
...current,
loading: true,
loading: current.subscriptionActive ? false : true,
error: null,
}));
try {
@@ -667,8 +831,10 @@ export function DashboardStoreProvider({
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
points?: number;
degraded_auth_profile?: boolean | null;
degraded_reason?: string | null;
};
setProAccess({
const nextAccess: ProAccessState = {
loading: false,
authenticated: Boolean(payload.authenticated),
userId: payload.user_id ?? null,
@@ -683,8 +849,30 @@ export function DashboardStoreProvider({
),
points: payload.points ?? 0,
error: null,
});
};
const mergedAccess = mergeWithStoredProAccess(
nextAccess,
payload.degraded_auth_profile
? String(payload.degraded_reason || "degraded_auth_profile")
: "using_cached_pro_access",
);
if (mergedAccess.subscriptionActive) {
writeStoredProAccess(mergedAccess);
} else if (!mergedAccess.authenticated || payload.subscription_active === false) {
clearStoredProAccess();
}
setProAccess(mergedAccess);
} catch (error) {
const cachedAccess = readStoredProAccess();
if (cachedAccess) {
setProAccess({
...cachedAccess,
loading: false,
error: String(error),
});
return;
}
clearStoredProAccess();
setProAccess({
loading: false,
authenticated: false,
@@ -727,6 +915,34 @@ export function DashboardStoreProvider({
void refreshProAccess();
}, []);
useEffect(() => {
if (!hasSupabasePublicEnv()) return;
const {
data: { subscription },
} = getSupabaseBrowserClient().auth.onAuthStateChange((event) => {
if (event === "SIGNED_OUT") {
clearStoredProAccess();
setProAccess({
loading: false,
authenticated: false,
userId: null,
subscriptionActive: false,
subscriptionPlanCode: null,
subscriptionExpiresAt: null,
subscriptionTotalExpiresAt: null,
subscriptionQueuedDays: 0,
points: 0,
error: null,
});
return;
}
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
void refreshProAccess();
}
});
return () => subscription.unsubscribe();
}, []);
const ensureCitySummary = async (cityName: string, force = false) => {
const existing = citySummariesRef.current[cityName];
if (!force && existing) {