feat(health-risk): add printable assessment report
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user