From c4246cf25f50057807698f1a648c86d294476f68 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 13:16:05 +0800 Subject: [PATCH] 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. --- .../BurstSimulation/AnalysisReport.test.tsx | 200 ++++++ .../olmap/BurstSimulation/AnalysisReport.tsx | 592 ++++++++++++++++++ .../BurstPipeAnalysisPanel.test.tsx | 154 +++++ .../BurstPipeAnalysisPanel.tsx | 68 +- .../olmap/BurstSimulation/LocationResults.tsx | 416 ------------ .../olmap/BurstSimulation/SchemeQuery.tsx | 11 +- .../olmap/BurstSimulation/ValveIsolation.tsx | 91 ++- src/components/olmap/BurstSimulation/types.ts | 9 - .../BurstSimulation/valveIsolationScope.ts | 4 + 9 files changed, 1082 insertions(+), 463 deletions(-) create mode 100644 src/components/olmap/BurstSimulation/AnalysisReport.test.tsx create mode 100644 src/components/olmap/BurstSimulation/AnalysisReport.tsx create mode 100644 src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx delete mode 100644 src/components/olmap/BurstSimulation/LocationResults.tsx create mode 100644 src/components/olmap/BurstSimulation/valveIsolationScope.ts diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx new file mode 100644 index 0000000..b33ef70 --- /dev/null +++ b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + + await waitFor(() => + expect( + within(screen.getByTestId("burst-analysis-report-preview")).getByText( + "800 mm", + ), + ).toBeInTheDocument(), + ); + expect(queryFeaturesByIds).toHaveBeenCalledTimes(2); + + firstRender.unmount(); + render(); + + expect( + within(screen.getByTestId("burst-analysis-report-preview")).getByText( + "800 mm", + ), + ).toBeInTheDocument(); + expect(queryFeaturesByIds).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.tsx new file mode 100644 index 0000000..519eea2 --- /dev/null +++ b/src/components/olmap/BurstSimulation/AnalysisReport.tsx @@ -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(); +const diameterRequestCache = new Map>(); + +export const clearAnalysisReportDiameterCache = () => { + diameterCache.clear(); + diameterRequestCache.clear(); +}; + +const loadPipeDiameters = async ( + pipeIds: string[], + queryKey: string, +): Promise => { + 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 }) => ( + + + + {children} + + +); + +const IdList = ({ + values, + emptyText = "无", +}: { + values: string[] | undefined; + emptyText?: string; +}) => + values?.length ? ( + + {values.map((value) => ( + + ))} + + ) : ( + + {emptyText} + + ); + +const ReportDocument: React.FC = ({ + 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 ( + + + + 爆管分析报告 + + + 报告编号:BA-{scheme.id} 生成时间:{formatDateTime(generatedAt)} + + + + + 方案概况 + + {[ + ["管网", NETWORK_NAME], + ["方案名称", scheme.schemeName], + ["方案创建人", scheme.user || "未记录"], + ["方案创建时间", formatDateTime(scheme.create_time)], + ["模拟开始时间", formatDateTime(scheme.startTime)], + ["模拟持续时间", formatDuration(duration)], + ].map(([label, value]) => ( + + + {label} + + + {value} + + + ))} + + + + + 爆管模拟参数 + + + + + 序号 + 爆管管段 + 管径 + 爆管面积 + + + + {pipeIds.length ? ( + pipeIds.map((pipeId, index) => ( + + {index + 1} + {pipeId} + + {getPipeDiameterDisplay( + [pipeId], + diameters, + loadingDiameters, + )} + + + {Number.isFinite(burstSizes[index]) + ? `${burstSizes[index]} cm²` + : "未记录"} + + + )) + ) : ( + + + 未记录爆管管段 + + + )} + +
+
+
+ + + 分析结论 + + 本方案记录了 {pipeIds.length} 条爆管管段,模拟持续时间为 + {formatDuration(duration)}。 + {matchedValveResult + ? ` 当前关阀分析判定事故管段${ + matchedValveResult.isolatable ? "可以" : "无法" + }有效隔离。` + : " 当前会话尚未对本方案执行匹配的关阀分析。"} + + + + + 关阀分析 + {matchedValveResult ? ( + + + {[ + ["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"], + ["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`], + ["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0} 个`], + ].map(([label, value]) => ( + + + {label} + + + {value} + + + ))} + + {[ + ["已分析事故管段", matchedValveResult.accident_elements], + ["必关阀门", matchedValveResult.must_close_valves], + ["可选阀门", matchedValveResult.optional_valves], + ["不可用阀门", disabledValves], + ["受影响节点", matchedValveResult.affected_nodes], + ].map(([label, values]) => ( + + + {label as string} + + + + ))} + + ) : ( + + 本方案尚未执行关阀分析。可在“关阀分析”页签完成分析后重新查看报告。 + + )} + +
+ ); +}; + +const AnalysisReport: React.FC = ({ + 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( + () => ( + + ), + [ + diameters, + disabledValves, + generatedAt, + loadingDiameters, + scheme, + valveResult, + ], + ); + + if (!scheme) { + return ( + + + + 等待选择分析方案 + + + 请在“方案查询”中点击“查看分析报告”。 + + + ); + } + + return ( + <> + *: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", + }, + }, + }} + /> + + + + + + {reportDocument} + + + {printReady && ( + + + + )} + + ); +}; + +export default AnalysisReport; diff --git a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx new file mode 100644 index 0000000..7e6b7a6 --- /dev/null +++ b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import BurstPipeAnalysisPanel from "./BurstPipeAnalysisPanel"; + +jest.mock("./AnalysisParameters", () => ({ + __esModule: true, + default: () =>
analysis parameters
, + createBurstAnalysisParametersState: () => ({}), +})); + +jest.mock("./SchemeQuery", () => ({ + __esModule: true, + default: ({ + onViewReport, + }: { + onViewReport: (scheme: Record) => 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 ( + <> + + + + ); + }, + createBurstSchemeQueryState: () => ({}), +})); + +jest.mock("./AnalysisReport", () => ({ + __esModule: true, + default: ({ + scheme, + valveResult, + }: { + scheme: { schemeName: string } | null; + valveResult: { accident_elements: string[] } | null; + }) => ( +
+ report:{scheme?.schemeName ?? "empty"}:valve: + {valveResult?.accident_elements.join(",") ?? "none"} +
+ ), + 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) => void; + }) => ( + <> +
+ valve scope:{sourceSchemeName ?? "independent"}: + {allowedPipeIds?.join(",") ?? "any"} +
+ + + ), + createValveIsolationState: () => ({ + selectedPipeId: null, + activeStep: 0, + expandedResult: true, + disabledValves: [], + }), +})); + +describe("BurstPipeAnalysisPanel", () => { + it("replaces the obsolete location tab and opens the selected report", () => { + render(); + + 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(); + + 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(); + }); +}); diff --git a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx index f305a34..3481f95 100644 --- a/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx +++ b/src/components/olmap/BurstSimulation/BurstPipeAnalysisPanel.tsx @@ -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 = ({ // 持久化方案查询结果 const [schemes, setSchemes] = useState([]); - // 定位结果数据 - const [locationResults, setLocationResults] = useState([]); + const [reportScheme, setReportScheme] = useState(null); + const [reportGeneratedAt, setReportGeneratedAt] = useState(null); // 关阀分析结果和加载状态 const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false); const [valveAnalysisResult, setValveAnalysisResult] = useState(null); + const [valveAnalysisSchemeName, setValveAnalysisSchemeName] = + useState(null); const [valveIsolationState, setValveIsolationState] = useState(createValveIsolationState); - const { open } = useNotification(); - // 使用受控或非受控状态 const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; const handleToggle = () => { @@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC = ({ 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 = ({ label="方案查询" /> } + icon={} iconPosition="start" - label="定位结果" + label="分析报告" /> } @@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC = ({ - @@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC = ({ loading={valveAnalysisLoading} result={valveAnalysisResult} onLoadingChange={setValveAnalysisLoading} - onResultChange={setValveAnalysisResult} + onResultChange={handleValveAnalysisResultChange} state={valveIsolationState} onStateChange={setValveIsolationState} + allowedPipeIds={ + reportScheme?.schemeDetail?.burst_ID + } + sourceSchemeName={reportScheme?.schemeName} /> diff --git a/src/components/olmap/BurstSimulation/LocationResults.tsx b/src/components/olmap/BurstSimulation/LocationResults.tsx deleted file mode 100644 index 9f14001..0000000 --- a/src/components/olmap/BurstSimulation/LocationResults.tsx +++ /dev/null @@ -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 = ({ - results = [], -}) => { - const highlightLayerRef = useRef | null>(null); - const [highlightFeatures, setHighlightFeatures] = useState([]); - 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 ( - - {/* 结果展示 */} - - {!result ? ( - - - - - - - - - - - - 暂无定位结果 - - 请先执行方案分析 - - - ) : ( - - {/* 头部:标识信息 */} - - - - {result.burst_incident} - - - - - ID: {result.id} - - - - {/* 主要信息:三栏卡片布局 */} - - {/* 检测时间卡片 */} - - - - - 检测时间 - - - - {formatTime(result.detect_time)} - - - - {/* 漏损量卡片 */} - - - - - 漏损量 - - - - {result.leakage !== null - ? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}` - : "N/A"} - - - - {/* 定位管段数量卡片 */} - - - - - 定位管段 - - - - {result.locate_result ? result.locate_result.length : 0}{" "} - 个管段 - - - - - {/* 定位管段详细列表 */} - {result.locate_result && result.locate_result.length > 0 && ( - - - - 管段列表 - - - - handleLocatePipes(result.locate_result!)} - color="primary" - sx={{ - backgroundColor: "rgba(37, 125, 212, 0.1)", - "&:hover": { - backgroundColor: "rgba(37, 125, 212, 0.2)", - }, - }} - > - - - - - - - {result.locate_result.map((pipeId, idx) => ( - handleLocatePipes([pipeId])} - sx={{ - "&:active": { - transform: "scale(0.98)", - boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)", - }, - }} - > - - - {pipeId} - - - {/* - { - e.stopPropagation(); - handleLocatePipes([pipeId]); - }} - sx={{ - "&:hover": { - backgroundColor: "rgba(37, 125, 212, 0.1)", - }, - }} - > - - - */} - - - - ))} - - - )} - - )} - - - ); -}; - -export default LocationResults; diff --git a/src/components/olmap/BurstSimulation/SchemeQuery.tsx b/src/components/olmap/BurstSimulation/SchemeQuery.tsx index fa38b0e..734ddef 100644 --- a/src/components/olmap/BurstSimulation/SchemeQuery.tsx +++ b/src/components/olmap/BurstSimulation/SchemeQuery.tsx @@ -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 = ({ schemes: externalSchemes, onSchemesChange, - onLocate, + onViewReport, network = NETWORK_NAME, state, onStateChange, @@ -599,14 +600,14 @@ const SchemeQuery: React.FC = ({ - + onLocate?.(scheme)} + onClick={() => onViewReport?.(scheme)} color="primary" className="p-1" > - + diff --git a/src/components/olmap/BurstSimulation/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx index 93f80d0..19ff8db 100644 --- a/src/components/olmap/BurstSimulation/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -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 = ({ initialPipeIds = [], + allowedPipeIds, + sourceSchemeName, shouldFetch = false, onFetchComplete, loading: externalLoading, @@ -160,14 +165,30 @@ const ValveIsolation: React.FC = ({ 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 = ({ "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 = ({ 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 = ({ setLoading(false); } }, - [open, setActiveStep, setDisabledValves, setLoading, setResult], + [ + allowedPipeIds, + open, + setActiveStep, + setDisabledValves, + setLoading, + setResult, + ], ); // 监听外部传入的分析请求 @@ -912,6 +957,16 @@ const ValveIsolation: React.FC = ({ + {allowedPipeIds !== undefined ? ( + + 已绑定爆管方案“{sourceSchemeName || "未命名方案"}”。关阀分析仅限该方案中的事故管段,分析结果将写入对应报告。 + + ) : ( + + 当前为独立关阀分析,结果不会自动写入爆管分析报告。请先从“方案查询”打开对应分析报告以建立关联。 + + )} + {/* 选择管段 */} @@ -955,6 +1010,36 @@ const ValveIsolation: React.FC = ({ )} + {allowedPipeIds !== undefined && ( + + + {allowedPipeIds.map((pipeId) => ( + selectSchemeAccidentPipe(pipeId)} + /> + ))} + + + + )} + {isSelecting && ( 💡 点击地图上的管道添加爆管点 diff --git a/src/components/olmap/BurstSimulation/types.ts b/src/components/olmap/BurstSimulation/types.ts index 9d8f5d7..91abb26 100644 --- a/src/components/olmap/BurstSimulation/types.ts +++ b/src/components/olmap/BurstSimulation/types.ts @@ -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[]; diff --git a/src/components/olmap/BurstSimulation/valveIsolationScope.ts b/src/components/olmap/BurstSimulation/valveIsolationScope.ts new file mode 100644 index 0000000..9c8c14c --- /dev/null +++ b/src/components/olmap/BurstSimulation/valveIsolationScope.ts @@ -0,0 +1,4 @@ +export const isAllowedAccidentPipe = ( + pipeId: string, + allowedPipeIds: string[] | undefined, +) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId);