feat(health-risk): add printable assessment report

This commit is contained in:
2026-07-30 11:52:43 +08:00
parent 5894ee277a
commit 82e75c03d0
8 changed files with 1189 additions and 6 deletions
@@ -8,6 +8,8 @@ interface HealthRiskContextType {
setPredictionResults: Dispatch<SetStateAction<PredictionResult[]>>;
currentYear: number;
setCurrentYear: Dispatch<SetStateAction<number>>;
analysisQueryTime: string | null;
setAnalysisQueryTime: Dispatch<SetStateAction<string | null>>;
}
const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undefined);
@@ -15,6 +17,7 @@ const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undef
export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [predictionResults, setPredictionResults] = useState<PredictionResult[]>([]);
const [currentYear, setCurrentYear] = useState<number>(4);
const [analysisQueryTime, setAnalysisQueryTime] = useState<string | null>(null);
return (
<HealthRiskContext.Provider
@@ -23,6 +26,8 @@ export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children
setPredictionResults,
currentYear,
setCurrentYear,
analysisQueryTime,
setAnalysisQueryTime,
}}
>
{children}
@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from "@testing-library/react";
import HealthRiskReport from "./HealthRiskReport";
import { PredictionResult } from "./types";
jest.mock("echarts-for-react", () => ({
__esModule: true,
default: () => <div data-testid="report-chart" />,
}));
const predictionResults: PredictionResult[] = [
{
link_id: "PIPE-007",
survival_function: {
x: [4, 5],
y: [0.2, 0.15],
a: 0,
b: 0,
},
},
{
link_id: "PIPE-021",
survival_function: {
x: [4, 5],
y: [0.8, 0.7],
a: 0,
b: 0,
},
},
];
describe("HealthRiskReport", () => {
it("renders automatic report identity and priority results", () => {
render(
<HealthRiskReport
open
onClose={jest.fn()}
predictionResults={predictionResults}
currentYear={4}
analysisQueryTime="2026-07-30T01:02:03.000Z"
networkName="fengyang"
generatedAt={new Date("2026-07-30T03:04:05.000Z")}
/>,
);
expect(
screen.getByRole("heading", { name: "管网健康风险体检报告" }),
).toBeInTheDocument();
expect(screen.getByText("fengyang")).toBeInTheDocument();
expect(screen.getByText("第 4 年")).toBeInTheDocument();
expect(screen.getByText("PIPE-007")).toBeInTheDocument();
expect(screen.getAllByTestId("report-chart")).toHaveLength(2);
});
it("prints the report and restores document state after printing", () => {
const originalTitle = document.title;
const print = jest
.spyOn(window, "print")
.mockImplementation(() => undefined);
render(
<HealthRiskReport
open
onClose={jest.fn()}
predictionResults={predictionResults}
currentYear={4}
analysisQueryTime="2026-07-30T01:02:03.000Z"
networkName="fengyang"
generatedAt={new Date("2026-07-30T03:04:05.000Z")}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "打印" }));
expect(print).toHaveBeenCalledTimes(1);
expect(document.body).toHaveClass("health-risk-report-printing");
expect(document.title).toContain("管网健康风险体检报告-fengyang");
window.dispatchEvent(new Event("afterprint"));
expect(document.body).not.toHaveClass("health-risk-report-printing");
expect(document.title).toBe(originalTitle);
print.mockRestore();
});
});
@@ -0,0 +1,746 @@
"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;
@@ -11,10 +11,18 @@ import {
Stack,
Slide,
Fade,
Button,
} from "@mui/material";
import { ChevronLeft, ChevronRight, BarChart } from "@mui/icons-material";
import {
ChevronLeft,
ChevronRight,
BarChart,
DescriptionOutlined,
} from "@mui/icons-material";
import { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types";
import { useHealthRisk } from "./HealthRiskContext";
import { useProject } from "@/contexts/ProjectContext";
import HealthRiskReport from "./HealthRiskReport";
const SIMPLE_LABELS = [
"0.0 - 0.1",
@@ -30,8 +38,13 @@ const SIMPLE_LABELS = [
];
const HealthRiskStatistics: React.FC = () => {
const { predictionResults, currentYear } = useHealthRisk();
const { predictionResults, currentYear, analysisQueryTime } = useHealthRisk();
const project = useProject();
const [isExpanded, setIsExpanded] = React.useState<boolean>(true);
const [isReportOpen, setIsReportOpen] = React.useState<boolean>(false);
const [reportGeneratedAt, setReportGeneratedAt] = React.useState(
() => new Date(),
);
const [hoveredYearIndex, setHoveredYearIndex] = React.useState<number | null>(
null
);
@@ -278,6 +291,41 @@ const HealthRiskStatistics: React.FC = () => {
/>
</div>
<Stack direction="row" spacing={1}>
<Tooltip
title={
predictionResults.length > 0
? "生成健康风险体检报告"
: "请先完成健康风险分析"
}
>
<span>
<Button
size="small"
variant="outlined"
startIcon={<DescriptionOutlined fontSize="small" />}
disabled={predictionResults.length === 0}
onClick={() => {
setReportGeneratedAt(new Date());
setIsReportOpen(true);
}}
sx={{
minHeight: 32,
color: "white",
borderColor: "rgba(255,255,255,0.62)",
"&:hover": {
borderColor: "white",
backgroundColor: "rgba(255,255,255,0.1)",
},
"&.Mui-disabled": {
color: "rgba(255,255,255,0.48)",
borderColor: "rgba(255,255,255,0.24)",
},
}}
>
</Button>
</span>
</Tooltip>
<Tooltip title="收起">
<IconButton
size="small"
@@ -308,6 +356,15 @@ const HealthRiskStatistics: React.FC = () => {
</div>
</div>
</Slide>
<HealthRiskReport
open={isReportOpen}
onClose={() => setIsReportOpen(false)}
predictionResults={predictionResults}
currentYear={currentYear}
analysisQueryTime={analysisQueryTime}
networkName={project?.networkName || "未命名管网"}
generatedAt={reportGeneratedAt}
/>
</>
);
};
@@ -137,6 +137,7 @@ const Timeline: React.FC<TimelineProps> = ({
setPredictionResults,
currentYear,
setCurrentYear,
setAnalysisQueryTime,
} = useHealthRisk();
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
@@ -397,6 +398,7 @@ const Timeline: React.FC<TimelineProps> = ({
if (response.ok) {
const results: PredictionResult[] = await response.json();
setPredictionResults(results);
setAnalysisQueryTime(calculationDateTime.toISOString());
open?.({
type: "success",
message: `模拟预测完成,获取到 ${results.length} 条管道数据`,
@@ -404,7 +406,11 @@ const Timeline: React.FC<TimelineProps> = ({
} else {
// 读取后端 HTTPException 返回的 detail 信息
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.detail || "模拟预测失败";
const errorMessage =
typeof errorData.detail === "string" &&
errorData.detail.includes("未找到流速数据")
? "所选时间暂无可用的管网模拟数据,请确认该时刻已完成模拟计算并生成流速结果后重试。"
: errorData.detail || "模拟预测失败";
open?.({
type: "error",
message: errorMessage,
@@ -0,0 +1,84 @@
import {
buildHealthRiskReportData,
getProbabilityAtYear,
getRiskBandIndex,
PRIORITY_PIPE_LIMIT,
} from "./healthRiskReport";
import { PredictionResult } from "./types";
const createResult = (
linkId: string,
years: number[],
probabilities: number[],
): PredictionResult => ({
link_id: linkId,
survival_function: {
x: years,
y: probabilities,
a: 0,
b: 0,
},
});
describe("healthRiskReport", () => {
it("matches probability by the exact forecast year", () => {
const result = createResult("P-1", [4, 6], [0.8, 0.2]);
expect(getProbabilityAtYear(result, 6)).toBe(0.2);
expect(getProbabilityAtYear(result, 5)).toBeNull();
});
it("rejects missing and out-of-range probabilities", () => {
expect(
getProbabilityAtYear(createResult("P-1", [4], [Number.NaN]), 4),
).toBeNull();
expect(getProbabilityAtYear(createResult("P-2", [4], [1.1]), 4)).toBeNull();
});
it("keeps risk break boundaries consistent with the existing legend", () => {
expect(getRiskBandIndex(0)).toBe(0);
expect(getRiskBandIndex(0.1)).toBe(0);
expect(getRiskBandIndex(0.10001)).toBe(1);
expect(getRiskBandIndex(1)).toBe(9);
});
it("builds the current snapshot and high-risk trend", () => {
const report = buildHealthRiskReportData(
[
createResult("P-1", [4, 5], [0.2, 0.1]),
createResult("P-2", [4, 5], [0.5, 0.25]),
createResult("P-3", [4, 5], [0.9, 0.8]),
createResult("P-4", [5], [0.4]),
],
4,
);
expect(report.totalCount).toBe(4);
expect(report.validCount).toBe(3);
expect(report.invalidCount).toBe(1);
expect(report.highRiskCount).toBe(1);
expect(report.highRiskRatio).toBeCloseTo(1 / 3);
expect(report.averageProbability).toBeCloseTo(0.533333);
expect(report.distribution.reduce((sum, item) => sum + item.count, 0)).toBe(
3,
);
expect(report.trend).toEqual([
{ year: 4, count: 1, validCount: 3, ratio: 1 / 3 },
{ year: 5, count: 2, validCount: 4, ratio: 0.5 },
]);
});
it("sorts priority pipes and limits the printed detail", () => {
const results = Array.from({ length: PRIORITY_PIPE_LIMIT + 5 }, (_, index) =>
createResult(`P-${PRIORITY_PIPE_LIMIT + 5 - index}`, [4], [0.2]),
);
const report = buildHealthRiskReportData(results, 4);
expect(report.priorityPipes).toHaveLength(PRIORITY_PIPE_LIMIT);
expect(report.priorityPipes[0].linkId).toBe("P-1");
expect(report.priorityPipes.at(-1)?.linkId).toBe(
`P-${PRIORITY_PIPE_LIMIT}`,
);
});
});
@@ -0,0 +1,201 @@
import {
PredictionResult,
RAINBOW_COLORS,
RISK_BREAKS,
RISK_LABELS,
} from "./types";
export const HIGH_RISK_MAX_PROBABILITY = 0.3;
export const PRIORITY_PIPE_LIMIT = 20;
export interface RiskDistributionItem {
label: string;
color: string;
count: number;
ratio: number;
}
export interface HighRiskTrendPoint {
year: number;
count: number;
validCount: number;
ratio: number;
}
export interface PriorityPipeItem {
linkId: string;
probability: number;
riskLabel: string;
color: string;
}
export interface HealthRiskReportData {
totalCount: number;
validCount: number;
invalidCount: number;
averageProbability: number | null;
highRiskCount: number;
highRiskRatio: number;
distribution: RiskDistributionItem[];
trend: HighRiskTrendPoint[];
priorityPipes: PriorityPipeItem[];
conclusion: string;
recommendations: string[];
}
const isValidProbability = (value: unknown): value is number =>
typeof value === "number" &&
Number.isFinite(value) &&
value >= 0 &&
value <= 1;
export const getProbabilityAtYear = (
result: PredictionResult,
year: number,
): number | null => {
const yearIndex = result.survival_function.x.findIndex(
(value) => value === year,
);
if (yearIndex < 0) return null;
const probability = result.survival_function.y[yearIndex];
return isValidProbability(probability) ? probability : null;
};
export const getRiskBandIndex = (probability: number): number => {
const index = RISK_BREAKS.findIndex((upperBound) => probability <= upperBound);
return index >= 0 ? index : RISK_BREAKS.length - 1;
};
export const buildHealthRiskReportData = (
results: PredictionResult[],
currentYear: number,
): HealthRiskReportData => {
const currentValues = results.flatMap((result) => {
const probability = getProbabilityAtYear(result, currentYear);
return probability === null ? [] : [{ result, probability }];
});
const validCount = currentValues.length;
const highRiskCount = currentValues.filter(
({ probability }) => probability <= HIGH_RISK_MAX_PROBABILITY,
).length;
const distribution = RISK_LABELS.map((label, index) => {
const count = currentValues.filter(
({ probability }) => getRiskBandIndex(probability) === index,
).length;
return {
label,
color: RAINBOW_COLORS[index],
count,
ratio: validCount > 0 ? count / validCount : 0,
};
});
const priorityPipes = currentValues
.filter(
({ probability }) => probability <= HIGH_RISK_MAX_PROBABILITY,
)
.sort(
(left, right) =>
left.probability - right.probability ||
left.result.link_id.localeCompare(right.result.link_id, "zh-CN", {
numeric: true,
}),
)
.slice(0, PRIORITY_PIPE_LIMIT)
.map(({ result, probability }) => {
const bandIndex = getRiskBandIndex(probability);
return {
linkId: result.link_id,
probability,
riskLabel: RISK_LABELS[bandIndex],
color: RAINBOW_COLORS[bandIndex],
};
});
const trendByYear = new Map<
number,
{ count: number; validCount: number }
>();
results.forEach((result) => {
const seenYears = new Set<number>();
result.survival_function.x.forEach((year, index) => {
if (
typeof year !== "number" ||
!Number.isFinite(year) ||
seenYears.has(year)
) {
return;
}
seenYears.add(year);
const probability = result.survival_function.y[index];
if (!isValidProbability(probability)) return;
const point = trendByYear.get(year) ?? { count: 0, validCount: 0 };
point.validCount += 1;
if (probability <= HIGH_RISK_MAX_PROBABILITY) point.count += 1;
trendByYear.set(year, point);
});
});
const trend = Array.from(trendByYear, ([year, point]) => ({
year,
count: point.count,
validCount: point.validCount,
ratio: point.validCount > 0 ? point.count / point.validCount : 0,
})).sort((left, right) => left.year - right.year);
const averageProbability =
validCount > 0
? currentValues.reduce(
(total, { probability }) => total + probability,
0,
) / validCount
: null;
const highRiskRatio = validCount > 0 ? highRiskCount / validCount : 0;
const mediumRiskCount = currentValues.filter(
({ probability }) =>
probability > HIGH_RISK_MAX_PROBABILITY && probability <= 0.6,
).length;
const conclusion =
validCount === 0
? `预测第 ${currentYear} 年没有可用于报告的有效生存概率数据。`
: highRiskCount > 0
? `预测第 ${currentYear} 年发现 ${highRiskCount} 条较高及以上风险管段,占有效结果的 ${(highRiskRatio * 100).toFixed(1)}%。建议优先核查重点管段,并结合现场检测确认处置顺序。`
: `预测第 ${currentYear} 年未发现较高及以上风险管段。当前结果仍需结合巡检记录持续跟踪。`;
const recommendations: string[] = [];
if (highRiskCount > 0) {
recommendations.push(
"优先核查重点风险管段的运行状态、历史故障和周边施工情况,必要时安排现场检测。",
);
}
if (mediumRiskCount > 0) {
recommendations.push(
`对其余 ${mediumRiskCount} 条中等风险管段提高监测频率,关注压力波动和风险趋势变化。`,
);
}
if (highRiskCount === 0 && mediumRiskCount === 0 && validCount > 0) {
recommendations.push(
"保持现有巡检和监测频率,在新的在线模拟数据产生后重新评估。",
);
}
recommendations.push(
"本报告为模型预测结果,仅用于辅助决策,不能替代现场检测、专业鉴定和安全处置流程。",
);
return {
totalCount: results.length,
validCount,
invalidCount: results.length - validCount,
averageProbability,
highRiskCount,
highRiskRatio,
distribution,
trend,
priorityPipes,
conclusion,
recommendations,
};
};
@@ -7,9 +7,9 @@ export interface SurvivalFunction {
export interface PredictionResult {
link_id: string;
diameter: number;
velocity: number;
pressure: number;
diameter?: number;
velocity?: number;
pressure?: number;
survival_function: SurvivalFunction;
}