591 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|