Files
CA/frontend/src/services/api.ts
Akiba So e64ca3b4f5 fix: analysis 500s, caching, alert page perf
P0: Fix KeyError in 3 analysis endpoints. geojson.py stores 1d risk
as "risk_value" but analysis.py accessed "risk_1d" — always crashed.

Backend: Add lru_cache to GeoJSON/CSV/Parquet loaders, date helpers,
and district loader. Add try/except and FileNotFoundError guards.

Frontend: Debounce riskRange, merge counts into useMemo, stabilize
handleGridClick with ref, memoize nearest-grid scan, wrap AlertMap
in React.memo, switch useLodGrid from fetch to cachedGet.
2026-06-05 02:27:10 +08:00

204 lines
5.7 KiB
TypeScript

import axios from 'axios';
import type {
RiskMapResponse,
GridDetailResponse,
AlertResponse,
Stats,
ForecastDay,
CaseTrendResponse,
DistrictCaseResponse,
CaseStatsResponse,
CaseGridResponse,
GeocodedCasesResponse,
} from '@/types';
interface CacheEntry<T> {
data: T;
timestamp: number;
promise?: Promise<T>;
}
const CACHE_TTL = 30000;
const cache = new Map<string, CacheEntry<any>>();
const pendingControllers = new Map<string, AbortController>();
function getCacheKey(url: string, params?: Record<string, any>): string {
if (!params) return url;
const sorted = Object.entries(params)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&');
return sorted ? `${url}?${sorted}` : url;
}
function getCached<T>(key: string): T | undefined {
const entry = cache.get(key);
if (!entry) return undefined;
if (Date.now() - entry.timestamp > CACHE_TTL) {
cache.delete(key);
return undefined;
}
return entry.data;
}
function setCache<T>(key: string, data: T): void {
cache.set(key, { data, timestamp: Date.now() });
}
function clearPending(key: string): void {
const controller = pendingControllers.get(key);
if (controller) {
controller.abort();
pendingControllers.delete(key);
}
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
timeout: 30000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('cbpoa_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
const key = getCacheKey(config.url || '', config.params);
const controller = new AbortController();
config.signal = controller.signal;
clearPending(key);
pendingControllers.set(key, controller);
return config;
});
api.interceptors.response.use(
(response) => {
const key = getCacheKey(response.config.url || '', response.config.params);
pendingControllers.delete(key);
return response;
},
(error) => {
if (error.config) {
const key = getCacheKey(error.config.url || '', error.config.params);
pendingControllers.delete(key);
}
return Promise.reject(error);
}
);
export async function cachedGet<T>(url: string, params?: Record<string, any>): Promise<T> {
const key = getCacheKey(url, params);
const cached = getCached<T>(key);
if (cached !== undefined) return cached;
const entry = cache.get(key);
if (entry?.promise) return entry.promise;
const promise = api.get<T>(url, { params }).then((res) => {
setCache(key, res.data);
const updated = cache.get(key);
if (updated) updated.promise = undefined;
return res.data;
});
cache.set(key, { data: undefined as T, timestamp: Date.now(), promise });
return promise;
}
export const riskApi = {
getCurrentRiskMap: (): Promise<RiskMapResponse> => cachedGet('/risk/current'),
getForecast: (days: ForecastDay): Promise<RiskMapResponse> => {
const d = days === 0 ? '' : `/${days}`;
return cachedGet(`/risk/forecast${d}`);
},
getGridDetail: (gridId: string): Promise<GridDetailResponse> =>
cachedGet(`/risk/grid/${encodeURIComponent(gridId)}`),
getStats: (): Promise<Stats> => cachedGet('/risk/stats'),
};
export const alertApi = {
getAlerts: (params?: {
min_risk?: number;
priority?: string;
region?: string;
}): Promise<AlertResponse> => cachedGet('/alerts', params),
getAlertRules: (): Promise<any> => cachedGet('/alerts/rules'),
};
export const historyApi = {
getHistory: (params: {
grid_id?: string;
region?: string;
days?: number;
}): Promise<any> => cachedGet('/history', params),
getTrend: (gridId: string, days: number = 7): Promise<any> =>
cachedGet('/history/trend', { grid_id: gridId, days }),
};
export const caseApi = {
getTrend: (days: number = 7): Promise<CaseTrendResponse> =>
cachedGet('/cases/trend', { days }),
getDistricts: (): Promise<DistrictCaseResponse> => cachedGet('/cases/districts'),
getStats: (): Promise<CaseStatsResponse> => cachedGet('/cases/stats'),
getGrid: (): Promise<CaseGridResponse> => cachedGet('/cases/grid'),
getGeocoded: (limit: number = 5000): Promise<GeocodedCasesResponse> =>
cachedGet('/cases/geocoded', { limit }),
};
export function clearApiCache(): void {
cache.clear();
}
export function cancelPendingRequests(): void {
pendingControllers.forEach((controller) => controller.abort());
pendingControllers.clear();
}
export const gridApi = {
getHistoricalAggregated: (
startDate: string,
endDate: string,
aggregation: 'daily' | 'weekly' | 'monthly' = 'daily',
district?: string
): Promise<any> => {
const params: Record<string, string> = { start_date: startDate, end_date: endDate, aggregation };
if (district) params.district = district;
return cachedGet('/history/aggregated', params);
},
getGridsGeoJSON: (date: string, district?: string): Promise<any> => {
const params: Record<string, string> = { date };
if (district) params.district = district;
return cachedGet('/grids/geojson', params);
},
getMultiDayPrediction: async (date: string, days: number = 7, district?: string): Promise<any> => {
const response = await api.post('/predict/multi-day', { date, days, district });
return response.data;
},
getGridHistory: (gridId: string, days: number = 30): Promise<any> =>
cachedGet(`/grids/${encodeURIComponent(gridId)}/history`, { days }),
};
export const analysisApi = {
getTrend: (days: number = 7): Promise<any> => cachedGet('/analysis/trend', { days }),
getDistricts: (): Promise<any> => cachedGet('/analysis/districts'),
};
export const insightsApi = {
getOverview: (): Promise<any> => cachedGet('/insights/overview'),
};
export default api;