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:
@@ -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;
|
||||
Reference in New Issue
Block a user