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