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);