"use client"; import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { dashboardClient, getCityRevision, toCitySummary, } from "@/lib/dashboard-client"; import { CityDetail, CityListItem, CitySummary, DashboardState, HistoryPoint, HistoryState, LoadingState, MarketScan, } from "@/lib/dashboard-types"; interface DashboardStoreValue extends DashboardState { closeFutureModal: () => void; closeGuide: () => void; closeHistory: () => void; closePanel: () => void; ensureCityDetail: (cityName: string, force?: boolean) => Promise; futureModalDate: string | null; isGuideOpen: boolean; loadCities: () => Promise; openFutureModal: (dateStr: string) => void; openGuide: () => void; openHistory: () => Promise; openTodayModal: (forceRefresh?: boolean) => Promise; registerMapStopMotion: (stopMotion: () => void) => void; refreshAll: () => Promise; refreshSelectedCity: () => Promise; selectedMarketScan: MarketScan | null; selectedDetail: CityDetail | null; selectCity: (cityName: string) => Promise; setForecastDate: (dateStr: string | null) => void; marketScanByCityName: Record; } const DashboardStoreContext = createContext(null); function getInitialLoadingState(): LoadingState { return { cities: false, cityDetail: false, history: false, refresh: false, marketScan: false, }; } function getInitialHistoryState(): HistoryState { return { dataByCity: {}, error: null, isOpen: false, loading: false, }; } const AI_EMPTY_PATTERNS = [ /暂无\s*AI\s*分析/i, /当前以结构化气象与模型数据为主/i, /No\s*AI\s*analysis\s*available/i, /Structured\s+meteorological\s+and\s+model\s+data/i, ]; function normalizeText(value: unknown) { return typeof value === "string" ? value.trim() : ""; } function extractAiPayload(analysis: CityDetail["ai_analysis"]) { if (!analysis) { return { bullets: [] as string[], summary: "", }; } if (typeof analysis === "string") { return { bullets: [] as string[], summary: normalizeText(analysis), }; } const summary = normalizeText(analysis.summary) || normalizeText(analysis.text) || normalizeText(analysis.message); const bulletsSource = Array.isArray(analysis.highlights) ? analysis.highlights : Array.isArray(analysis.points) ? analysis.points : []; return { bullets: bulletsSource.map((item) => normalizeText(item)).filter(Boolean), summary, }; } function hasMeaningfulAiAnalysis(analysis: CityDetail["ai_analysis"]) { const parsed = extractAiPayload(analysis); const hasBullets = parsed.bullets.length > 0; const hasSummary = Boolean(parsed.summary) && !AI_EMPTY_PATTERNS.some((pattern) => pattern.test(parsed.summary)); return hasBullets || hasSummary; } function normalizeMetarSignature(detail?: CityDetail) { if (!detail) return ""; const metar = normalizeText(detail.current?.raw_metar) .replace(/\s+/g, " ") .toUpperCase(); const obsTime = normalizeText(detail.current?.obs_time); return [metar, obsTime].filter(Boolean).join("|"); } function mergeAiAnalysisIfStable( previousDetail: CityDetail | undefined, nextDetail: CityDetail, ) { if (!previousDetail) return nextDetail; if (hasMeaningfulAiAnalysis(nextDetail.ai_analysis)) return nextDetail; if (!hasMeaningfulAiAnalysis(previousDetail.ai_analysis)) return nextDetail; const prevTemp = Number(previousDetail.current?.temp); const nextTemp = Number(nextDetail.current?.temp); const tempUnchanged = Number.isFinite(prevTemp) && Number.isFinite(nextTemp) && prevTemp === nextTemp; const prevMetar = normalizeMetarSignature(previousDetail); const nextMetar = normalizeMetarSignature(nextDetail); const metarUnchanged = Boolean(prevMetar) && Boolean(nextMetar) && prevMetar === nextMetar; if (!tempUnchanged && !metarUnchanged) { return nextDetail; } return { ...nextDetail, ai_analysis: previousDetail.ai_analysis, }; } export function DashboardStoreProvider({ children, }: { children: React.ReactNode; }) { const initialCache = dashboardClient.readCityDetailCacheBundle(); const [cities, setCities] = useState([]); const [cityDetailsByName, setCityDetailsByName] = useState< Record >(() => initialCache.details); const [citySummariesByName, setCitySummariesByName] = useState< Record >(() => Object.fromEntries( Object.entries(initialCache.details).map(([cityName, detail]) => [ cityName, toCitySummary(detail), ]), ), ); const [cityDetailMetaByName, setCityDetailMetaByName] = useState< Record >(() => initialCache.meta); const [marketScanByCityName, setMarketScanByCityName] = useState< Record >({}); const [selectedCity, setSelectedCity] = useState(null); const [isPanelOpen, setIsPanelOpen] = useState(false); const [selectedForecastDate, setSelectedForecastDate] = useState< string | null >(null); const [futureModalDate, setFutureModalDate] = useState(null); const [loadingState, setLoadingState] = useState( getInitialLoadingState, ); const [historyState, setHistoryState] = useState( getInitialHistoryState, ); const [isGuideOpen, setIsGuideOpen] = useState(false); const mapStopMotionRef = useRef<() => void>(() => {}); const citySummariesRef = useRef>( Object.fromEntries( Object.entries(initialCache.details).map(([cityName, detail]) => [ cityName, toCitySummary(detail), ]), ), ); const selectedDetail = selectedCity ? cityDetailsByName[selectedCity] || null : null; const selectedMarketScan = selectedCity ? marketScanByCityName[selectedCity] || null : null; useEffect(() => { dashboardClient.writeCityDetailCacheBundle( cityDetailsByName, cityDetailMetaByName, ); }, [cityDetailMetaByName, cityDetailsByName]); useEffect(() => { citySummariesRef.current = citySummariesByName; }, [citySummariesByName]); const ensureCityDetail = async (cityName: string, force = false) => { const cached = cityDetailsByName[cityName]; const cachedMeta = cityDetailMetaByName[cityName]; if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) { return cached; } if (!force && cached) { try { const summary = await dashboardClient.getCitySummary(cityName); const revision = getCityRevision(summary); if (revision && revision === cachedMeta?.revision) { setCityDetailMetaByName((current) => ({ ...current, [cityName]: { cachedAt: Date.now(), revision, }, })); return cached; } } catch { return cached; } } const latestDetail = await dashboardClient.getCityDetail(cityName, { force, }); const detail = mergeAiAnalysisIfStable(cached, latestDetail); setCityDetailsByName((current) => ({ ...current, [cityName]: detail, })); setCitySummariesByName((current) => ({ ...current, [cityName]: toCitySummary(detail), })); setCityDetailMetaByName((current) => ({ ...current, [cityName]: { cachedAt: Date.now(), revision: getCityRevision(detail), }, })); return detail; }; const ensureCityMarketScan = async ( cityName: string, force = false, marketSlug?: string | null, ) => { const cached = marketScanByCityName[cityName]; if (!force && cached && !marketSlug) { return cached; } const latestScan = await dashboardClient.getCityMarketScan(cityName, { force, marketSlug, }); if (latestScan) { setMarketScanByCityName((current) => ({ ...current, [cityName]: latestScan, })); } return latestScan; }; const loadCities = async () => { setLoadingState((current) => ({ ...current, cities: true })); try { const nextCities = await dashboardClient.getCities(); setCities(nextCities); } finally { setLoadingState((current) => ({ ...current, cities: false })); } }; useEffect(() => { void loadCities(); }, []); useEffect(() => { if (!cities.length) return; const queue = cities .map((city) => city.name) .filter((cityName) => !citySummariesRef.current[cityName]); if (!queue.length) return; let active = true; const concurrency = 4; let cursor = 0; const worker = async () => { while (active && cursor < queue.length) { const cityName = queue[cursor]; cursor += 1; if (citySummariesRef.current[cityName]) continue; try { const summary = await dashboardClient.getCitySummary(cityName); if (!active) return; setCitySummariesByName((current) => { if (current[cityName]) return current; const next = { ...current, [cityName]: summary, }; citySummariesRef.current = next; return next; }); } catch {} } }; void Promise.all( Array.from({ length: Math.min(concurrency, queue.length) }, () => worker(), ), ); return () => { active = false; }; }, [cities]); const selectCity = async (cityName: string) => { setSelectedCity(cityName); setIsPanelOpen(true); setSelectedForecastDate(null); setFutureModalDate(null); setLoadingState((current) => ({ ...current, cityDetail: true })); try { const detail = await ensureCityDetail(cityName); setSelectedForecastDate(detail.local_date); // 预热市场数据,不做 await 阻塞,后台静默拉取 void ensureCityMarketScan(cityName, false).catch(() => {}); } finally { setLoadingState((current) => ({ ...current, cityDetail: false })); } }; const refreshSelectedCity = async () => { if (!selectedCity) return; setLoadingState((current) => ({ ...current, refresh: true })); try { const detail = await ensureCityDetail(selectedCity, true); setSelectedForecastDate(detail.local_date); } finally { setLoadingState((current) => ({ ...current, refresh: false })); } }; const refreshAll = async () => { const previousSelectedDetail = selectedCity ? cityDetailsByName[selectedCity] : undefined; dashboardClient.clearCityDetailCache(); setCityDetailsByName({}); setCityDetailMetaByName({}); if (selectedCity) { setLoadingState((current) => ({ ...current, refresh: true })); try { const latestDetail = await dashboardClient.getCityDetail(selectedCity, { force: true, }); const detail = mergeAiAnalysisIfStable( previousSelectedDetail, latestDetail, ); setCityDetailsByName({ [selectedCity]: detail }); setCitySummariesByName((current) => ({ ...current, [selectedCity]: toCitySummary(detail), })); setCityDetailMetaByName({ [selectedCity]: { cachedAt: Date.now(), revision: getCityRevision(detail), }, }); setSelectedForecastDate(detail.local_date); } finally { setLoadingState((current) => ({ ...current, refresh: false })); } } }; const openHistory = async () => { if (!selectedCity) return; setHistoryState((current) => ({ ...current, error: null, isOpen: true, loading: true, })); try { const history = await dashboardClient.getHistory(selectedCity); setHistoryState((current) => ({ ...current, dataByCity: { ...current.dataByCity, [selectedCity]: history, }, loading: false, })); } catch (error) { setHistoryState((current) => ({ ...current, error: String(error), loading: false, })); } }; const value = useMemo( () => ({ cities, cityDetailsByName, citySummariesByName, closeFutureModal: () => setFutureModalDate(null), closeGuide: () => setIsGuideOpen(false), closeHistory: () => setHistoryState((current) => ({ ...current, isOpen: false })), closePanel: () => { setIsPanelOpen(false); }, ensureCityDetail, futureModalDate, historyState, isPanelOpen, isGuideOpen, loadCities, loadingState, openFutureModal: (dateStr: string) => { mapStopMotionRef.current(); setFutureModalDate(dateStr); }, openGuide: () => setIsGuideOpen(true), openHistory, openTodayModal: async (forceRefresh?: boolean) => { if (!selectedCity || loadingState.cityDetail) { return; } mapStopMotionRef.current(); const cachedDetail = cityDetailsByName[selectedCity]; // 乐观 UI: 有缓存则立刻秒开 modal,不阻塞显示 if (cachedDetail?.local_date) { setSelectedForecastDate(cachedDetail.local_date); setFutureModalDate(cachedDetail.local_date); setLoadingState((current) => ({ ...current, marketScan: true })); } else { setLoadingState((current) => ({ ...current, refresh: true, marketScan: true, })); } // 异步静默拉取最新气象与市场数据 try { const detail = await ensureCityDetail(selectedCity, true); setSelectedForecastDate(detail.local_date); setFutureModalDate(detail.local_date); try { // 如果缓存里没有或者想要强制刷新,则拉取最新市场数据 await ensureCityMarketScan( selectedCity, forceRefresh || !marketScanByCityName[selectedCity], ); } catch {} } catch { if (cachedDetail?.local_date) { setFutureModalDate(cachedDetail.local_date); } } finally { setLoadingState((current) => ({ ...current, refresh: false, marketScan: false, })); } }, registerMapStopMotion: (stopMotion: () => void) => { mapStopMotionRef.current = stopMotion; }, refreshAll, refreshSelectedCity, selectedMarketScan, selectedCity, selectedDetail, selectedForecastDate, selectCity, setForecastDate: (dateStr: string | null) => setSelectedForecastDate(dateStr), marketScanByCityName, }), [ cities, cityDetailsByName, citySummariesByName, futureModalDate, historyState, isPanelOpen, isGuideOpen, loadingState, marketScanByCityName, selectedMarketScan, selectedCity, selectedDetail, selectedForecastDate, ], ); return ( {children} ); } export function useDashboardStore() { const context = useContext(DashboardStoreContext); if (!context) { throw new Error( "useDashboardStore must be used within DashboardStoreProvider", ); } return context; } export function useCityData(name?: string | null) { const store = useDashboardStore(); const key = name || store.selectedCity; return { data: key ? store.cityDetailsByName[key] || null : null, isLoading: store.loadingState.cityDetail && Boolean(key) && store.selectedCity === key, }; } export function useHistoryData(name?: string | null) { const store = useDashboardStore(); const key = name || store.selectedCity; return { data: key ? store.historyState.dataByCity[key] || ([] as HistoryPoint[]) : [], error: store.historyState.error, isLoading: store.historyState.loading, isOpen: store.historyState.isOpen, }; }