feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-05 02:27:10 +08:00
|
|
|
export async function cachedGet<T>(url: string, params?: Record<string, any>): Promise<T> {
|
feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
Context: Build a spatial risk assessment system correlating air quality
data with children's respiratory disease incidence across Wuhan.
Approach: FastAPI backend serving PostGIS spatial queries, React
frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline
for multi-day (1d/3d/7d) risk prediction.
Changes:
- backend/ — FastAPI API with auth (JWT), alerts, risk analysis,
geocoded case data, grid statistics, and report endpoints
- frontend/ — React dashboard with interactive risk maps, alert
monitoring, district comparison charts, and timeline player
- models/ — SpatialTemporalGCN model with trained weights and ONNX
export for inference
- scripts/ — ETL pipeline for weather + medical data, grid generation,
feature engineering, training, and daily inference
- deploy/ — Docker Compose configs for backend, frontend, and MLflow
- docs/ — API docs, deployment guide, user guide, and code review
Impact: Enables spatial risk visualization, alert monitoring, and
ML-driven health risk forecasting for environmental health teams.
2026-06-05 02:13:49 +08:00
|
|
|
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;
|