feat(burst): add analysis report and valve binding

Replace the obsolete location result with a printable analysis report, bind valve analysis to the selected scheme, and cache report diameter lookups. Scheme identity is tracked with valve results so reports cannot reuse results from another scheme that shares the same pipe.
This commit is contained in:
2026-07-30 13:16:05 +08:00
parent 1b2a7f4fb8
commit c4246cf25f
9 changed files with 1082 additions and 463 deletions
@@ -0,0 +1,592 @@
"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 { queryFeaturesByIds } from "@/utils/mapQueryService";
import {
getPipeDiameterDisplay,
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types";
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";
const diameterCache = new Map<string, PipeDiameterMap>();
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
export const clearAnalysisReportDiameterCache = () => {
diameterCache.clear();
diameterRequestCache.clear();
};
const loadPipeDiameters = async (
pipeIds: string[],
queryKey: string,
): Promise<PipeDiameterMap> => {
const cachedDiameters = diameterCache.get(queryKey);
if (cachedDiameters) {
return cachedDiameters;
}
const cachedRequest = diameterRequestCache.get(queryKey);
if (cachedRequest) {
return cachedRequest;
}
const request = (async () => {
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
const foundIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingIds = pipeIds.filter((pipeId) => !foundIds.has(pipeId));
if (missingIds.length) {
features = [
...features,
...(await queryFeaturesByIds(missingIds, "geo_pipes")),
];
}
const diameters: PipeDiameterMap = Object.fromEntries(
pipeIds.map((pipeId) => [pipeId, null]),
);
features.forEach((feature) => {
const properties = feature.getProperties();
const pipeId = String(properties.id);
const diameter = Number(properties.diameter);
if (pipeIds.includes(pipeId)) {
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
}
});
diameterCache.set(queryKey, diameters);
return diameters;
})();
diameterRequestCache.set(queryKey, request);
try {
return await request;
} finally {
if (diameterRequestCache.get(queryKey) === request) {
diameterRequestCache.delete(queryKey);
}
}
};
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;
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.user || "未记录"],
["方案创建时间", 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}`],
["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0}`],
].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>
{[
["已分析事故管段", matchedValveResult.accident_elements],
["必关阀门", matchedValveResult.must_close_valves],
["可选阀门", matchedValveResult.optional_valves],
["不可用阀门", disabledValves],
["受影响节点", matchedValveResult.affected_nodes],
].map(([label, values]) => (
<Box key={label as string} sx={{ breakInside: "avoid" }}>
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
{label as string}
</Typography>
<IdList values={values as string[]} />
</Box>
))}
</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 = pipeIds.join("\u0000");
const cachedDiameters = diameterCache.get(diameterQueryKey);
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 (diameterCache.has(diameterQueryKey)) {
return;
}
let cancelled = false;
loadPipeDiameters(pipeIds, diameterQueryKey)
.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 (
<Box className="flex h-full flex-col items-center justify-center px-6 text-center">
<DescriptionOutlined sx={{ mb: 2, fontSize: 52, color: "#94a3b8" }} />
<Typography variant="h6" className="font-bold text-gray-700">
</Typography>
<Typography variant="body2" className="mt-2 text-gray-500">
</Typography>
</Box>
);
}
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;