546 lines
16 KiB
TypeScript
546 lines
16 KiB
TypeScript
"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;
|