Files
PolyWeather/frontend/hooks/useDashboardStore.tsx
T

591 lines
16 KiB
TypeScript

"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<CityDetail>;
futureModalDate: string | null;
isGuideOpen: boolean;
loadCities: () => Promise<void>;
openFutureModal: (dateStr: string) => void;
openGuide: () => void;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>;
refreshSelectedCity: () => Promise<void>;
selectedMarketScan: MarketScan | null;
selectedDetail: CityDetail | null;
selectCity: (cityName: string) => Promise<void>;
setForecastDate: (dateStr: string | null) => void;
marketScanByCityName: Record<string, MarketScan>;
}
const DashboardStoreContext = createContext<DashboardStoreValue | null>(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<CityListItem[]>([]);
const [cityDetailsByName, setCityDetailsByName] = useState<
Record<string, CityDetail>
>(() => initialCache.details);
const [citySummariesByName, setCitySummariesByName] = useState<
Record<string, CitySummary>
>(() =>
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
);
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
Record<string, { cachedAt: number; revision: string }>
>(() => initialCache.meta);
const [marketScanByCityName, setMarketScanByCityName] = useState<
Record<string, MarketScan>
>({});
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [selectedForecastDate, setSelectedForecastDate] = useState<
string | null
>(null);
const [futureModalDate, setFutureModalDate] = useState<string | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>(
getInitialLoadingState,
);
const [historyState, setHistoryState] = useState<HistoryState>(
getInitialHistoryState,
);
const [isGuideOpen, setIsGuideOpen] = useState(false);
const mapStopMotionRef = useRef<() => void>(() => {});
const citySummariesRef = useRef<Record<string, CitySummary>>(
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<DashboardStoreValue>(
() => ({
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 (
<DashboardStoreContext.Provider value={value}>
{children}
</DashboardStoreContext.Provider>
);
}
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,
};
}