feat: Phase 2 — leadership 大屏 (/overview) + district normalization + drawer a11y
Phase 2 of the UX modernization. Three conflict-free workstreams. Leadership 驾驶舱 (/overview): - Wuhan 13-district Leaflet choropleth (public/wuhan_districts.geojson, keyed on name, darker=higher per 高风险高亮), legend, hover/click-zoom - 全部/门诊/住院 Segmented toggle drives choropleth + Top-5 district bar - literal "数据截至2023-12" as-of badge (D3 honesty); raw spinner → LoadingState - decompose OverviewDashboard 501→273; 6 components + 2 helpers under components/overview/ District normalization (backend data boundary): - case_loader.normalize_district + load_cases_by_district_daily collapse the 26 dirty labels (武昌/武昌区…) → 13 canonical; analysis/grid/insights repointed (fixes a grid-merge row-drop bug as a bonus); in-memory, schema unchanged Shell a11y (code-review carryover): - drawer is now a proper modal: ESC, body scroll-lock, focus-in + focus-trap cycle + focus-restore, role=dialog/aria-modal/aria-label, hamburger aria-expanded - SideNav expanded state lifted to AppShell so rail+drawer stay in sync - RouteErrorBoundary around <Outlet/> keeps shell chrome on page/chunk failure Gates: tsc 0 · vitest 64 · e2e 19/19 (17 user-flows + 2 overview) · build ok · backend pytest 6 new + 48 regression green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,9 @@
|
||||
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 { Activity } from 'lucide-react';
|
||||
import { caseApi, riskApi, alertApi, envApi } from '@/services/api';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { LoadingState, Segmented } from '@/components/ui';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import type {
|
||||
CaseTrendPoint,
|
||||
DistrictCaseData,
|
||||
@@ -33,27 +11,30 @@ import type {
|
||||
DiagnosisBreakdown,
|
||||
Alert,
|
||||
} from '@/types';
|
||||
import { KpiRow, type KpiData } from '@/components/overview/KpiRow';
|
||||
import { CaseAqiTrend, type MergedTrendItem } from '@/components/overview/CaseAqiTrend';
|
||||
import { DistrictChoropleth } from '@/components/overview/DistrictChoropleth';
|
||||
import { TopDistrictsBar } from '@/components/overview/TopDistrictsBar';
|
||||
import { TopDiagnosesBar } from '@/components/overview/TopDiagnosesBar';
|
||||
import { AlertSeverityDonut, type AlertSlice } from '@/components/overview/AlertSeverityDonut';
|
||||
import { CHART_COLORS } from '@/components/overview/chartColors';
|
||||
import {
|
||||
joinDistrictCases,
|
||||
buildMetricLookup,
|
||||
type MetricKey,
|
||||
} from '@/components/overview/districtNormalize';
|
||||
|
||||
// --- Types for fetched data ---
|
||||
interface KpiData {
|
||||
totalCases: number;
|
||||
todayCases: number;
|
||||
changeRatio: number | null;
|
||||
activeAlerts: number;
|
||||
highRiskGrids: number;
|
||||
avgAQI: number;
|
||||
}
|
||||
const METRIC_OPTIONS: { value: MetricKey; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'outpatient', label: '门诊' },
|
||||
{ value: 'inpatient', label: '住院' },
|
||||
];
|
||||
|
||||
interface MergedTrendItem {
|
||||
date: string;
|
||||
cases: number;
|
||||
aqi: number;
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
const METRIC_LABEL: Record<MetricKey, string> = {
|
||||
all: '病例',
|
||||
outpatient: '门诊',
|
||||
inpatient: '住院',
|
||||
};
|
||||
|
||||
function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
||||
if (trend.length < 8) return null;
|
||||
@@ -66,12 +47,15 @@ function computeChangeRatio(trend: CaseTrendPoint[]): number | null {
|
||||
export function OverviewDashboard() {
|
||||
const [kpi, setKpi] = useState<KpiData | null>(null);
|
||||
const [mergedTrend, setMergedTrend] = useState<MergedTrendItem[]>([]);
|
||||
const [topDistricts, setTopDistricts] = useState<DistrictCaseData[]>([]);
|
||||
const [districts, setDistricts] = useState<DistrictCaseData[]>([]);
|
||||
const [topDiagnoses, setTopDiagnoses] = useState<DiagnosisBreakdown[]>([]);
|
||||
const [alertPie, setAlertPie] = useState<{ name: string; value: number; color: string }[]>([]);
|
||||
const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
|
||||
const [metric, setMetric] = useState<MetricKey>('all');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -88,7 +72,7 @@ export function OverviewDashboard() {
|
||||
start30.setDate(start30.getDate() - 30);
|
||||
const start30Str = start30.toISOString().split('T')[0];
|
||||
|
||||
// KPI sources — Promise.allSettled to survive individual failures
|
||||
// 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' }),
|
||||
@@ -97,7 +81,7 @@ export function OverviewDashboard() {
|
||||
envApi.getPollutants(7),
|
||||
]);
|
||||
|
||||
// Trend sources
|
||||
// Trend + district sources.
|
||||
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
||||
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
||||
caseApi.getDistricts(),
|
||||
@@ -108,7 +92,7 @@ export function OverviewDashboard() {
|
||||
|
||||
const newErrors: string[] = [];
|
||||
|
||||
// --- Build KPI ---
|
||||
// --- KPI ---
|
||||
let totalCases = 0;
|
||||
if (statsR.status === 'fulfilled') {
|
||||
const s = statsR.value;
|
||||
@@ -121,9 +105,7 @@ export function OverviewDashboard() {
|
||||
let changeRatio: number | null = null;
|
||||
if (trend14R.status === 'fulfilled') {
|
||||
const trend = trend14R.value.trend || [];
|
||||
if (trend.length > 0) {
|
||||
todayCases = trend[trend.length - 1].total;
|
||||
}
|
||||
if (trend.length > 0) todayCases = trend[trend.length - 1].total;
|
||||
changeRatio = computeChangeRatio(trend);
|
||||
} else {
|
||||
newErrors.push('今日病例数据加载失败');
|
||||
@@ -158,33 +140,24 @@ export function OverviewDashboard() {
|
||||
}
|
||||
|
||||
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);
|
||||
for (const p of pollutantData) aqiMap[p.date] = p.AQI || 0;
|
||||
setMergedTrend(
|
||||
trend30.map((t) => ({ date: t.date, cases: t.total, aqi: aqiMap[t.date] || 0 }))
|
||||
);
|
||||
} else if (!newErrors.includes('今日病例数据加载失败')) {
|
||||
newErrors.push('趋势数据加载失败');
|
||||
}
|
||||
|
||||
// --- Top 5 Districts ---
|
||||
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
|
||||
if (districtsR.status === 'fulfilled') {
|
||||
const districts = districtsR.value.districts || [];
|
||||
const sorted = [...districts].sort((a, b) => b.total - a.total);
|
||||
setTopDistricts(sorted.slice(0, 5));
|
||||
setDistricts(districtsR.value.districts || []);
|
||||
} else {
|
||||
newErrors.push('区县数据加载失败');
|
||||
}
|
||||
|
||||
// --- Top 5 Diagnoses ---
|
||||
@@ -204,43 +177,33 @@ export function OverviewDashboard() {
|
||||
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' },
|
||||
{ name: 'P1 紧急', value: p1, color: CHART_COLORS.alertP1 },
|
||||
{ name: 'P2 关注', value: p2, color: CHART_COLORS.alertP2 },
|
||||
]);
|
||||
|
||||
setErrors(newErrors);
|
||||
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]);
|
||||
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
|
||||
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
|
||||
const metricLookup = useMemo(
|
||||
() => buildMetricLookup(joinedDistricts, metric),
|
||||
[joinedDistricts, metric]
|
||||
);
|
||||
|
||||
// --- 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 <LoadingState label="加载概览数据…" testid={TESTIDS.pageLoading} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="page-overview" className="flex flex-col h-full overflow-auto">
|
||||
{/* Error banner */}
|
||||
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
|
||||
{errors.length > 0 && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
@@ -252,249 +215,58 @@ export function OverviewDashboard() {
|
||||
)}
|
||||
|
||||
<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趋势
|
||||
{/* Page header + honesty badge + metric toggle */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-primary" />
|
||||
综合概览
|
||||
<span
|
||||
data-testid={TESTIDS.asofBadge}
|
||||
className="ml-1 inline-flex items-center rounded-full bg-bg-hover px-2 py-0.5 text-[11px] font-medium text-text-secondary border border-border"
|
||||
>
|
||||
数据截至2023-12
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-[12px] text-text-secondary">病例、环境与预警关键指标总览</p>
|
||||
</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>
|
||||
)}
|
||||
<Segmented
|
||||
options={METRIC_OPTIONS}
|
||||
value={metric}
|
||||
onChange={setMetric}
|
||||
testid={TESTIDS.outinpatientToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section 3 + 4: Top Districts + Top Diagnoses side by side */}
|
||||
{/* KPI Row */}
|
||||
<KpiRow kpi={kpi} />
|
||||
|
||||
{/* Headline: Wuhan 13-district choropleth */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-3">
|
||||
武汉市13区{METRIC_LABEL[metric]}分布(高风险高亮)
|
||||
</div>
|
||||
<DistrictChoropleth
|
||||
metricLookup={metricLookup}
|
||||
metricLabel={`${METRIC_LABEL[metric]}数`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Case + AQI trend */}
|
||||
<CaseAqiTrend data={mergedTrend} />
|
||||
|
||||
{/* Top districts (metric-driven) + Top diagnoses */}
|
||||
<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>
|
||||
<TopDistrictsBar
|
||||
districts={joinedDistricts}
|
||||
metric={metric}
|
||||
metricLabel={METRIC_LABEL[metric]}
|
||||
/>
|
||||
<TopDiagnosesBar diagnoses={topDiagnoses} />
|
||||
</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>
|
||||
{/* Alert severity donut */}
|
||||
<AlertSeverityDonut data={alertPie} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user