合并 agent-mvp 到 master #1
@@ -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,
|
ChevronLeft,
|
||||||
Analytics as AnalyticsIcon,
|
Analytics as AnalyticsIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
MyLocation as MyLocationIcon,
|
DescriptionOutlined as ReportIcon,
|
||||||
Handyman as HandymanIcon,
|
Handyman as HandymanIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import AnalysisParameters, {
|
import AnalysisParameters, {
|
||||||
@@ -26,15 +26,12 @@ import SchemeQuery, {
|
|||||||
createBurstSchemeQueryState,
|
createBurstSchemeQueryState,
|
||||||
type BurstSchemeQueryState,
|
type BurstSchemeQueryState,
|
||||||
} from "./SchemeQuery";
|
} from "./SchemeQuery";
|
||||||
import LocationResults from "./LocationResults";
|
import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport";
|
||||||
import ValveIsolation, {
|
import ValveIsolation, {
|
||||||
createValveIsolationState,
|
createValveIsolationState,
|
||||||
type ValveIsolationState,
|
type ValveIsolationState,
|
||||||
} from "./ValveIsolation";
|
} from "./ValveIsolation";
|
||||||
import { api } from "@/lib/api";
|
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||||
import { config } from "@config/config";
|
|
||||||
import { useNotification } from "@refinedev/core";
|
|
||||||
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
|
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -75,16 +72,16 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
|
|
||||||
// 持久化方案查询结果
|
// 持久化方案查询结果
|
||||||
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
|
||||||
// 定位结果数据
|
const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null);
|
||||||
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
|
const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null);
|
||||||
// 关阀分析结果和加载状态
|
// 关阀分析结果和加载状态
|
||||||
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
|
||||||
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
|
||||||
|
const [valveAnalysisSchemeName, setValveAnalysisSchemeName] =
|
||||||
|
useState<string | null>(null);
|
||||||
const [valveIsolationState, setValveIsolationState] =
|
const [valveIsolationState, setValveIsolationState] =
|
||||||
useState<ValveIsolationState>(createValveIsolationState);
|
useState<ValveIsolationState>(createValveIsolationState);
|
||||||
|
|
||||||
const { open } = useNotification();
|
|
||||||
|
|
||||||
// 使用受控或非受控状态
|
// 使用受控或非受控状态
|
||||||
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
@@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
setCurrentTab(newValue);
|
setCurrentTab(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLocateScheme = async (scheme: SchemeRecord) => {
|
const handleViewReport = (scheme: SchemeRecord) => {
|
||||||
try {
|
if (
|
||||||
const response = await api.get(
|
valveAnalysisSchemeName !== scheme.schemeName ||
|
||||||
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
|
!matchesValveAnalysis(scheme, valveAnalysisResult)
|
||||||
);
|
) {
|
||||||
setLocationResults(response.data);
|
setValveAnalysisResult(null);
|
||||||
setCurrentTab(2); // 切换到定位结果标签页
|
setValveAnalysisSchemeName(null);
|
||||||
} catch (error) {
|
setValveIsolationState(createValveIsolationState());
|
||||||
console.error("获取定位结果失败:", error);
|
|
||||||
open?.({
|
|
||||||
type: "error",
|
|
||||||
message: "获取定位结果失败",
|
|
||||||
description: "无法从服务器获取该方案的定位结果",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setReportScheme(scheme);
|
||||||
|
setReportGeneratedAt(new Date());
|
||||||
|
setCurrentTab(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValveAnalysisResultChange = (
|
||||||
|
result: ValveIsolationResult | null,
|
||||||
|
) => {
|
||||||
|
setValveAnalysisResult(result);
|
||||||
|
setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawerWidth = 520;
|
const drawerWidth = 520;
|
||||||
@@ -226,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
label="方案查询"
|
label="方案查询"
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
icon={<MyLocationIcon fontSize="small" />}
|
icon={<ReportIcon fontSize="small" />}
|
||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
label="定位结果"
|
label="分析报告"
|
||||||
/>
|
/>
|
||||||
<Tab
|
<Tab
|
||||||
icon={<HandymanIcon fontSize="small" />}
|
icon={<HandymanIcon fontSize="small" />}
|
||||||
@@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
<SchemeQuery
|
<SchemeQuery
|
||||||
schemes={schemes}
|
schemes={schemes}
|
||||||
onSchemesChange={setSchemes}
|
onSchemesChange={setSchemes}
|
||||||
onLocate={handleLocateScheme}
|
onViewReport={handleViewReport}
|
||||||
state={queryState}
|
state={queryState}
|
||||||
onStateChange={setQueryState}
|
onStateChange={setQueryState}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={currentTab} index={2}>
|
<TabPanel value={currentTab} index={2}>
|
||||||
<LocationResults
|
<AnalysisReport
|
||||||
results={locationResults}
|
scheme={reportScheme}
|
||||||
|
valveResult={valveAnalysisResult}
|
||||||
|
disabledValves={valveIsolationState.disabledValves}
|
||||||
|
generatedAt={reportGeneratedAt}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
|
|||||||
loading={valveAnalysisLoading}
|
loading={valveAnalysisLoading}
|
||||||
result={valveAnalysisResult}
|
result={valveAnalysisResult}
|
||||||
onLoadingChange={setValveAnalysisLoading}
|
onLoadingChange={setValveAnalysisLoading}
|
||||||
onResultChange={setValveAnalysisResult}
|
onResultChange={handleValveAnalysisResultChange}
|
||||||
state={valveIsolationState}
|
state={valveIsolationState}
|
||||||
onStateChange={setValveIsolationState}
|
onStateChange={setValveIsolationState}
|
||||||
|
allowedPipeIds={
|
||||||
|
reportScheme?.schemeDetail?.burst_ID
|
||||||
|
}
|
||||||
|
sourceSchemeName={reportScheme?.schemeName}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Box>
|
</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,
|
Link,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
|
DescriptionOutlined as ReportIcon,
|
||||||
Info as InfoIcon,
|
Info as InfoIcon,
|
||||||
LocationOn as LocationIcon,
|
LocationOn as LocationIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
@@ -59,7 +60,7 @@ import {
|
|||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: SchemeRecord[];
|
schemes?: SchemeRecord[];
|
||||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||||
onLocate?: (scheme: SchemeRecord) => void;
|
onViewReport?: (scheme: SchemeRecord) => void;
|
||||||
network?: string;
|
network?: string;
|
||||||
state?: BurstSchemeQueryState;
|
state?: BurstSchemeQueryState;
|
||||||
onStateChange?: (state: BurstSchemeQueryState) => void;
|
onStateChange?: (state: BurstSchemeQueryState) => void;
|
||||||
@@ -88,7 +89,7 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
|
|||||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||||
schemes: externalSchemes,
|
schemes: externalSchemes,
|
||||||
onSchemesChange,
|
onSchemesChange,
|
||||||
onLocate,
|
onViewReport,
|
||||||
network = NETWORK_NAME,
|
network = NETWORK_NAME,
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
@@ -599,14 +600,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
<InfoIcon fontSize="small" />
|
<InfoIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="查看定位结果">
|
<Tooltip title="查看分析报告">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onLocate?.(scheme)}
|
onClick={() => onViewReport?.(scheme)}
|
||||||
color="primary"
|
color="primary"
|
||||||
className="p-1"
|
className="p-1"
|
||||||
>
|
>
|
||||||
<LocationIcon fontSize="small" />
|
<ReportIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -52,9 +52,12 @@ import {
|
|||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { isAllowedAccidentPipe } from "./valveIsolationScope";
|
||||||
|
|
||||||
interface ValveIsolationProps {
|
interface ValveIsolationProps {
|
||||||
initialPipeIds?: string[];
|
initialPipeIds?: string[];
|
||||||
|
allowedPipeIds?: string[];
|
||||||
|
sourceSchemeName?: string;
|
||||||
shouldFetch?: boolean;
|
shouldFetch?: boolean;
|
||||||
onFetchComplete?: () => void;
|
onFetchComplete?: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -81,6 +84,8 @@ export const createValveIsolationState = (): ValveIsolationState => ({
|
|||||||
|
|
||||||
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||||
initialPipeIds = [],
|
initialPipeIds = [],
|
||||||
|
allowedPipeIds,
|
||||||
|
sourceSchemeName,
|
||||||
shouldFetch = false,
|
shouldFetch = false,
|
||||||
onFetchComplete,
|
onFetchComplete,
|
||||||
loading: externalLoading,
|
loading: externalLoading,
|
||||||
@@ -160,14 +165,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedPipeId(pipeId);
|
const normalizedPipeId = String(pipeId);
|
||||||
|
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "请选择当前爆管方案中的事故管段",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedPipeId(normalizedPipeId);
|
||||||
setHighlightFeature(feature);
|
setHighlightFeature(feature);
|
||||||
setIsSelecting(false);
|
setIsSelecting(false);
|
||||||
setResult(null); // 清除旧结果
|
setResult(null); // 清除旧结果
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isSelecting, map, open, setResult, setSelectedPipeId],
|
[
|
||||||
|
allowedPipeIds,
|
||||||
|
isSelecting,
|
||||||
|
map,
|
||||||
|
open,
|
||||||
|
setResult,
|
||||||
|
setSelectedPipeId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -207,6 +228,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
"must_close" | "optional" | "affected_node" | "pipe"
|
"must_close" | "optional" | "affected_node" | "pipe"
|
||||||
>("affected_node");
|
>("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) => {
|
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
|
||||||
if (pipeIds.length > 0) {
|
if (pipeIds.length > 0) {
|
||||||
@@ -311,6 +342,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
|
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
|
||||||
|
open?.({
|
||||||
|
type: "error",
|
||||||
|
message: "关阀分析仅限当前爆管方案中的事故管段",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const isExpandSearch = disabled.length > 0;
|
const isExpandSearch = disabled.length > 0;
|
||||||
if (!isExpandSearch) {
|
if (!isExpandSearch) {
|
||||||
@@ -352,7 +390,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[open, setActiveStep, setDisabledValves, setLoading, setResult],
|
[
|
||||||
|
allowedPipeIds,
|
||||||
|
open,
|
||||||
|
setActiveStep,
|
||||||
|
setDisabledValves,
|
||||||
|
setLoading,
|
||||||
|
setResult,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 监听外部传入的分析请求
|
// 监听外部传入的分析请求
|
||||||
@@ -912,6 +957,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
</StepLabel>
|
</StepLabel>
|
||||||
<StepContent>
|
<StepContent>
|
||||||
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
|
<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">
|
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
|
||||||
<Box className="flex items-center justify-between mb-2">
|
<Box className="flex items-center justify-between mb-2">
|
||||||
@@ -955,6 +1010,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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 && (
|
{isSelecting && (
|
||||||
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
<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;
|
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 {
|
export interface ValveIsolationResult {
|
||||||
accident_elements: string[];
|
accident_elements: string[];
|
||||||
affected_nodes: 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