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,200 @@
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import AnalysisReport, {
clearAnalysisReportDiameterCache,
matchesValveAnalysis,
} from "./AnalysisReport";
import { SchemeRecord, ValveIsolationResult } from "./types";
import { isAllowedAccidentPipe } from "./valveIsolationScope";
jest.mock("@/utils/mapQueryService", () => ({
queryFeaturesByIds: jest.fn(),
}));
const scheme: SchemeRecord = {
id: 17,
schemeName: "burst-report-demo",
type: "burst_analysis",
user: "operator",
create_time: "2026-07-30T08:00:00+08:00",
startTime: "2026-07-30T09:00:00+08:00",
schemeDetail: {
burst_ID: ["P-1", "P-2"],
burst_size: [120, 80],
modify_total_duration: 5400,
modify_fixed_pump_pattern: null,
modify_valve_opening: null,
modify_variable_pump_pattern: null,
},
};
const valveResult: ValveIsolationResult = {
accident_elements: ["P-1"],
affected_nodes: ["J-1", "J-2"],
must_close_valves: ["V-1"],
optional_valves: ["V-2"],
isolatable: true,
};
const feature = (id: string, diameter: number) => ({
getProperties: () => ({ id, diameter }),
});
describe("AnalysisReport", () => {
beforeEach(() => {
(queryFeaturesByIds as jest.Mock).mockImplementation(
async (_ids: string[], layerName: string) =>
layerName === "geo_pipes_mat"
? [feature("P-1", 315)]
: [feature("P-2", 800)],
);
});
afterEach(() => {
clearAnalysisReportDiameterCache();
jest.clearAllMocks();
document.body.classList.remove("burst-analysis-report-printing");
});
it("renders the selected scheme, pipe data, and matching valve result", async () => {
render(
<AnalysisReport
scheme={scheme}
valveResult={valveResult}
disabledValves={["V-3"]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
expect(
preview.getByRole("heading", { name: "爆管分析报告" }),
).toBeInTheDocument();
expect(preview.getByText("burst-report-demo")).toBeInTheDocument();
expect(preview.getByText("可隔离")).toBeInTheDocument();
expect(preview.getByText("V-1")).toBeInTheDocument();
expect(preview.getByText("V-3")).toBeInTheDocument();
await waitFor(() => {
expect(preview.getByText("315 mm")).toBeInTheDocument();
expect(preview.getByText("800 mm")).toBeInTheDocument();
});
expect(queryFeaturesByIds).toHaveBeenCalledWith(
["P-2"],
"geo_pipes",
);
});
it("does not mix an unrelated valve analysis into the report", async () => {
render(
<AnalysisReport
scheme={scheme}
valveResult={{ ...valveResult, accident_elements: ["P-99"] }}
disabledValves={[]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
expect(
preview.getByText(/尚未对本方案执行匹配的关阀分析/),
).toBeInTheDocument();
expect(preview.queryByText("V-1")).not.toBeInTheDocument();
expect(
matchesValveAnalysis(scheme, {
...valveResult,
accident_elements: ["P-99"],
}),
).toBe(false);
await waitFor(() =>
expect(preview.getByText("315 mm")).toBeInTheDocument(),
);
});
it("limits scheme-bound valve analysis to the scheme accident pipes", () => {
expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true);
expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false);
expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true);
});
it("prints only after assigning the report print state", async () => {
const originalTitle = document.title;
const print = jest
.spyOn(window, "print")
.mockImplementation(() => undefined);
render(
<AnalysisReport
scheme={scheme}
valveResult={null}
disabledValves={[]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
await waitFor(() =>
expect(preview.getByText("315 mm")).toBeInTheDocument(),
);
expect(
document.querySelector(".burst-analysis-report-print-root"),
).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "打印/保存 PDF" }),
);
expect(print).toHaveBeenCalledTimes(1);
expect(
document.querySelector(".burst-analysis-report-print-root"),
).toBeInTheDocument();
expect(document.body).toHaveClass("burst-analysis-report-printing");
expect(document.title).toBe("爆管分析报告-burst-report-demo");
fireEvent(window, new Event("afterprint"));
expect(document.body).not.toHaveClass(
"burst-analysis-report-printing",
);
expect(document.title).toBe(originalTitle);
print.mockRestore();
});
it("reuses pipe diameters after the report is remounted", async () => {
const props = {
scheme,
valveResult: null,
disabledValves: [],
generatedAt: new Date("2026-07-30T10:00:00+08:00"),
};
const firstRender = render(<AnalysisReport {...props} />);
await waitFor(() =>
expect(
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
"800 mm",
),
).toBeInTheDocument(),
);
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
firstRender.unmount();
render(<AnalysisReport {...props} />);
expect(
within(screen.getByTestId("burst-analysis-report-preview")).getByText(
"800 mm",
),
).toBeInTheDocument();
expect(queryFeaturesByIds).toHaveBeenCalledTimes(2);
});
});
@@ -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;
@@ -0,0 +1,154 @@
import { fireEvent, render, screen } from "@testing-library/react";
import BurstPipeAnalysisPanel from "./BurstPipeAnalysisPanel";
jest.mock("./AnalysisParameters", () => ({
__esModule: true,
default: () => <div>analysis parameters</div>,
createBurstAnalysisParametersState: () => ({}),
}));
jest.mock("./SchemeQuery", () => ({
__esModule: true,
default: ({
onViewReport,
}: {
onViewReport: (scheme: Record<string, unknown>) => void;
}) => {
const createScheme = (id: number, schemeName: string) => ({
id,
schemeName,
type: "burst_analysis",
user: "operator",
create_time: "2026-07-30T08:00:00+08:00",
startTime: "2026-07-30T09:00:00+08:00",
schemeDetail: {
burst_ID: ["P-1", "P-2"],
burst_size: [120, 80],
},
});
return (
<>
<button onClick={() => onViewReport(createScheme(1, "selected-scheme"))}>
mock report action
</button>
<button onClick={() => onViewReport(createScheme(2, "other-scheme"))}>
mock other report action
</button>
</>
);
},
createBurstSchemeQueryState: () => ({}),
}));
jest.mock("./AnalysisReport", () => ({
__esModule: true,
default: ({
scheme,
valveResult,
}: {
scheme: { schemeName: string } | null;
valveResult: { accident_elements: string[] } | null;
}) => (
<div>
report:{scheme?.schemeName ?? "empty"}:valve:
{valveResult?.accident_elements.join(",") ?? "none"}
</div>
),
matchesValveAnalysis: (
scheme: { schemeDetail: { burst_ID: string[] } },
valveResult: { accident_elements: string[] } | null,
) =>
valveResult !== null &&
valveResult.accident_elements.every((pipeId) =>
scheme.schemeDetail.burst_ID.includes(pipeId),
),
}));
jest.mock("./ValveIsolation", () => ({
__esModule: true,
default: ({
allowedPipeIds,
sourceSchemeName,
onResultChange,
}: {
allowedPipeIds?: string[];
sourceSchemeName?: string;
onResultChange: (result: Record<string, unknown>) => void;
}) => (
<>
<div data-testid="valve-scope">
valve scope:{sourceSchemeName ?? "independent"}:
{allowedPipeIds?.join(",") ?? "any"}
</div>
<button
onClick={() =>
onResultChange({
accident_elements: ["P-1"],
affected_nodes: ["J-1"],
must_close_valves: ["V-1"],
optional_valves: [],
isolatable: true,
})
}
>
mock valve result
</button>
</>
),
createValveIsolationState: () => ({
selectedPipeId: null,
activeStep: 0,
expandedResult: true,
disabledValves: [],
}),
}));
describe("BurstPipeAnalysisPanel", () => {
it("replaces the obsolete location tab and opens the selected report", () => {
render(<BurstPipeAnalysisPanel />);
expect(
screen.queryByRole("tab", { name: /定位结果/ }),
).not.toBeInTheDocument();
expect(
screen.getByRole("tab", { name: /分析报告/ }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock report action" }),
);
expect(
screen.getByText("report:selected-scheme:valve:none"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
expect(screen.getByTestId("valve-scope")).toHaveTextContent(
"valve scope:selected-scheme:P-1,P-2",
);
});
it("does not reuse valve results across schemes with the same pipe", () => {
render(<BurstPipeAnalysisPanel />);
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock report action" }),
);
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock valve result" }),
);
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock other report action" }),
);
expect(
screen.getByText("report:other-scheme:valve:none"),
).toBeInTheDocument();
});
});
@@ -15,7 +15,7 @@ import {
ChevronLeft,
Analytics as AnalyticsIcon,
Search as SearchIcon,
MyLocation as MyLocationIcon,
DescriptionOutlined as ReportIcon,
Handyman as HandymanIcon,
} from "@mui/icons-material";
import AnalysisParameters, {
@@ -26,15 +26,12 @@ import SchemeQuery, {
createBurstSchemeQueryState,
type BurstSchemeQueryState,
} from "./SchemeQuery";
import LocationResults from "./LocationResults";
import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport";
import ValveIsolation, {
createValveIsolationState,
type ValveIsolationState,
} from "./ValveIsolation";
import { api } from "@/lib/api";
import { config } from "@config/config";
import { useNotification } from "@refinedev/core";
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
import { SchemeRecord, ValveIsolationResult } from "./types";
interface TabPanelProps {
children?: React.ReactNode;
@@ -75,16 +72,16 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
// 持久化方案查询结果
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
// 定位结果数据
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null);
const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null);
// 关阀分析结果和加载状态
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
const [valveAnalysisSchemeName, setValveAnalysisSchemeName] =
useState<string | null>(null);
const [valveIsolationState, setValveIsolationState] =
useState<ValveIsolationState>(createValveIsolationState);
const { open } = useNotification();
// 使用受控或非受控状态
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const handleToggle = () => {
@@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
setCurrentTab(newValue);
};
const handleLocateScheme = async (scheme: SchemeRecord) => {
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
);
setLocationResults(response.data);
setCurrentTab(2); // 切换到定位结果标签页
} catch (error) {
console.error("获取定位结果失败:", error);
open?.({
type: "error",
message: "获取定位结果失败",
description: "无法从服务器获取该方案的定位结果",
});
const handleViewReport = (scheme: SchemeRecord) => {
if (
valveAnalysisSchemeName !== scheme.schemeName ||
!matchesValveAnalysis(scheme, valveAnalysisResult)
) {
setValveAnalysisResult(null);
setValveAnalysisSchemeName(null);
setValveIsolationState(createValveIsolationState());
}
setReportScheme(scheme);
setReportGeneratedAt(new Date());
setCurrentTab(2);
};
const handleValveAnalysisResultChange = (
result: ValveIsolationResult | null,
) => {
setValveAnalysisResult(result);
setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null);
};
const drawerWidth = 520;
@@ -226,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
label="方案查询"
/>
<Tab
icon={<MyLocationIcon fontSize="small" />}
icon={<ReportIcon fontSize="small" />}
iconPosition="start"
label="定位结果"
label="分析报告"
/>
<Tab
icon={<HandymanIcon fontSize="small" />}
@@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
<SchemeQuery
schemes={schemes}
onSchemesChange={setSchemes}
onLocate={handleLocateScheme}
onViewReport={handleViewReport}
state={queryState}
onStateChange={setQueryState}
/>
</TabPanel>
<TabPanel value={currentTab} index={2}>
<LocationResults
results={locationResults}
<AnalysisReport
scheme={reportScheme}
valveResult={valveAnalysisResult}
disabledValves={valveIsolationState.disabledValves}
generatedAt={reportGeneratedAt}
/>
</TabPanel>
@@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
loading={valveAnalysisLoading}
result={valveAnalysisResult}
onLoadingChange={setValveAnalysisLoading}
onResultChange={setValveAnalysisResult}
onResultChange={handleValveAnalysisResultChange}
state={valveIsolationState}
onStateChange={setValveIsolationState}
allowedPipeIds={
reportScheme?.schemeDetail?.burst_ID
}
sourceSchemeName={reportScheme?.schemeName}
/>
</TabPanel>
</Box>
@@ -1,416 +0,0 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Box,
Typography,
Chip,
IconButton,
Tooltip,
Link,
} from "@mui/material";
import {
LocationOn as LocationIcon,
} from "@mui/icons-material";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useMap } from "@components/olmap/core/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Stroke, Style, Icon } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature";
import {
along,
lineString,
length,
toMercator,
bbox,
featureCollection,
} from "@turf/turf";
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import moment from "moment";
import "moment-timezone";
import { LocationResult } from "./types";
import { FLOW_DISPLAY_UNIT } from "@utils/units";
interface LocationResultsProps {
results?: LocationResult[];
}
const LocationResults: React.FC<LocationResultsProps> = ({
results = [],
}) => {
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const map = useMap();
// 格式化时间为 UTC+8
const formatTime = (timeStr: string) => {
return moment(timeStr).utcOffset(8).format("YYYY-MM-DD HH:mm:ss");
};
const handleLocatePipes = (pipeIds: string[]) => {
if (pipeIds.length > 0) {
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
if (features.length > 0) {
// 设置高亮要素
setHighlightFeatures(features);
// 将 OpenLayers Feature 转换为 GeoJSON Feature
const geojsonFormat = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, { maxZoom: 18, duration: 1000 });
}
}
});
}
};
// 初始化管道图层和高亮图层
useEffect(() => {
if (!map) return;
const burstPipeStyle = function (feature: FeatureLike) {
const styles = [];
// 线条样式(底层发光,主线条,内层高亮线)
styles.push(
new Style({
stroke: new Stroke({
color: "rgba(255, 0, 0, 0.3)",
width: 12,
}),
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 0, 0, 1)",
width: 6,
lineDash: [15, 10],
}),
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 102, 102, 1)",
width: 3,
lineDash: [15, 10],
}),
}),
);
const geometry = feature.getGeometry();
const lineCoords =
geometry?.getType() === "LineString"
? (geometry as any).getCoordinates()
: null;
if (geometry && lineCoords) {
const lineCoordsWGS84 = lineCoords.map((coord: []) => {
const [lon, lat] = toLonLat(coord);
return [lon, lat];
});
// 计算中点
const lineStringFeature = lineString(lineCoordsWGS84);
const lineLength = length(lineStringFeature);
const midPoint = along(lineStringFeature, lineLength / 2).geometry
.coordinates;
// 在中点添加 icon 样式
const midPointMercator = toMercator(midPoint);
styles.push(
new Style({
geometry: new Point(midPointMercator),
image: new Icon({
src: "/icons/burst_pipe.svg",
scale: 0.2,
anchor: [0.5, 1],
}),
}),
);
}
return styles;
};
// 创建高亮图层
const highlightLayer = new VectorLayer({
source: new VectorSource(),
style: burstPipeStyle,
maxZoom: 24,
minZoom: 12,
properties: {
name: "爆管管段高亮",
value: "burst_pipe_highlight",
queryable: false,
},
});
map.addLayer(highlightLayer);
highlightLayerRef.current = highlightLayer;
return () => {
highlightLayerRef.current = null;
map.removeLayer(highlightLayer);
};
}, [map]);
// 高亮要素的函数
useEffect(() => {
const source = highlightLayerRef.current?.getSource();
if (!source) {
return;
}
// 清除之前的高亮
source.clear();
// 添加新的高亮要素
highlightFeatures.forEach((feature) => {
if (feature instanceof Feature) {
source.addFeature(feature);
}
});
}, [highlightFeatures]);
// 取第一条记录或空对象
const result = results.length > 0 ? results[0] : null;
return (
<Box className="flex flex-col h-full">
{/* 结果展示 */}
<Box className="flex-1 overflow-auto bg-white rounded border border-gray-200">
{!result ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<circle
cx="40"
cy="40"
r="25"
stroke="currentColor"
strokeWidth="2"
/>
<circle cx="40" cy="40" r="5" fill="currentColor" />
<line
x1="40"
y1="15"
x2="40"
y2="25"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="40"
y1="55"
x2="40"
y2="65"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="15"
y1="40"
x2="25"
y2="40"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="55"
y1="40"
x2="65"
y2="40"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"></Typography>
<Typography variant="body2" className="mt-1">
</Typography>
</Box>
) : (
<Box className="p-5 h-full overflow-auto">
{/* 头部:标识信息 */}
<Box className="mb-5">
<Box className="flex items-center gap-2 mb-1">
<Typography
variant="h6"
className="font-bold text-gray-900"
title={result.burst_incident}
>
{result.burst_incident}
</Typography>
<Chip
label={
result.type === "burst_analysis" ? "爆管模拟" : result.type
}
size="small"
color="primary"
variant="outlined"
sx={{
fontWeight: 600,
fontSize: "0.75rem",
height: "24px",
}}
/>
</Box>
<Typography variant="caption" className="text-gray-500">
ID: {result.id}
</Typography>
</Box>
{/* 主要信息:三栏卡片布局 */}
<Box className="grid grid-cols-3 gap-3 mb-5">
{/* 检测时间卡片 */}
<Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm hover:shadow-md transition-shadow">
<Box className="flex items-center gap-1.5 mb-2">
<Box className="w-1.5 h-1.5 rounded-full bg-blue-600"></Box>
<Typography
variant="caption"
className="text-blue-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-blue-900 leading-tight"
sx={{ fontSize: "0.875rem" }}
>
{formatTime(result.detect_time)}
</Typography>
</Box>
{/* 漏损量卡片 */}
<Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm hover:shadow-md transition-shadow">
<Box className="flex items-center gap-1.5 mb-2">
<Box className="w-1.5 h-1.5 rounded-full bg-orange-600"></Box>
<Typography
variant="caption"
className="text-orange-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-orange-900"
sx={{ fontSize: "0.875rem" }}
>
{result.leakage !== null
? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`
: "N/A"}
</Typography>
</Box>
{/* 定位管段数量卡片 */}
<Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm hover:shadow-md transition-shadow">
<Box className="flex items-center gap-1.5 mb-2">
<Box className="w-1.5 h-1.5 rounded-full bg-green-600"></Box>
<Typography
variant="caption"
className="text-green-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-green-900"
sx={{ fontSize: "0.875rem" }}
>
{result.locate_result ? result.locate_result.length : 0}{" "}
</Typography>
</Box>
</Box>
{/* 定位管段详细列表 */}
{result.locate_result && result.locate_result.length > 0 && (
<Box className="bg-white rounded-lg p-4 border-2 border-blue-200 shadow-sm">
<Box className="flex items-center justify-between mb-3">
<Typography
variant="body1"
className="text-gray-900 font-bold"
sx={{ fontSize: "0.95rem" }}
>
</Typography>
<Box className="flex items-center gap-2">
<Tooltip title="定位所有管道">
<IconButton
size="small"
onClick={() => handleLocatePipes(result.locate_result!)}
color="primary"
sx={{
backgroundColor: "rgba(37, 125, 212, 0.1)",
"&:hover": {
backgroundColor: "rgba(37, 125, 212, 0.2)",
},
}}
>
<LocationIcon sx={{ fontSize: "1.2rem" }} />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box className="grid grid-cols-2 gap-2">
{result.locate_result.map((pipeId, idx) => (
<Box
key={idx}
className="bg-gradient-to-r from-blue-50 to-white rounded-lg px-3 py-2 border border-blue-200 hover:border-blue-400 hover:shadow-md transition-all cursor-pointer group"
onClick={() => handleLocatePipes([pipeId])}
sx={{
"&:active": {
transform: "scale(0.98)",
boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)",
},
}}
>
<Box className="flex items-center justify-between">
<Typography
variant="body2"
className="font-semibold text-blue-700 group-hover:text-blue-900"
>
{pipeId}
</Typography>
<Box className="flex items-center gap-1">
{/* <Tooltip title="定位管段">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleLocatePipes([pipeId]);
}}
sx={{
"&:hover": {
backgroundColor: "rgba(37, 125, 212, 0.1)",
},
}}
>
<LocationIcon sx={{ fontSize: "1rem" }} />
</IconButton>
</Tooltip> */}
</Box>
</Box>
</Box>
))}
</Box>
</Box>
)}
</Box>
)}
</Box>
</Box>
);
};
export default LocationResults;
@@ -18,6 +18,7 @@ import {
Link,
} from "@mui/material";
import {
DescriptionOutlined as ReportIcon,
Info as InfoIcon,
LocationOn as LocationIcon,
} from "@mui/icons-material";
@@ -59,7 +60,7 @@ import {
interface SchemeQueryProps {
schemes?: SchemeRecord[];
onSchemesChange?: (schemes: SchemeRecord[]) => void;
onLocate?: (scheme: SchemeRecord) => void;
onViewReport?: (scheme: SchemeRecord) => void;
network?: string;
state?: BurstSchemeQueryState;
onStateChange?: (state: BurstSchemeQueryState) => void;
@@ -88,7 +89,7 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes,
onSchemesChange,
onLocate,
onViewReport,
network = NETWORK_NAME,
state,
onStateChange,
@@ -599,14 +600,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="查看定位结果">
<Tooltip title="查看分析报告">
<IconButton
size="small"
onClick={() => onLocate?.(scheme)}
onClick={() => onViewReport?.(scheme)}
color="primary"
className="p-1"
>
<LocationIcon fontSize="small" />
<ReportIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
@@ -52,9 +52,12 @@ import {
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { isAllowedAccidentPipe } from "./valveIsolationScope";
interface ValveIsolationProps {
initialPipeIds?: string[];
allowedPipeIds?: string[];
sourceSchemeName?: string;
shouldFetch?: boolean;
onFetchComplete?: () => void;
loading?: boolean;
@@ -81,6 +84,8 @@ export const createValveIsolationState = (): ValveIsolationState => ({
const ValveIsolation: React.FC<ValveIsolationProps> = ({
initialPipeIds = [],
allowedPipeIds,
sourceSchemeName,
shouldFetch = false,
onFetchComplete,
loading: externalLoading,
@@ -160,14 +165,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
return;
}
setSelectedPipeId(pipeId);
const normalizedPipeId = String(pipeId);
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
open?.({
type: "error",
message: "请选择当前爆管方案中的事故管段",
});
return;
}
setSelectedPipeId(normalizedPipeId);
setHighlightFeature(feature);
setIsSelecting(false);
setResult(null); // 清除旧结果
}
}
},
[isSelecting, map, open, setResult, setSelectedPipeId],
[
allowedPipeIds,
isSelecting,
map,
open,
setResult,
setSelectedPipeId,
],
);
useEffect(() => {
@@ -207,6 +228,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
"must_close" | "optional" | "affected_node" | "pipe"
>("affected_node");
const selectSchemeAccidentPipe = (pipeId: string) => {
if (!isAllowedAccidentPipe(pipeId, allowedPipeIds)) return;
setSelectedPipeId(pipeId);
setHighlightFeature(null);
setHighlightFeatures([]);
setResult(null);
setActiveStep(0);
setExpandedResult(true);
setDisabledValves([]);
};
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
if (pipeIds.length > 0) {
@@ -311,6 +342,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
return;
}
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
open?.({
type: "error",
message: "关阀分析仅限当前爆管方案中的事故管段",
});
return;
}
setLoading(true);
const isExpandSearch = disabled.length > 0;
if (!isExpandSearch) {
@@ -352,7 +390,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
setLoading(false);
}
},
[open, setActiveStep, setDisabledValves, setLoading, setResult],
[
allowedPipeIds,
open,
setActiveStep,
setDisabledValves,
setLoading,
setResult,
],
);
// 监听外部传入的分析请求
@@ -912,6 +957,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</StepLabel>
<StepContent>
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
{allowedPipeIds !== undefined ? (
<Alert severity="info" variant="outlined">
{sourceSchemeName || "未命名方案"}
</Alert>
) : (
<Alert severity="warning" variant="outlined">
</Alert>
)}
{/* 选择管段 */}
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
<Box className="flex items-center justify-between mb-2">
@@ -955,6 +1010,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
)}
</Box>
{allowedPipeIds !== undefined && (
<Box className="mb-3 rounded border border-blue-100 bg-blue-50 p-2">
<Box className="mb-2 flex flex-wrap gap-1.5">
{allowedPipeIds.map((pipeId) => (
<Chip
key={pipeId}
label={pipeId}
size="small"
color={selectedPipeId === pipeId ? "primary" : "default"}
variant={selectedPipeId === pipeId ? "filled" : "outlined"}
onClick={() => selectSchemeAccidentPipe(pipeId)}
/>
))}
</Box>
<Button
variant="outlined"
size="small"
fullWidth
disabled={allowedPipeIds.length === 0}
onClick={() =>
allowedPipeIds[0] &&
selectSchemeAccidentPipe(allowedPipeIds[0])
}
startIcon={<CheckCircleIcon />}
>
</Button>
</Box>
)}
{isSelecting && (
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
💡
@@ -28,15 +28,6 @@ export interface SchemaItem {
scheme_detail?: SchemeDetail;
}
export interface LocationResult {
id: number;
type: string;
burst_incident: string;
leakage: number | null;
detect_time: string;
locate_result: string[] | null;
}
export interface ValveIsolationResult {
accident_elements: string[];
affected_nodes: string[];
@@ -0,0 +1,4 @@
export const isAllowedAccidentPipe = (
pipeId: string,
allowedPipeIds: string[] | undefined,
) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId);