502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
|
|
import { useEffect, useState, useMemo } from 'react';
|
|||
|
|
import {
|
|||
|
|
LineChart,
|
|||
|
|
Line,
|
|||
|
|
BarChart,
|
|||
|
|
Bar,
|
|||
|
|
PieChart,
|
|||
|
|
Pie,
|
|||
|
|
Cell,
|
|||
|
|
XAxis,
|
|||
|
|
YAxis,
|
|||
|
|
CartesianGrid,
|
|||
|
|
Tooltip,
|
|||
|
|
Legend,
|
|||
|
|
ResponsiveContainer,
|
|||
|
|
} from 'recharts';
|
|||
|
|
import {
|
|||
|
|
Activity,
|
|||
|
|
AlertTriangle,
|
|||
|
|
Droplets,
|
|||
|
|
Building2,
|
|||
|
|
TrendingUp,
|
|||
|
|
TrendingDown,
|
|||
|
|
Users,
|
|||
|
|
} from 'lucide-react';
|
|||
|
|
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
|
|||
|
|
import { StatCard } from '@/components/StatCard';
|
|||
|
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
|||
|
|
import type {
|
|||
|
|
CaseTrendPoint,
|
|||
|
|
DistrictCaseData,
|
|||
|
|
PollutantPoint,
|
|||
|
|
DiagnosisBreakdown,
|
|||
|
|
Alert,
|
|||
|
|
} from '@/types';
|
|||
|
|
|
|||
|
|
// --- Types for fetched data ---
|
|||
|
|
interface KpiData {
|
|||
|
|
totalCases: number;
|
|||
|
|
todayCases: number;
|
|||
|
|
changeRatio: number | null;
|
|||
|
|
activeAlerts: number;
|
|||
|
|
highRiskGrids: number;
|
|||
|
|
avgAQI: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface MergedTrendItem {
|
|||
|
|
date: string;
|
|||
|
|
cases: number;
|
|||
|
|
aqi: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDateLabel(dateStr: string): string {
|
|||
|
|
const d = new Date(dateStr);
|
|||
|
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
|||
|
|
if (trend.length < 8) return null;
|
|||
|
|
const recent7 = trend.slice(-7).reduce((s, p) => s + p.total, 0);
|
|||
|
|
const prior7 = trend.slice(-14, -7).reduce((s, p) => s + p.total, 0);
|
|||
|
|
if (prior7 === 0) return null;
|
|||
|
|
return ((recent7 - prior7) / prior7) * 100;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function OverviewDashboard() {
|
|||
|
|
const [kpi, setKpi] = useState<KpiData | null>(null);
|
|||
|
|
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
|
|||
|
|
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
|
|||
|
|
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
|
|||
|
|
const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]);
|
|||
|
|
const [isLoading, setIsLoading] = useState(true);
|
|||
|
|
const [errors, setErrors] = useState<string[]>([]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false;
|
|||
|
|
|
|||
|
|
const fetchAll = async () => {
|
|||
|
|
setIsLoading(true);
|
|||
|
|
setErrors([]);
|
|||
|
|
|
|||
|
|
const now = new Date();
|
|||
|
|
const endStr = now.toISOString().split('T')[0];
|
|||
|
|
const start14 = new Date(now);
|
|||
|
|
start14.setDate(start14.getDate() - 14);
|
|||
|
|
const start14Str = start14.toISOString().split('T')[0];
|
|||
|
|
const start30 = new Date(now);
|
|||
|
|
start30.setDate(start30.getDate() - 30);
|
|||
|
|
const start30Str = start30.toISOString().split('T')[0];
|
|||
|
|
|
|||
|
|
// KPI sources — Promise.allSettled to survive individual failures
|
|||
|
|
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
|||
|
|
caseApi.getStats(),
|
|||
|
|
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
|||
|
|
alertApi.getAlerts(),
|
|||
|
|
riskApi.getStats(),
|
|||
|
|
envApi.getPollutants(7),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Trend sources
|
|||
|
|
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
|||
|
|
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
|||
|
|
caseApi.getDistricts(),
|
|||
|
|
caseApi.getStats(), // reuse for top_diagnoses
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
if (cancelled) return;
|
|||
|
|
|
|||
|
|
const newErrors: string[] = [];
|
|||
|
|
|
|||
|
|
// --- Build KPI ---
|
|||
|
|
let totalCases = 0;
|
|||
|
|
if (statsR.status === 'fulfilled') {
|
|||
|
|
const s = statsR.value;
|
|||
|
|
totalCases = (s.total_outpatient || 0) + (s.total_inpatient || 0);
|
|||
|
|
} else {
|
|||
|
|
newErrors.push('累计病例数据加载失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let todayCases = 0;
|
|||
|
|
let changeRatio: number | null = null;
|
|||
|
|
if (trend14R.status === 'fulfilled') {
|
|||
|
|
const trend = trend14R.value.trend || [];
|
|||
|
|
if (trend.length > 0) {
|
|||
|
|
todayCases = trend[trend.length - 1].total;
|
|||
|
|
}
|
|||
|
|
changeRatio = computeChangeRatio(trend);
|
|||
|
|
} else {
|
|||
|
|
newErrors.push('今日病例数据加载失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let activeAlerts = 0;
|
|||
|
|
let alertList: Alert[] = [];
|
|||
|
|
if (alertsR.status === 'fulfilled') {
|
|||
|
|
alertList = alertsR.value.alerts || [];
|
|||
|
|
activeAlerts = alertList.length;
|
|||
|
|
} else {
|
|||
|
|
newErrors.push('预警数据加载失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let highRiskGrids = 0;
|
|||
|
|
if (riskStatsR.status === 'fulfilled') {
|
|||
|
|
highRiskGrids = riskStatsR.value.high_risk_count || 0;
|
|||
|
|
} else {
|
|||
|
|
newErrors.push('风险网格数据加载失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let avgAQI = 0;
|
|||
|
|
let pollutantData: PollutantPoint[] = [];
|
|||
|
|
if (pollutantsR.status === 'fulfilled') {
|
|||
|
|
pollutantData = pollutantsR.value.data || [];
|
|||
|
|
if (pollutantData.length > 0) {
|
|||
|
|
const sumAQI = pollutantData.reduce((s, p) => s + (p.AQI || 0), 0);
|
|||
|
|
avgAQI = Math.round(sumAQI / pollutantData.length);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
newErrors.push('AQI数据加载失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
|||
|
|
setErrors(newErrors);
|
|||
|
|
|
|||
|
|
// --- Merge case trend + AQI ---
|
|||
|
|
if (trend30R.status === 'fulfilled') {
|
|||
|
|
const trend30 = trend30R.value.trend || [];
|
|||
|
|
const aqiMap: Record<string, number> = {};
|
|||
|
|
if (pollutantsR.status === 'fulfilled') {
|
|||
|
|
for (const p of pollutantData) {
|
|||
|
|
aqiMap[p.date] = p.AQI || 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Only use data from the last 30 days for display
|
|||
|
|
const merged: MergedTrendItem[] = trend30.map((t) => ({
|
|||
|
|
date: t.date,
|
|||
|
|
cases: t.total,
|
|||
|
|
aqi: aqiMap[t.date] || 0,
|
|||
|
|
}));
|
|||
|
|
setMergedTrend(merged);
|
|||
|
|
} else if (!newErrors.includes('今日病例数据加载失败')) {
|
|||
|
|
newErrors.push('趋势数据加载失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Top 5 Districts ---
|
|||
|
|
if (districtsR.status === 'fulfilled') {
|
|||
|
|
const districts = districtsR.value.districts || [];
|
|||
|
|
const sorted = [...districts].sort((a, b) => b.total - a.total);
|
|||
|
|
setTopDistricts(sorted.slice(0, 5));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Top 5 Diagnoses ---
|
|||
|
|
if (diagStatsR.status === 'fulfilled') {
|
|||
|
|
const topDiag = diagStatsR.value.top_diagnoses || [];
|
|||
|
|
setTopDiagnoses(
|
|||
|
|
topDiag.slice(0, 5).map((d) => ({
|
|||
|
|
diagnosis: d.diagnosis,
|
|||
|
|
outpatient: d.outpatient,
|
|||
|
|
inpatient: d.inpatient,
|
|||
|
|
total: d.outpatient + d.inpatient,
|
|||
|
|
}))
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Alert severity donut ---
|
|||
|
|
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
|||
|
|
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
|||
|
|
setAlertPie([
|
|||
|
|
{ name: 'P1 紧急', value: p1, color: '#EF4444' },
|
|||
|
|
{ name: 'P2 关注', value: p2, color: '#F59E0B' },
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
setIsLoading(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
fetchAll();
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true;
|
|||
|
|
};
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const changeTrend = useMemo(() => {
|
|||
|
|
if (kpi?.changeRatio == null) return undefined;
|
|||
|
|
if (kpi.changeRatio > 0) {
|
|||
|
|
return { direction: 'up' as const, value: `${kpi.changeRatio.toFixed(1)}%` };
|
|||
|
|
}
|
|||
|
|
if (kpi.changeRatio < 0) {
|
|||
|
|
return { direction: 'down' as const, value: `${Math.abs(kpi.changeRatio).toFixed(1)}%` };
|
|||
|
|
}
|
|||
|
|
return { direction: 'stable' as const, value: '0%' };
|
|||
|
|
}, [kpi?.changeRatio]);
|
|||
|
|
|
|||
|
|
// --- Loading state ---
|
|||
|
|
if (isLoading) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center justify-center h-64">
|
|||
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col h-full overflow-auto">
|
|||
|
|
{/* Error banner */}
|
|||
|
|
{errors.length > 0 && (
|
|||
|
|
<div className="px-6 pt-4">
|
|||
|
|
<ErrorBanner
|
|||
|
|
error={errors.join(';')}
|
|||
|
|
onRetry={() => window.location.reload()}
|
|||
|
|
onDismiss={() => setErrors([])}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="p-6 space-y-6">
|
|||
|
|
{/* Page header */}
|
|||
|
|
<div>
|
|||
|
|
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
|||
|
|
<Activity className="w-5 h-5 text-primary" />
|
|||
|
|
综合概览
|
|||
|
|
</h1>
|
|||
|
|
<p className="text-[12px] text-gray-500">病例、环境与预警关键指标总览</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Section 1: KPI Row */}
|
|||
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
|||
|
|
<StatCard
|
|||
|
|
icon={<Users className="w-4 h-4 text-blue-600" />}
|
|||
|
|
label="累计病例总数"
|
|||
|
|
value={kpi?.totalCases?.toLocaleString() ?? '--'}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={<Activity className="w-4 h-4 text-green-600" />}
|
|||
|
|
label="今日病例"
|
|||
|
|
value={kpi?.todayCases?.toLocaleString() ?? '--'}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={
|
|||
|
|
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-red-500" />) ||
|
|||
|
|
(changeTrend?.direction === 'down' && <TrendingDown className="w-4 h-4 text-green-500" />) || (
|
|||
|
|
<Activity className="w-4 h-4 text-gray-400" />
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
label="7日变化率"
|
|||
|
|
value={changeTrend ? changeTrend.value : '--'}
|
|||
|
|
trend={changeTrend}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={<AlertTriangle className="w-4 h-4 text-orange-500" />}
|
|||
|
|
label="活跃预警数"
|
|||
|
|
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
|
|||
|
|
color={kpi && kpi.activeAlerts > 0 ? '#EF4444' : undefined}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={<Building2 className="w-4 h-4 text-red-500" />}
|
|||
|
|
label="高风险网格"
|
|||
|
|
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
|
|||
|
|
/>
|
|||
|
|
<StatCard
|
|||
|
|
icon={<Droplets className="w-4 h-4 text-cyan-500" />}
|
|||
|
|
label="平均AQI"
|
|||
|
|
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Section 2: Case + AQI Mini Trend */}
|
|||
|
|
<div className="card p-4">
|
|||
|
|
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|||
|
|
近30日病例与AQI趋势
|
|||
|
|
</div>
|
|||
|
|
{mergedTrend.length > 0 ? (
|
|||
|
|
<ResponsiveContainer width="100%" height={200}>
|
|||
|
|
<LineChart data={mergedTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
|||
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
|||
|
|
<XAxis
|
|||
|
|
dataKey="date"
|
|||
|
|
tickFormatter={formatDateLabel}
|
|||
|
|
tick={{ fontSize: 10, fill: '#64748B' }}
|
|||
|
|
interval="preserveStartEnd"
|
|||
|
|
axisLine={{ stroke: '#E2E8F0' }}
|
|||
|
|
/>
|
|||
|
|
<YAxis
|
|||
|
|
yAxisId="left"
|
|||
|
|
tick={{ fontSize: 10, fill: '#64748B' }}
|
|||
|
|
axisLine={{ stroke: '#E2E8F0' }}
|
|||
|
|
/>
|
|||
|
|
<YAxis
|
|||
|
|
yAxisId="right"
|
|||
|
|
orientation="right"
|
|||
|
|
tick={{ fontSize: 10, fill: '#F59E0B' }}
|
|||
|
|
axisLine={{ stroke: '#E2E8F0' }}
|
|||
|
|
/>
|
|||
|
|
<Tooltip
|
|||
|
|
contentStyle={{
|
|||
|
|
backgroundColor: '#FFFFFF',
|
|||
|
|
border: '1px solid #E2E8F0',
|
|||
|
|
borderRadius: '8px',
|
|||
|
|
fontSize: '12px',
|
|||
|
|
}}
|
|||
|
|
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
|||
|
|
/>
|
|||
|
|
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
|||
|
|
<Line
|
|||
|
|
yAxisId="left"
|
|||
|
|
type="monotone"
|
|||
|
|
dataKey="cases"
|
|||
|
|
name="病例数"
|
|||
|
|
stroke="#3B82F6"
|
|||
|
|
strokeWidth={2}
|
|||
|
|
dot={false}
|
|||
|
|
activeDot={{ r: 3 }}
|
|||
|
|
/>
|
|||
|
|
<Line
|
|||
|
|
yAxisId="right"
|
|||
|
|
type="monotone"
|
|||
|
|
dataKey="aqi"
|
|||
|
|
name="AQI"
|
|||
|
|
stroke="#F59E0B"
|
|||
|
|
strokeWidth={2}
|
|||
|
|
strokeDasharray="5 5"
|
|||
|
|
dot={false}
|
|||
|
|
activeDot={{ r: 3 }}
|
|||
|
|
/>
|
|||
|
|
</LineChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
) : (
|
|||
|
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
|
|||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|||
|
|
{/* Section 3: Top 5 Districts */}
|
|||
|
|
<div className="card p-4">
|
|||
|
|
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|||
|
|
Top 5 区县病例分布
|
|||
|
|
</div>
|
|||
|
|
{topDistricts.length > 0 ? (
|
|||
|
|
<ResponsiveContainer width="100%" height={220}>
|
|||
|
|
<BarChart
|
|||
|
|
data={[...topDistricts].reverse()}
|
|||
|
|
layout="vertical"
|
|||
|
|
margin={{ top: 0, right: 10, left: 30, bottom: 0 }}
|
|||
|
|
>
|
|||
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
|||
|
|
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
|||
|
|
<YAxis
|
|||
|
|
type="category"
|
|||
|
|
dataKey="district"
|
|||
|
|
tick={{ fontSize: 11, fill: '#374151' }}
|
|||
|
|
width={60}
|
|||
|
|
axisLine={false}
|
|||
|
|
tickLine={false}
|
|||
|
|
/>
|
|||
|
|
<Tooltip
|
|||
|
|
contentStyle={{
|
|||
|
|
backgroundColor: '#FFFFFF',
|
|||
|
|
border: '1px solid #E2E8F0',
|
|||
|
|
borderRadius: '8px',
|
|||
|
|
fontSize: '12px',
|
|||
|
|
}}
|
|||
|
|
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
|||
|
|
/>
|
|||
|
|
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={20} />
|
|||
|
|
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={20} />
|
|||
|
|
</BarChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
) : (
|
|||
|
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Section 4: Top 5 Diagnoses */}
|
|||
|
|
<div className="card p-4">
|
|||
|
|
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|||
|
|
Top 5 诊断分布
|
|||
|
|
</div>
|
|||
|
|
{topDiagnoses.length > 0 ? (
|
|||
|
|
<ResponsiveContainer width="100%" height={220}>
|
|||
|
|
<BarChart
|
|||
|
|
data={[...topDiagnoses].reverse()}
|
|||
|
|
layout="vertical"
|
|||
|
|
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
|||
|
|
>
|
|||
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
|||
|
|
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
|||
|
|
<YAxis
|
|||
|
|
type="category"
|
|||
|
|
dataKey="diagnosis"
|
|||
|
|
tick={{ fontSize: 11, fill: '#374151' }}
|
|||
|
|
width={100}
|
|||
|
|
axisLine={false}
|
|||
|
|
tickLine={false}
|
|||
|
|
/>
|
|||
|
|
<Tooltip
|
|||
|
|
contentStyle={{
|
|||
|
|
backgroundColor: '#FFFFFF',
|
|||
|
|
border: '1px solid #E2E8F0',
|
|||
|
|
borderRadius: '8px',
|
|||
|
|
fontSize: '12px',
|
|||
|
|
}}
|
|||
|
|
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
|||
|
|
/>
|
|||
|
|
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
|||
|
|
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
|||
|
|
</BarChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
) : (
|
|||
|
|
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Section 5: Alert Severity Donut */}
|
|||
|
|
<div className="card p-4">
|
|||
|
|
<div className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-4">
|
|||
|
|
预警严重度分布
|
|||
|
|
</div>
|
|||
|
|
{alertPie[0].value > 0 || alertPie[1].value > 0 ? (
|
|||
|
|
<div className="flex items-center justify-center">
|
|||
|
|
<ResponsiveContainer width="100%" height={240}>
|
|||
|
|
<PieChart>
|
|||
|
|
<Pie
|
|||
|
|
data={alertPie}
|
|||
|
|
cx="50%"
|
|||
|
|
cy="50%"
|
|||
|
|
innerRadius={50}
|
|||
|
|
outerRadius={80}
|
|||
|
|
paddingAngle={4}
|
|||
|
|
dataKey="value"
|
|||
|
|
nameKey="name"
|
|||
|
|
>
|
|||
|
|
{alertPie.map((entry, idx) => (
|
|||
|
|
<Cell key={idx} fill={entry.color} />
|
|||
|
|
))}
|
|||
|
|
</Pie>
|
|||
|
|
<Tooltip
|
|||
|
|
contentStyle={{
|
|||
|
|
backgroundColor: '#FFFFFF',
|
|||
|
|
border: '1px solid #E2E8F0',
|
|||
|
|
borderRadius: '8px',
|
|||
|
|
fontSize: '12px',
|
|||
|
|
}}
|
|||
|
|
formatter={(value: number, name: string) => [value, name]}
|
|||
|
|
/>
|
|||
|
|
<Legend
|
|||
|
|
wrapperStyle={{ fontSize: '12px' }}
|
|||
|
|
formatter={(value: string) => (
|
|||
|
|
<span className="text-gray-700">{value}</span>
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
</PieChart>
|
|||
|
|
</ResponsiveContainer>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="text-center py-8 text-gray-400 text-sm">暂无预警数据</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|