Files
TJWaterFrontend_Refine/src/components/olmap/BurstSimulation/AnalysisReport.tsx
T

546 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Box,
Button,
Chip,
GlobalStyles,
Paper,
Portal,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import {
DescriptionOutlined,
PrintOutlined,
} from "@mui/icons-material";
import { NETWORK_NAME } from "@config/config";
import {
clearPipeDiameterCache,
getCachedPipeDiameters,
getPipeDiameterDisplay,
getPipeDiameterQueryKey,
loadPipeDiameters,
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types";
import { getAffectedNodeCount } from "./valveIsolationScope";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface AnalysisReportProps {
scheme: SchemeRecord | null;
valveResult: ValveIsolationResult | null;
disabledValves: string[];
generatedAt: Date | null;
}
interface ReportDocumentProps extends AnalysisReportProps {
diameters: PipeDiameterMap;
loadingDiameters: boolean;
}
const REPORT_BLUE = "#0b4f87";
const REPORT_INK = "#172435";
const REPORT_MUTED = "#5f6f80";
const REPORT_LINE = "#d8e0e8";
export const clearAnalysisReportDiameterCache = clearPipeDiameterCache;
const formatDateTime = (value: Date | string | null | undefined) => {
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 formatDuration = (seconds: number | undefined) => {
if (!Number.isFinite(seconds) || seconds === undefined || seconds < 0) {
return "未记录";
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return [
hours ? `${hours} 小时` : "",
minutes ? `${minutes} 分钟` : "",
remainingSeconds || (!hours && !minutes) ? `${remainingSeconds} 秒` : "",
]
.filter(Boolean)
.join(" ");
};
export const matchesValveAnalysis = (
scheme: SchemeRecord | null,
valveResult: ValveIsolationResult | null,
) => {
const schemePipeIds = new Set(scheme?.schemeDetail?.burst_ID ?? []);
const accidentElements = valveResult?.accident_elements ?? [];
return (
valveResult !== null &&
accidentElements.length > 0 &&
accidentElements.every((pipeId) => schemePipeIds.has(pipeId))
);
};
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.5 }}>
<Box sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }} />
<Typography
component="h2"
sx={{ color: REPORT_INK, fontSize: 17, fontWeight: 700 }}
>
{children}
</Typography>
</Stack>
);
const IdList = ({
values,
emptyText = "无",
}: {
values: string[] | undefined;
emptyText?: string;
}) =>
values?.length ? (
<Box className="flex flex-wrap gap-1.5">
{values.map((value) => (
<Chip
key={value}
label={value}
size="small"
variant="outlined"
sx={{ borderColor: REPORT_LINE, color: REPORT_INK }}
/>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: REPORT_MUTED }}>
{emptyText}
</Typography>
);
const ReportDocument: React.FC<ReportDocumentProps> = ({
scheme,
valveResult,
disabledValves,
generatedAt,
diameters,
loadingDiameters,
}) => {
if (!scheme) return null;
const pipeIds = scheme.schemeDetail?.burst_ID ?? [];
const burstSizes = scheme.schemeDetail?.burst_size ?? [];
const matchedValveResult = matchesValveAnalysis(scheme, valveResult)
? valveResult
: null;
const duration = scheme.schemeDetail?.modify_total_duration;
const valveDetailRows: Array<[string, string[] | undefined]> =
matchedValveResult
? [
["已分析事故管段", matchedValveResult.accident_elements],
["必关阀门", matchedValveResult.must_close_valves],
["可选阀门", matchedValveResult.optional_valves],
["不可用阀门", disabledValves],
]
: [];
if (matchedValveResult?.isolatable) {
valveDetailRows.push(["受影响节点", matchedValveResult.affected_nodes]);
}
return (
<Box
sx={{
width: "100%",
minHeight: "100%",
bgcolor: "#fff",
color: REPORT_INK,
p: { xs: 2, sm: 3 },
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
}}
>
<Box
sx={{
borderBottom: `3px solid ${REPORT_BLUE}`,
pb: 2,
mb: 3,
}}
>
<Typography
component="h1"
sx={{ color: REPORT_BLUE, fontSize: 26, fontWeight: 800 }}
>
爆管分析报告
</Typography>
<Typography sx={{ mt: 0.75, color: REPORT_MUTED, fontSize: 13 }}>
报告编号:BA-{scheme.id} 生成时间:{formatDateTime(generatedAt)}
</Typography>
</Box>
<Box sx={{ mb: 3, breakInside: "avoid" }}>
<SectionTitle>方案概况</SectionTitle>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
borderTop: `1px solid ${REPORT_LINE}`,
borderLeft: `1px solid ${REPORT_LINE}`,
}}
>
{[
["管网", NETWORK_NAME],
["方案名称", scheme.schemeName],
["方案创建人", scheme.username || "未记录"],
["方案创建时间", formatDateTime(scheme.create_time)],
["模拟开始时间", formatDateTime(scheme.startTime)],
["模拟持续时间", formatDuration(duration)],
].map(([label, value]) => (
<Box
key={label}
sx={{
p: 1.25,
borderRight: `1px solid ${REPORT_LINE}`,
borderBottom: `1px solid ${REPORT_LINE}`,
}}
>
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
{label}
</Typography>
<Typography sx={{ mt: 0.3, fontSize: 14, fontWeight: 600 }}>
{value}
</Typography>
</Box>
))}
</Box>
</Box>
<Box sx={{ mb: 3, breakInside: "avoid" }}>
<SectionTitle>爆管模拟参数</SectionTitle>
<TableContainer component={Paper} variant="outlined" elevation={0}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "#f3f7fa" }}>
<TableCell>序号</TableCell>
<TableCell>爆管管段</TableCell>
<TableCell>管径</TableCell>
<TableCell align="right">爆管面积</TableCell>
</TableRow>
</TableHead>
<TableBody>
{pipeIds.length ? (
pipeIds.map((pipeId, index) => (
<TableRow key={`${pipeId}-${index}`}>
<TableCell>{index + 1}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{pipeId}</TableCell>
<TableCell>
{getPipeDiameterDisplay(
[pipeId],
diameters,
loadingDiameters,
)}
</TableCell>
<TableCell align="right">
{Number.isFinite(burstSizes[index])
? `${burstSizes[index]} cm²`
: "未记录"}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} align="center">
未记录爆管管段
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Box>
<Box sx={{ mb: 3, breakInside: "avoid" }}>
<SectionTitle>分析结论</SectionTitle>
<Alert
severity={matchedValveResult?.isolatable === false ? "warning" : "info"}
variant="outlined"
>
本方案记录了 {pipeIds.length} 条爆管管段,模拟持续时间为
{formatDuration(duration)}
{matchedValveResult
? ` 当前关阀分析判定事故管段${
matchedValveResult.isolatable ? "可以" : "无法"
}有效隔离。`
: " 当前会话尚未对本方案执行匹配的关阀分析。"}
</Alert>
</Box>
<Box sx={{ mb: 2 }}>
<SectionTitle>关阀分析</SectionTitle>
{matchedValveResult ? (
<Stack spacing={2}>
<Box className="grid grid-cols-3 gap-2">
{[
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`],
["受影响节点", `${getAffectedNodeCount(matchedValveResult)} 个`],
].map(([label, value]) => (
<Paper
key={label}
variant="outlined"
sx={{ p: 1.5, textAlign: "center", breakInside: "avoid" }}
>
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
{label}
</Typography>
<Typography sx={{ mt: 0.5, fontWeight: 700 }}>
{value}
</Typography>
</Paper>
))}
</Box>
{valveDetailRows.map(([label, values]) => (
<Box key={label} sx={{ breakInside: "avoid" }}>
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
{label}
</Typography>
<IdList values={values} />
</Box>
))}
{!matchedValveResult.isolatable && (
<Alert severity="info" variant="outlined">
不可隔离,未生成受影响节点清单。
</Alert>
)}
</Stack>
) : (
<Alert severity="info" variant="outlined">
本方案尚未执行关阀分析。可在“关阀分析”页签完成分析后重新查看报告。
</Alert>
)}
</Box>
</Box>
);
};
const AnalysisReport: React.FC<AnalysisReportProps> = ({
scheme,
valveResult,
disabledValves,
generatedAt,
}) => {
const pipeIds = useMemo(
() => scheme?.schemeDetail?.burst_ID ?? [],
[scheme],
);
const diameterQueryKey = getPipeDiameterQueryKey(pipeIds);
const cachedDiameters = getCachedPipeDiameters(pipeIds);
const [diameterState, setDiameterState] = useState<{
queryKey: string | null;
values: PipeDiameterMap;
}>({ queryKey: null, values: {} });
const [printReady, setPrintReady] = useState(false);
const printStateRef = useRef<{ title: string } | null>(null);
const diameters = useMemo(
() =>
diameterState.queryKey === diameterQueryKey
? diameterState.values
: cachedDiameters ?? {},
[cachedDiameters, diameterQueryKey, diameterState],
);
const loadingDiameters =
pipeIds.length > 0 &&
diameterState.queryKey !== diameterQueryKey &&
!cachedDiameters;
useEffect(() => {
if (!pipeIds.length) {
return;
}
if (getCachedPipeDiameters(pipeIds)) {
return;
}
let cancelled = false;
loadPipeDiameters(pipeIds)
.then((nextDiameters) => {
if (!cancelled) {
setDiameterState({
queryKey: diameterQueryKey,
values: nextDiameters,
});
}
})
.catch((error) => {
console.error("查询分析报告管径失败:", error);
if (!cancelled) {
setDiameterState({
queryKey: diameterQueryKey,
values: Object.fromEntries(
pipeIds.map((pipeId) => [pipeId, null]),
),
});
}
});
return () => {
cancelled = true;
};
}, [diameterQueryKey, pipeIds]);
const restoreAfterPrint = useCallback(() => {
if (printStateRef.current) {
document.title = printStateRef.current.title;
printStateRef.current = null;
document.body.classList.remove("burst-analysis-report-printing");
}
setPrintReady(false);
}, []);
useEffect(
() => () => {
restoreAfterPrint();
},
[restoreAfterPrint],
);
const handlePrint = () => {
if (!scheme || printStateRef.current) return;
printStateRef.current = { title: document.title };
document.title = `爆管分析报告-${scheme.schemeName}`;
document.body.classList.add("burst-analysis-report-printing");
setPrintReady(true);
};
useEffect(() => {
if (!printReady) return;
window.addEventListener("afterprint", restoreAfterPrint, { once: true });
try {
window.print();
} catch (error) {
console.error("打印爆管分析报告失败:", error);
window.setTimeout(restoreAfterPrint, 0);
}
return () => {
window.removeEventListener("afterprint", restoreAfterPrint);
};
}, [printReady, restoreAfterPrint]);
const reportDocument = useMemo(
() => (
<ReportDocument
scheme={scheme}
valveResult={valveResult}
disabledValves={disabledValves}
generatedAt={generatedAt}
diameters={diameters}
loadingDiameters={loadingDiameters}
/>
),
[
diameters,
disabledValves,
generatedAt,
loadingDiameters,
scheme,
valveResult,
],
);
if (!scheme) {
return (
<PanelEmptyState
icon={<DescriptionOutlined />}
title="尚未选择分析方案"
description="请在“方案查询”中打开一个方案,再查看分析报告。"
/>
);
}
return (
<>
<GlobalStyles
styles={{
".burst-analysis-report-print-root": { display: "none" },
"@page": { size: "A4 portrait", margin: 0 },
"@media print": {
"html, body": {
width: "210mm",
minHeight: "297mm",
margin: 0,
padding: 0,
backgroundColor: "#fff",
},
"body.burst-analysis-report-printing > *:not(.burst-analysis-report-print-root)":
{ display: "none !important" },
".burst-analysis-report-print-root": {
display: "block !important",
width: "210mm !important",
minHeight: "297mm !important",
backgroundColor: "#fff !important",
},
".burst-analysis-report-print-root > div": {
padding: "14mm 16mm !important",
},
},
}}
/>
<Box className="space-y-3">
<Box className="flex justify-end">
<Button
variant="contained"
size="small"
startIcon={<PrintOutlined />}
onClick={handlePrint}
>
打印/保存 PDF
</Button>
</Box>
<Paper
data-testid="burst-analysis-report-preview"
variant="outlined"
sx={{ overflow: "hidden" }}
>
{reportDocument}
</Paper>
</Box>
{printReady && (
<Portal>
<Box
className="burst-analysis-report-print-root"
aria-hidden="true"
>
{reportDocument}
</Box>
</Portal>
)}
</>
);
};
export default AnalysisReport;