feat(health-risk): add printable assessment report
This commit is contained in:
@@ -8,6 +8,8 @@ interface HealthRiskContextType {
|
|||||||
setPredictionResults: Dispatch<SetStateAction<PredictionResult[]>>;
|
setPredictionResults: Dispatch<SetStateAction<PredictionResult[]>>;
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
setCurrentYear: Dispatch<SetStateAction<number>>;
|
setCurrentYear: Dispatch<SetStateAction<number>>;
|
||||||
|
analysisQueryTime: string | null;
|
||||||
|
setAnalysisQueryTime: Dispatch<SetStateAction<string | null>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undefined);
|
const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undefined);
|
||||||
@@ -15,6 +17,7 @@ const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undef
|
|||||||
export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [predictionResults, setPredictionResults] = useState<PredictionResult[]>([]);
|
const [predictionResults, setPredictionResults] = useState<PredictionResult[]>([]);
|
||||||
const [currentYear, setCurrentYear] = useState<number>(4);
|
const [currentYear, setCurrentYear] = useState<number>(4);
|
||||||
|
const [analysisQueryTime, setAnalysisQueryTime] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HealthRiskContext.Provider
|
<HealthRiskContext.Provider
|
||||||
@@ -23,6 +26,8 @@ export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children
|
|||||||
setPredictionResults,
|
setPredictionResults,
|
||||||
currentYear,
|
currentYear,
|
||||||
setCurrentYear,
|
setCurrentYear,
|
||||||
|
analysisQueryTime,
|
||||||
|
setAnalysisQueryTime,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{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,
|
Stack,
|
||||||
Slide,
|
Slide,
|
||||||
Fade,
|
Fade,
|
||||||
|
Button,
|
||||||
} from "@mui/material";
|
} 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 { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types";
|
||||||
import { useHealthRisk } from "./HealthRiskContext";
|
import { useHealthRisk } from "./HealthRiskContext";
|
||||||
|
import { useProject } from "@/contexts/ProjectContext";
|
||||||
|
import HealthRiskReport from "./HealthRiskReport";
|
||||||
|
|
||||||
const SIMPLE_LABELS = [
|
const SIMPLE_LABELS = [
|
||||||
"0.0 - 0.1",
|
"0.0 - 0.1",
|
||||||
@@ -30,8 +38,13 @@ const SIMPLE_LABELS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const HealthRiskStatistics: React.FC = () => {
|
const HealthRiskStatistics: React.FC = () => {
|
||||||
const { predictionResults, currentYear } = useHealthRisk();
|
const { predictionResults, currentYear, analysisQueryTime } = useHealthRisk();
|
||||||
|
const project = useProject();
|
||||||
const [isExpanded, setIsExpanded] = React.useState<boolean>(true);
|
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>(
|
const [hoveredYearIndex, setHoveredYearIndex] = React.useState<number | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -278,6 +291,41 @@ const HealthRiskStatistics: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Stack direction="row" spacing={1}>
|
<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="收起">
|
<Tooltip title="收起">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -308,6 +356,15 @@ const HealthRiskStatistics: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Slide>
|
</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,
|
setPredictionResults,
|
||||||
currentYear,
|
currentYear,
|
||||||
setCurrentYear,
|
setCurrentYear,
|
||||||
|
setAnalysisQueryTime,
|
||||||
} = useHealthRisk();
|
} = useHealthRisk();
|
||||||
|
|
||||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||||
@@ -397,6 +398,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const results: PredictionResult[] = await response.json();
|
const results: PredictionResult[] = await response.json();
|
||||||
setPredictionResults(results);
|
setPredictionResults(results);
|
||||||
|
setAnalysisQueryTime(calculationDateTime.toISOString());
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: `模拟预测完成,获取到 ${results.length} 条管道数据`,
|
message: `模拟预测完成,获取到 ${results.length} 条管道数据`,
|
||||||
@@ -404,7 +406,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
// 读取后端 HTTPException 返回的 detail 信息
|
// 读取后端 HTTPException 返回的 detail 信息
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
const errorMessage = errorData.detail || "模拟预测失败";
|
const errorMessage =
|
||||||
|
typeof errorData.detail === "string" &&
|
||||||
|
errorData.detail.includes("未找到流速数据")
|
||||||
|
? "所选时间暂无可用的管网模拟数据,请确认该时刻已完成模拟计算并生成流速结果后重试。"
|
||||||
|
: errorData.detail || "模拟预测失败";
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: errorMessage,
|
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 {
|
export interface PredictionResult {
|
||||||
link_id: string;
|
link_id: string;
|
||||||
diameter: number;
|
diameter?: number;
|
||||||
velocity: number;
|
velocity?: number;
|
||||||
pressure: number;
|
pressure?: number;
|
||||||
survival_function: SurvivalFunction;
|
survival_function: SurvivalFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user