Files
TJWaterFrontend_Refine/src/components/olmap/HealthRiskAnalysis/HealthRiskReport.tsx
T

747 lines
24 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useRef } from "react";
import ReactECharts from "echarts-for-react";
import {
Box,
Button,
Dialog,
GlobalStyles,
IconButton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import {
Close,
PrintOutlined,
WaterDropOutlined,
} from "@mui/icons-material";
import { PredictionResult } from "./types";
import { buildHealthRiskReportData } from "./healthRiskReport";
interface HealthRiskReportProps {
open: boolean;
onClose: () => void;
predictionResults: PredictionResult[];
currentYear: number;
analysisQueryTime: string | null;
networkName: string;
generatedAt: Date;
}
const REPORT_BLUE = "#0b4f87";
const REPORT_INK = "#172435";
const REPORT_MUTED = "#5f6f80";
const REPORT_LINE = "#d8e0e8";
const reportFontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif';
const formatDateTime = (value: Date | string | null) => {
if (!value) return "未记录";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "未记录";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
};
const formatPercent = (value: number | null, fractionDigits = 1) =>
value === null ? "无有效数据" : `${(value * 100).toFixed(fractionDigits)}%`;
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<Stack
direction="row"
alignItems="center"
spacing={1.25}
sx={{ mb: 1.5 }}
>
<Box
aria-hidden="true"
sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }}
/>
<Typography
component="h2"
sx={{
color: REPORT_INK,
fontSize: 17,
fontWeight: 700,
lineHeight: 1.4,
}}
>
{children}
</Typography>
</Stack>
);
const HealthRiskReport: React.FC<HealthRiskReportProps> = ({
open,
onClose,
predictionResults,
currentYear,
analysisQueryTime,
networkName,
generatedAt,
}) => {
const printStateRef = useRef<{ title: string } | null>(null);
const reportData = useMemo(
() => buildHealthRiskReportData(predictionResults, currentYear),
[currentYear, predictionResults],
);
useEffect(
() => () => {
if (printStateRef.current) {
document.title = printStateRef.current.title;
document.body.classList.remove("health-risk-report-printing");
}
},
[],
);
const restoreAfterPrint = () => {
if (!printStateRef.current) return;
document.title = printStateRef.current.title;
printStateRef.current = null;
document.body.classList.remove("health-risk-report-printing");
};
const handlePrint = () => {
if (printStateRef.current) return;
printStateRef.current = { title: document.title };
document.title = `管网健康风险体检报告-${networkName || "未命名管网"}-${new Intl.DateTimeFormat(
"zh-CN",
{ year: "numeric", month: "2-digit", day: "2-digit" },
)
.format(generatedAt)
.replaceAll("/", "-")}`;
document.body.classList.add("health-risk-report-printing");
window.addEventListener("afterprint", restoreAfterPrint, { once: true });
window.print();
};
const distributionOption = {
animation: false,
grid: { top: 4, right: 38, bottom: 28, left: 148 },
xAxis: {
type: "value",
name: "管段数",
minInterval: 1,
axisLine: { lineStyle: { color: "#8b99a8" } },
axisLabel: { color: REPORT_MUTED, fontSize: 10 },
splitLine: { lineStyle: { color: "#e7edf2" } },
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
},
yAxis: {
type: "category",
inverse: true,
data: reportData.distribution.map((item) => item.label),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: REPORT_INK, fontSize: 10 },
},
tooltip: { show: false },
series: [
{
type: "bar",
barMaxWidth: 14,
data: reportData.distribution.map((item) => ({
value: item.count,
itemStyle: { color: item.color },
})),
label: {
show: true,
position: "right",
color: REPORT_INK,
fontSize: 10,
},
},
],
};
const trendOption = {
animation: false,
grid: { top: 24, right: 24, bottom: 38, left: 48 },
xAxis: {
type: "category",
name: "预测年份",
boundaryGap: false,
data: reportData.trend.map((item) => item.year),
axisLine: { lineStyle: { color: "#8b99a8" } },
axisLabel: {
color: REPORT_MUTED,
fontSize: 10,
interval: Math.max(0, Math.ceil(reportData.trend.length / 9) - 1),
},
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
},
yAxis: {
type: "value",
name: "较高及以上风险管段数",
minInterval: 1,
axisLine: { show: false },
axisLabel: { color: REPORT_MUTED, fontSize: 10 },
splitLine: { lineStyle: { color: "#e7edf2" } },
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
},
tooltip: { show: false },
series: [
{
type: "line",
smooth: true,
symbol: "none",
lineStyle: { color: "#c83d3d", width: 2.5 },
areaStyle: { color: "rgba(200, 61, 61, 0.08)" },
data: reportData.trend.map((item) => item.count),
},
],
};
const metricItems = [
{ label: "分析管段", value: reportData.totalCount.toLocaleString("zh-CN") },
{ label: "有效结果", value: reportData.validCount.toLocaleString("zh-CN") },
{
label: "平均生存概率",
value: formatPercent(reportData.averageProbability),
},
{
label: "较高及以上风险",
value: `${reportData.highRiskCount.toLocaleString("zh-CN")} 条`,
detail: formatPercent(reportData.highRiskRatio),
danger: reportData.highRiskCount > 0,
},
];
return (
<>
<GlobalStyles
styles={{
"@page": { size: "A4 portrait", margin: 0 },
"@media print": {
"html, body": {
width: "210mm",
minHeight: "297mm",
margin: 0,
padding: 0,
backgroundColor: "#fff",
},
"body.health-risk-report-printing > *:not(.health-risk-report-dialog)":
{
display: "none !important",
},
".health-risk-report-dialog": {
position: "static !important",
display: "block !important",
},
".health-risk-report-dialog .MuiBackdrop-root, .health-risk-report-actions":
{
display: "none !important",
},
".health-risk-report-dialog .MuiDialog-container": {
display: "block !important",
height: "auto !important",
},
".health-risk-report-dialog .MuiDialog-paper": {
width: "210mm !important",
maxWidth: "none !important",
minHeight: "297mm !important",
maxHeight: "none !important",
margin: "0 !important",
overflow: "visible !important",
boxShadow: "none !important",
},
".health-risk-report-preview": {
padding: "0 !important",
overflow: "visible !important",
backgroundColor: "#fff !important",
},
"#health-risk-print-root": {
width: "210mm !important",
minHeight: "297mm !important",
padding: "12mm !important",
boxShadow: "none !important",
printColorAdjust: "exact",
WebkitPrintColorAdjust: "exact",
},
".health-risk-report-section, .health-risk-report-metrics": {
breakInside: "avoid",
pageBreakInside: "avoid",
},
".health-risk-report-table-row": {
breakInside: "avoid",
pageBreakInside: "avoid",
},
},
}}
/>
<Dialog
open={open}
onClose={onClose}
maxWidth={false}
fullWidth
className="health-risk-report-dialog"
slotProps={{
paper: {
sx: {
width: "100%",
height: "100%",
maxHeight: "100%",
m: 0,
borderRadius: 0,
bgcolor: "#e8edf2",
},
},
}}
>
<Stack
className="health-risk-report-actions"
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{
position: "sticky",
top: 0,
zIndex: 2,
minHeight: 64,
px: { xs: 1.5, sm: 3 },
bgcolor: "#fff",
boxShadow: "0 1px 4px rgba(23, 36, 53, 0.16)",
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<IconButton
onClick={onClose}
aria-label="关闭报告预览"
sx={{ width: 40, height: 40 }}
>
<Close />
</IconButton>
<Box>
<Typography sx={{ fontWeight: 700, color: REPORT_INK }}>
报告预览
</Typography>
<Typography variant="caption" sx={{ color: REPORT_MUTED }}>
A4 纵向,可在打印窗口另存为 PDF
</Typography>
</Box>
</Stack>
<Button
variant="contained"
startIcon={<PrintOutlined />}
onClick={handlePrint}
sx={{
minHeight: 40,
borderRadius: 1.5,
bgcolor: REPORT_BLUE,
"&:hover": { bgcolor: "#083f6d" },
"&:active": { transform: "scale(0.96)" },
}}
>
打印
</Button>
</Stack>
<Box
className="health-risk-report-preview"
sx={{
flex: 1,
overflow: "auto",
px: { xs: 0, sm: 2 },
py: { xs: 0, sm: 3 },
}}
>
<Box
id="health-risk-print-root"
lang="zh-CN"
sx={{
boxSizing: "border-box",
width: { xs: "100%", sm: "min(210mm, 100%)" },
minHeight: "297mm",
mx: "auto",
p: { xs: 2, sm: "12mm" },
bgcolor: "#fff",
color: REPORT_INK,
boxShadow: { xs: "none", sm: "0 12px 32px rgba(23,36,53,0.16)" },
fontFamily: reportFontFamily,
fontVariantNumeric: "tabular-nums",
}}
>
<Box
component="header"
sx={{
bgcolor: REPORT_BLUE,
color: "#f4f8fb",
px: { xs: 2.5, sm: 4 },
py: { xs: 2.5, sm: 3.5 },
}}
>
<Stack
direction={{ xs: "column", sm: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", sm: "center" }}
spacing={2}
>
<Box>
<Stack direction="row" alignItems="center" spacing={1}>
<WaterDropOutlined aria-hidden="true" />
<Typography
lang="en"
sx={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.08em" }}
>
TJWATER
</Typography>
</Stack>
<Typography
component="h1"
sx={{
mt: 1.5,
fontSize: { xs: 24, sm: 30 },
fontWeight: 700,
lineHeight: 1.25,
textWrap: "balance",
}}
>
管网健康风险体检报告
</Typography>
<Typography
sx={{
mt: 0.75,
color: "#cfe1ef",
fontSize: 13,
lineHeight: 1.7,
}}
>
Pipeline health risk assessment
</Typography>
</Box>
<Box
sx={{
minWidth: { sm: 168 },
width: { xs: "100%", sm: "auto" },
pt: { xs: 2, sm: 0 },
pl: { xs: 0, sm: 3 },
borderTop: {
xs: "1px solid rgba(224, 238, 248, 0.28)",
sm: "none",
},
borderLeft: {
xs: "none",
sm: "1px solid rgba(224, 238, 248, 0.28)",
},
}}
>
<Stack direction="row" alignItems="center" spacing={0.8}>
<Box
aria-hidden="true"
sx={{
width: 7,
height: 7,
flexShrink: 0,
borderRadius: "50%",
bgcolor:
reportData.highRiskCount > 0 ? "#ffb3a7" : "#9dd7bd",
}}
/>
<Typography sx={{ color: "#cfe1ef", fontSize: 11 }}>
本期提示
</Typography>
</Stack>
<Typography
sx={{
mt: 0.75,
fontWeight: 700,
fontSize: 20,
lineHeight: 1.35,
}}
>
{reportData.highRiskCount > 0 ? "需重点关注" : "持续监测"}
</Typography>
</Box>
</Stack>
</Box>
<Box
component="dl"
sx={{
m: 0,
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)" },
borderBottom: `1px solid ${REPORT_LINE}`,
}}
>
{[
["管网项目", networkName || "未命名管网"],
["预测时点", `第 ${currentYear} 年`],
["分析数据时间", formatDateTime(analysisQueryTime)],
["报告生成时间", formatDateTime(generatedAt)],
].map(([label, value]) => (
<Box
key={label}
sx={{
display: "grid",
gridTemplateColumns: "96px 1fr",
gap: 1,
px: 2,
py: 1.25,
borderBottom: { xs: `1px solid ${REPORT_LINE}`, sm: "none" },
}}
>
<Typography
component="dt"
sx={{ color: REPORT_MUTED, fontSize: 12 }}
>
{label}
</Typography>
<Typography
component="dd"
sx={{
m: 0,
color: REPORT_INK,
fontSize: 12,
fontWeight: 600,
overflowWrap: "anywhere",
}}
>
{value}
</Typography>
</Box>
))}
</Box>
<Box
component="section"
className="health-risk-report-section"
sx={{ mt: 3 }}
>
<SectionTitle>总体结论</SectionTitle>
<Box
sx={{
bgcolor:
reportData.highRiskCount > 0 ? "#fff4f1" : "#eff8f4",
px: 2.25,
py: 1.75,
}}
>
<Typography sx={{ fontSize: 13, lineHeight: 1.8, color: REPORT_INK }}>
{reportData.conclusion}
</Typography>
{reportData.invalidCount > 0 && (
<Typography sx={{ mt: 0.5, fontSize: 11, color: REPORT_MUTED }}>
另有 {reportData.invalidCount} 条管段在当前预测年份缺少有效结果,未纳入本期统计。
</Typography>
)}
</Box>
</Box>
<Box
className="health-risk-report-metrics"
sx={{
mt: 2.25,
display: "grid",
gridTemplateColumns: { xs: "repeat(2, 1fr)", sm: "repeat(4, 1fr)" },
borderTop: `1px solid ${REPORT_LINE}`,
borderBottom: `1px solid ${REPORT_LINE}`,
}}
>
{metricItems.map((item, index) => (
<Box
key={item.label}
sx={{
px: 1.5,
py: 1.75,
borderRight:
index < metricItems.length - 1
? `1px solid ${REPORT_LINE}`
: "none",
}}
>
<Typography sx={{ color: REPORT_MUTED, fontSize: 11 }}>
{item.label}
</Typography>
<Typography
sx={{
mt: 0.5,
color: item.danger ? "#a62f2f" : REPORT_INK,
fontSize: 20,
fontWeight: 700,
lineHeight: 1.25,
}}
>
{item.value}
</Typography>
{item.detail && (
<Typography sx={{ mt: 0.25, color: REPORT_MUTED, fontSize: 10 }}>
占有效结果 {item.detail}
</Typography>
)}
</Box>
))}
</Box>
<Box
component="section"
className="health-risk-report-section"
sx={{ mt: 3 }}
>
<SectionTitle>当前年份风险分布</SectionTitle>
<Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}>
生存概率越低,表示模型预测风险越高。统计口径与地图风险图例一致。
</Typography>
<ReactECharts
option={distributionOption}
style={{ width: "100%", height: 300 }}
opts={{ renderer: "svg" }}
notMerge
/>
</Box>
<Box
component="section"
className="health-risk-report-section"
sx={{ mt: 3 }}
>
<SectionTitle>重点风险趋势</SectionTitle>
<Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}>
展示各预测年份中生存概率不高于 0.3 的管段数量。
</Typography>
<ReactECharts
option={trendOption}
style={{ width: "100%", height: 240 }}
opts={{ renderer: "svg" }}
notMerge
/>
</Box>
<Box component="section" sx={{ mt: 3 }}>
<SectionTitle>重点风险管段</SectionTitle>
<Typography sx={{ mb: 1.25, color: REPORT_MUTED, fontSize: 11 }}>
按当前预测年份的生存概率从低到高排列,最多展示 20 条。
</Typography>
{reportData.priorityPipes.length > 0 ? (
<TableContainer>
<Table
size="small"
aria-label="重点风险管段"
sx={{ tableLayout: "fixed" }}
>
<TableHead>
<TableRow sx={{ bgcolor: "#edf3f7" }}>
<TableCell sx={{ width: 52, fontWeight: 700 }}>序号</TableCell>
<TableCell sx={{ width: "34%", fontWeight: 700 }}>
管段编号
</TableCell>
<TableCell align="right" sx={{ width: 110, fontWeight: 700 }}>
生存概率
</TableCell>
<TableCell sx={{ fontWeight: 700 }}>风险等级</TableCell>
</TableRow>
</TableHead>
<TableBody>
{reportData.priorityPipes.map((pipe, index) => (
<TableRow
key={`${pipe.linkId}-${index}`}
className="health-risk-report-table-row"
>
<TableCell>{index + 1}</TableCell>
<TableCell
sx={{ fontWeight: 600, overflowWrap: "anywhere" }}
>
{pipe.linkId}
</TableCell>
<TableCell align="right">
{formatPercent(pipe.probability, 2)}
</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<Box
aria-hidden="true"
sx={{
width: 9,
height: 9,
flexShrink: 0,
bgcolor: pipe.color,
borderRadius: "50%",
}}
/>
<Typography sx={{ fontSize: 12 }}>
{pipe.riskLabel}
</Typography>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Box sx={{ bgcolor: "#f4f7f9", px: 2, py: 2 }}>
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
当前预测年份没有较高及以上风险管段。
</Typography>
</Box>
)}
</Box>
<Box
component="section"
className="health-risk-report-section"
sx={{ mt: 3 }}
>
<SectionTitle>处置建议</SectionTitle>
<Box component="ol" sx={{ m: 0, pl: 3 }}>
{reportData.recommendations.map((recommendation) => (
<Typography
component="li"
key={recommendation}
sx={{
pl: 0.5,
mb: 0.75,
color: REPORT_INK,
fontSize: 12,
lineHeight: 1.75,
}}
>
{recommendation}
</Typography>
))}
</Box>
</Box>
<Box
component="footer"
sx={{
mt: 3.5,
pt: 1.5,
borderTop: `1px solid ${REPORT_LINE}`,
display: "flex",
justifyContent: "space-between",
gap: 2,
color: REPORT_MUTED,
}}
>
<Typography sx={{ fontSize: 10 }}>TJWater 管网分析平台</Typography>
<Typography sx={{ fontSize: 10, textAlign: "right" }}>
风险阈值:生存概率 0.3
</Typography>
</Box>
</Box>
</Box>
</Dialog>
</>
);
};
export default HealthRiskReport;