19 Commits
Author SHA1 Message Date
jiang 0e638046ed fix(frontend): update production dependency security patches
Build Push and Deploy / docker-image (push) Successful in 2m7s
Build Push and Deploy / deploy-fallback-log (push) Skipped
2026-07-30 22:41:30 +08:00
jiang 65ed1fd212 fix(sensor-placement): refine drawing export and editor density
Build Push and Deploy / docker-image (push) Failing after 23s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-07-30 21:39:27 +08:00
jiang 81cc5dcae1 fix(burst-location): clarify simulation baseline requirement 2026-07-30 20:55:01 +08:00
jiang 782363cfb6 fix(api): align frontend with REST contracts 2026-07-30 20:38:52 +08:00
jiang b57e58ff87 fix(chat): 调整输入区操作按钮布局 2026-07-30 19:07:17 +08:00
jiang 38d246eb7b fix(dma): 放宽总漏损流量下限 2026-07-30 18:15:36 +08:00
jiang 723931b6ae feat(frontend): enforce RBAC and refine burst analysis 2026-07-30 16:45:10 +08:00
jiang f355ddd002 fix(sensor-placement): smooth result editor transitions 2026-07-30 16:38:06 +08:00
jiang 6114e76735 fix(sensor-placement): stabilize engineering drawing export
Render the OpenLayers basemap at the final A3 map-box resolution with pixelRatio 1, carry provider attribution into the drawing, and keep the linework/basemap export path covered by regression tests.
2026-07-30 16:21:30 +08:00
jiang d643f09fcf feat(sensor-placement): add scheme engineering editor 2026-07-30 16:16:51 +08:00
jiang 04557b9363 feat(ui): 统一面板空状态引导 2026-07-30 14:34:43 +08:00
jiang 8836549697 fix(auth): unify scheme creator display 2026-07-30 14:21:20 +08:00
jiang 1d7e07174f fix(map): use component-level loading skeletons 2026-07-30 13:21:55 +08:00
jiang c4246cf25f 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.
2026-07-30 13:16:05 +08:00
jiang 1b2a7f4fb8 perf(map): reduce tile snapshot work during zoom 2026-07-30 12:50:33 +08:00
jiang 82e75c03d0 feat(health-risk): add printable assessment report 2026-07-30 11:52:43 +08:00
jiang 5894ee277a fix(frontend): use success style for empty scheme queries 2026-07-30 11:04:31 +08:00
jiang a3331691de test(frontend): strengthen default time assertion 2026-07-30 11:04:15 +08:00
jiang 7745333a58 fix(frontend): query schemes by type 2026-07-30 11:01:45 +08:00
138 changed files with 108242 additions and 2582 deletions
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"contract_version": "1.0.0",
"contracts": {
"agent": {
"file": "agent-v1.openapi.json",
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
}
}
}
File diff suppressed because it is too large Load Diff
+808 -410
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -14,6 +14,8 @@
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
"api:check": "node scripts/check-api-contracts.mjs",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
},
"dependencies": {
@@ -44,7 +46,8 @@
"next": "^16.1.6",
"next-auth": "^4.24.5",
"ol": "^10.7.0",
"postcss": "^8.5.6",
"openapi-fetch": "^0.17.0",
"postcss": "8.5.25",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-draggable": "^4.5.0",
@@ -56,7 +59,9 @@
"zustand": "^5.0.11"
},
"overrides": {
"fast-xml-parser": "5.5.9"
"fast-xml-parser": "5.5.9",
"postcss": "8.5.25",
"sharp": "0.35.3"
},
"devDependencies": {
"@svgr/webpack": "^8.1.0",
@@ -75,6 +80,7 @@
"eslint-config-next": "^16.1.6",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"openapi-typescript": "^7.13.0",
"ts-jest": "^29.4.6",
"typescript": "^5.8.3"
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-labelledby="title">
<title id="title">TJWater</title>
<rect width="72" height="72" rx="18" fill="#1478d4"/>
<path d="M36 13c-7.8 11.1-16.1 19.4-16.1 29.3A16.1 16.1 0 0 0 36 58.4a16.1 16.1 0 0 0 16.1-16.1C52.1 32.4 43.8 24.1 36 13Z" fill="#f5fbfc"/>
<path d="M26.5 43.5h19M31 36.5l5 7 5-7" fill="none" stroke="#0b8f82" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
<circle cx="26.5" cy="43.5" r="2.7" fill="#0b8f82"/>
<circle cx="45.5" cy="43.5" r="2.7" fill="#0b8f82"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice">
<rect width="1440" height="900" fill="#edf5f5"/>
<path d="M0 0h790L650 900H0Z" fill="#dceced"/>
<path d="M0 182c187-78 303-73 451-16 153 59 288 47 409-25M0 552c193-45 327-19 461 74 128 90 266 100 410 47" fill="none" stroke="#c8dddd" stroke-width="2"/>
<g fill="none" stroke="#a9ccce" stroke-linecap="round" stroke-linejoin="round">
<path d="M-32 725 178 608l142 51 155-190 184 67 176-205" stroke-width="5"/>
<path d="m178 608 25-222 151-92 121 175" stroke-width="3"/>
<path d="m203 386-92-95 55-161M354 294l92-141 152 58 98-106" stroke-width="3"/>
<path d="m320 659-4 132 171 81M659 536l88 116 124-42" stroke-width="3"/>
</g>
<g fill="#edf5f5" stroke="#1478d4" stroke-width="4">
<circle cx="178" cy="608" r="10"/><circle cx="203" cy="386" r="9"/>
<circle cx="354" cy="294" r="9"/><circle cx="475" cy="469" r="11"/>
<circle cx="659" cy="536" r="10"/><circle cx="747" cy="652" r="9"/>
<circle cx="320" cy="659" r="8"/>
</g>
<g fill="#0b8f82">
<circle cx="111" cy="291" r="6"/><circle cx="166" cy="130" r="6"/>
<circle cx="446" cy="153" r="7"/><circle cx="598" cy="211" r="6"/>
<circle cx="696" cy="105" r="6"/><circle cx="316" cy="791" r="6"/>
<circle cx="487" cy="872" r="6"/><circle cx="835" cy="331" r="7"/>
</g>
<g fill="none" stroke="#86b8bb" stroke-width="2" opacity=".72">
<circle cx="615" cy="448" r="222"/><circle cx="615" cy="448" r="276"/>
<circle cx="615" cy="448" r="334"/>
</g>
<g fill="#1478d4" opacity=".08">
<rect x="40" y="40" width="118" height="10" rx="5"/>
<rect x="40" y="62" width="72" height="6" rx="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+57
View File
@@ -0,0 +1,57 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const manifest = JSON.parse(
await readFile(new URL("../contracts/manifest.json", import.meta.url), "utf8"),
);
const run = promisify(execFile);
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
const generator = join(
projectRoot,
"node_modules",
"openapi-typescript",
"bin",
"cli.js",
);
const temporaryDirectory = await mkdtemp(
join(tmpdir(), "tjwater-api-contracts-"),
);
try {
for (const [name, contract] of Object.entries(manifest.contracts)) {
const contractPath = fileURLToPath(
new URL(`../contracts/${contract.file}`, import.meta.url),
);
const payload = await readFile(contractPath);
const actual = createHash("sha256").update(payload).digest("hex");
if (actual !== contract.sha256) {
throw new Error(
`${name} contract hash mismatch: expected ${contract.sha256}, got ${actual}`,
);
}
const generatedName = `${name}Api.ts`;
const temporaryOutput = join(temporaryDirectory, generatedName);
await run(process.execPath, [generator, contractPath, "-o", temporaryOutput]);
const expectedOutput = await readFile(
new URL(`../src/generated/${generatedName}`, import.meta.url),
);
const generatedOutput = await readFile(temporaryOutput);
if (!expectedOutput.equals(generatedOutput)) {
throw new Error(
`${basename(contract.file)} generated type is stale: run \`npm run api:generate\``,
);
}
}
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
console.log(
`validated API contract mirrors and generated types for ${manifest.contract_version}`,
);
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,10 +1,10 @@
"use client";
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
import dynamic from "next/dynamic";
import { MapPanelSkeleton, MapTimelineSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
import {
RAINBOW_COLORS,
@@ -12,6 +12,26 @@ import {
} from "@components/olmap/HealthRiskAnalysis/types";
import { Box } from "@mui/material";
const Timeline = dynamic(
() => import("@components/olmap/HealthRiskAnalysis/Timeline"),
{
loading: () => <MapTimelineSkeleton />,
},
);
const HealthRiskStatistics = dynamic(
() =>
import("@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"),
{
loading: () => <MapPanelSkeleton variant="health-risk-analysis" />,
},
);
const PredictDataPanel = dynamic(
() => import("@components/olmap/HealthRiskAnalysis/PredictDataPanel"),
{
loading: () => null,
},
);
export default function Home() {
return (
<HealthRiskProvider>
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
const BurstDetectionPanel = dynamic(
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-detection" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
const BurstLocationPanel = dynamic(
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-location" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
const BurstPipeAnalysisPanel = dynamic(
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
const WaterQualityPanel = dynamic(
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
{
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
const DMALeakDetectionPanel = dynamic(
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
{
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
const FlushingAnalysisPanel = dynamic(
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
{
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,7 +1,21 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
const MonitoringPlaceOptimizationPanel = dynamic(
() =>
import(
"@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"
),
{
loading: () => (
<MapPanelSkeleton variant="monitoring-place-optimization" />
),
},
);
export default function Home() {
return (
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,11 +1,32 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import Timeline from "@components/olmap/core/Controls/Timeline";
import {
MapPanelSkeleton,
MapTimelineSkeleton,
} from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
const Timeline = dynamic(
() => import("@components/olmap/core/Controls/Timeline"),
{
loading: () => <MapTimelineSkeleton />,
},
);
const SCADADeviceList = dynamic(
() => import("@components/olmap/SCADA/SCADADeviceList"),
{
loading: () => <MapPanelSkeleton variant="network-simulation" />,
},
);
const SCADADataPanel = dynamic(
() => import("@components/olmap/SCADA/SCADADataPanel"),
{
loading: () => null,
},
);
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,10 +1,23 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
const SCADADeviceList = dynamic(
() => import("@components/olmap/SCADA/SCADADeviceList"),
{
loading: () => <MapPanelSkeleton variant="scada-data-cleaning" />,
},
);
const SCADADataPanel = dynamic(
() => import("@components/olmap/SCADA/SCADADataPanel"),
{
loading: () => null,
},
);
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
+2 -5
View File
@@ -1,11 +1,10 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import React, { Suspense } from "react";
import type { ReactNode } from "react";
import authOptions from "@app/api/auth/[...nextauth]/options";
import { Header } from "@components/header";
import { Title } from "@components/title";
import { MapSkeleton } from "@components/loading/MapSkeleton";
import { AppSider } from "@components/sider/AppSider";
import { ThemedLayout } from "@refinedev/mui";
import { getServerSession } from "next-auth/next";
@@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA;
export default async function MainLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: ReactNode;
}>) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme");
@@ -49,9 +48,7 @@ export default async function MainLayout({
sx: { height: "100vh", overflow: "hidden" },
}}
>
<Suspense fallback={<MapSkeleton />}>
{children}
</Suspense>
</ThemedLayout>
);
}
+149 -85
View File
@@ -1,32 +1,38 @@
"use client";
import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import {
RefineSnackbarProvider,
} from "@refinedev/mui";
Refine,
type AccessControlProvider,
type AuthProvider,
} from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { RefineSnackbarProvider } from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import routerProvider from "@refinedev/nextjs-router";
import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext";
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
import { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
import {
ManageAccounts as ManageAccountsIcon,
FactCheck as FactCheckIcon,
ManageAccounts as ManageAccountsIcon,
MyLocation as MyLocationIcon,
Search as SearchIcon,
} from "@mui/icons-material";
@@ -36,16 +42,12 @@ type RefineContextProps = {
};
export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps>
) => {
return (
props: React.PropsWithChildren<RefineContextProps>,
) => (
<SessionProvider>
<ProjectProvider>
<App {...props} />
</ProjectProvider>
</SessionProvider>
);
};
type AppProps = {
defaultMode?: string;
@@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession();
const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const [isMetadataAdmin, setIsMetadataAdmin] = useState(false);
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
const resetAccess = useAccessStore((state) => state.reset);
const can = (permission: string) => permissions.includes(permission);
useEffect(() => {
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
setAccessToken(
typeof data?.accessToken === "string" ? data.accessToken : null,
);
}, [data?.accessToken, setAccessToken]);
useEffect(() => {
if (status !== "authenticated") {
setIsMetadataAdmin(false);
resetAccess();
return;
}
let cancelled = false;
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
projectHeaderMode: "omit",
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
skipAuthRedirect: true,
})
.then(async (response) => {
if (cancelled) return;
if (!response.ok) {
setIsMetadataAdmin(false);
resetAccess();
return;
}
const payload = await response.json();
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
setAccessContext(await response.json());
})
.catch(() => {
if (!cancelled) setIsMetadataAdmin(false);
if (!cancelled) resetAccess();
});
return () => {
cancelled = true;
};
}, [status]);
}, [
currentProjectId,
resetAccess,
setAccessContext,
setAccessLoading,
status,
]);
useEffect(() => {
if (status !== "authenticated" || !data?.user?.id) return;
const auditKey = `tjwater-login-audit:${data.user.id}`;
if (sessionStorage.getItem(auditKey)) return;
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "login" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
})
.then((response) => {
if (response.ok) sessionStorage.setItem(auditKey, "1");
})
.catch(() => undefined);
}, [data?.user?.id, status]);
if (status === "loading") {
return <span>loading...</span>;
@@ -100,74 +133,65 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
callbackUrl: to ? to.toString() : "/",
redirect: true,
});
return {
success: true,
};
return { success: true };
},
logout: async () => {
signOut({
redirect: true,
callbackUrl: "/login",
try {
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "logout" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
});
return {
success: true,
};
} catch {
// Logout must still complete when audit storage is unavailable.
}
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
signOut({ redirect: true, callbackUrl: "/login" });
return { success: true };
},
onError: async (error) => {
if (error.response?.status === 401) {
return {
logout: true,
};
return { logout: true };
}
return {
error,
};
},
check: async () => {
if (status === "unauthenticated") {
return {
authenticated: false,
redirectTo: "/login",
};
}
return {
authenticated: true,
};
},
getPermissions: async () => {
return null;
return { error };
},
check: async () =>
status === "unauthenticated"
? { authenticated: false, redirectTo: "/login" }
: { authenticated: true },
getPermissions: async () => permissions,
getIdentity: async () => {
if (data?.user) {
const { user } = data;
if (!data?.user) return null;
return {
id: user.id,
name: user.name,
avatar: user.image,
id: data.user.id,
username: data.user.username,
name: data.user.name,
avatar: data.user.image,
};
}
return null;
},
};
const defaultMode = props?.defaultMode;
const accessControlProvider: AccessControlProvider = {
can: async ({ resource }) => {
const requiredPermission = resource
? resourcePermissions[resource]
: undefined;
return {
can: !requiredPermission || permissions.includes(requiredPermission),
reason: requiredPermission
? `需要权限:${requiredPermission}`
: undefined,
};
},
};
return (
<>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
resources={[
const resources = [
...(can(permissionCodes.simulationView)
? [
{
name: "管网在线模拟",
list: "/network-simulation",
@@ -176,6 +200,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "管网在线模拟",
},
},
]
: []),
...(can(permissionCodes.scadaClean)
? [
{
name: "SCADA 数据清洗",
list: "/scada-data-cleaning",
@@ -184,6 +212,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "SCADA 数据清洗",
},
},
]
: []),
...(can(permissionCodes.optimizationRun)
? [
{
name: "监测点优化布置",
list: "/monitoring-place-optimization",
@@ -192,6 +224,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "监测点优化布置",
},
},
]
: []),
...(can(permissionCodes.riskRun)
? [
{
name: "健康风险分析",
list: "/health-risk-analysis",
@@ -200,13 +236,18 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "健康风险分析",
},
},
]
: []),
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
? [
{
name: "Hydraulic Simulation",
meta: {
// icon: <MdWater className="w-6 h-6" />,
label: "事件模拟",
},
meta: { label: "事件模拟" },
},
]
: []),
...(can(permissionCodes.burstRun)
? [
{
name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation",
@@ -243,6 +284,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "DMA 漏损识别",
},
},
]
: []),
...(can(permissionCodes.simulationRun)
? [
{
name: "水质模拟",
list: "/hydraulic-simulation/contaminant-simulation",
@@ -261,7 +306,9 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "管道冲洗",
},
},
...(isMetadataAdmin
]
: []),
...(can(permissionCodes.environmentManage)
? [
{
name: "系统管理",
@@ -271,6 +318,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
label: "系统管理",
},
},
]
: []),
...(can(permissionCodes.auditView)
? [
{
name: "审计日志",
list: "/audit-logs",
@@ -281,18 +332,31 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
},
]
: []),
]}
];
return (
<ProjectProvider>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
accessControlProvider={accessControlProvider}
resources={resources}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
{props.children}
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</>
</ProjectProvider>
);
};
+10
View File
@@ -66,6 +66,7 @@ const authOptions: NextAuthOptions = {
profile(profile) {
return {
id: profile.sub,
username: profile.preferred_username,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: Avatar.src,
@@ -79,6 +80,12 @@ const authOptions: NextAuthOptions = {
if (profile?.sub) {
token.sub = profile.sub;
}
const preferredUsername = (
profile as { preferred_username?: unknown } | undefined
)?.preferred_username;
if (typeof preferredUsername === "string") {
token.username = preferredUsername;
}
if (account) {
if (account.access_token) {
@@ -104,6 +111,9 @@ const authOptions: NextAuthOptions = {
if (session.user && token.sub) {
session.user.id = token.sub;
}
if (session.user && token.username) {
session.user.username = token.username;
}
if (token.accessToken) {
session.accessToken = token.accessToken;
}
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function RootLayout({
const defaultMode = theme?.value === "dark" ? "dark" : "light";
return (
<html lang="en">
<html lang="zh-CN">
<body>
<Script src="/runtime-config.js" strategy="beforeInteractive" />
<Suspense>
+45
View File
@@ -0,0 +1,45 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import Login from "./page";
const mockLogin = jest.fn();
jest.mock("@refinedev/core", () => ({
useLogin: () => ({ mutate: mockLogin }),
}));
describe("Login", () => {
beforeEach(() => {
jest.useFakeTimers();
mockLogin.mockClear();
});
afterEach(() => {
jest.useRealTimers();
});
it("starts Keycloak login once when the page opens", () => {
const { rerender } = render(<Login />);
expect(mockLogin).toHaveBeenCalledTimes(1);
expect(mockLogin).toHaveBeenCalledWith({});
expect(screen.getByText("正在进入账号登录")).toBeInTheDocument();
rerender(<Login />);
expect(mockLogin).toHaveBeenCalledTimes(1);
});
it("shows a manual fallback when automatic redirect does not complete", () => {
render(<Login />);
expect(
screen.queryByRole("button", { name: "继续登录" }),
).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(4000);
});
fireEvent.click(screen.getByRole("button", { name: "继续登录" }));
expect(mockLogin).toHaveBeenCalledTimes(2);
});
});
+234 -29
View File
@@ -1,54 +1,259 @@
"use client";
import Image from "next/image";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Container from "@mui/material/Container";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useLogin } from "@refinedev/core";
import { Title } from "@components/title";
import { PROJECT_TITLE } from "@config/config";
import { useEffect, useRef, useState } from "react";
const FALLBACK_DELAY_MS = 4000;
export default function Login() {
const { mutate: login } = useLogin();
const loginStartedRef = useRef(false);
const [showFallback, setShowFallback] = useState(false);
useEffect(() => {
if (!loginStartedRef.current) {
loginStartedRef.current = true;
login({});
}
const fallbackTimer = window.setTimeout(
() => setShowFallback(true),
FALLBACK_DELAY_MS,
);
return () => window.clearTimeout(fallbackTimer);
}, [login]);
return (
<Container
style={{
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
<Box
component="main"
sx={{
minHeight: "100vh",
"@supports (height: 100svh)": {
minHeight: "100svh",
},
color: "oklch(0.3 0.055 215)",
bgcolor: "oklch(0.965 0.014 205)",
backgroundImage: `
linear-gradient(
90deg,
transparent 0%,
transparent 50%,
oklch(0.975 0.01 205 / 62%) 68%,
oklch(0.975 0.01 205 / 82%) 100%
),
url("/login-network-blueprint.svg")
`,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
"@keyframes tjwaterEnter": {
from: { opacity: 0, transform: "translateY(12px)" },
to: { opacity: 1, transform: "translateY(0)" },
},
}}
>
<Box
display="flex"
gap="36px"
justifyContent="center"
flexDirection="column"
sx={{
width: "100%",
maxWidth: 1720,
minHeight: "100vh",
"@supports (height: 100svh)": {
minHeight: "100svh",
},
mx: "auto",
px: {
xs: 1.75,
sm: 3,
md: "clamp(40px, 5vw, 88px)",
},
py: {
xs: "max(28px, env(safe-area-inset-top))",
md: "clamp(32px, 4.5vw, 76px)",
},
display: "grid",
gridTemplateColumns: {
xs: "minmax(0, 1fr)",
md: "minmax(360px, 1fr) minmax(400px, 460px)",
},
gridTemplateAreas: {
xs: '"brand" "status"',
md: '"brand status"',
},
alignContent: { xs: "center", md: "stretch" },
alignItems: "center",
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
}}
>
<Box display="flex" justifyContent="center">
<Title collapsed={false} />
</Box>
<Stack
spacing={{ xs: 0, md: 3 }}
direction={{ xs: "row", md: "column" }}
alignItems={{ xs: "center", md: "flex-start" }}
sx={{
position: "relative",
isolation: "isolate",
gridArea: "brand",
width: "fit-content",
maxWidth: "100%",
animation:
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
"&::before": {
position: "absolute",
zIndex: -1,
inset: { xs: "-24px -20px", md: "-54px -72px" },
background: {
xs: `radial-gradient(
ellipse at center,
oklch(0.965 0.014 205 / 98%) 0%,
oklch(0.965 0.014 205 / 88%) 58%,
transparent 84%
)`,
md: `radial-gradient(
ellipse at center,
oklch(0.925 0.018 205 / 98%) 0%,
oklch(0.925 0.018 205 / 94%) 48%,
oklch(0.925 0.018 205 / 62%) 65%,
transparent 82%
)`,
},
pointerEvents: "none",
content: '""',
},
}}
>
<Box
component="img"
src="/login-logo-mark.svg"
alt=""
width={56}
height={56}
sx={{
width: { xs: 42, md: 56 },
height: { xs: 42, md: 56 },
flex: { xs: "0 0 42px", md: "0 0 auto" },
mr: { xs: 1.5, md: 0 },
}}
/>
<Typography
component="h1"
sx={{
maxWidth: 680,
m: 0,
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
fontWeight: 720,
lineHeight: { xs: 1.4, md: 1.28 },
textWrap: "balance",
}}
>
{PROJECT_TITLE}
</Typography>
<Box
aria-hidden="true"
sx={{
display: { xs: "none", md: "block" },
width: 64,
height: 3,
borderRadius: 999,
bgcolor: "oklch(0.58 0.12 180)",
}}
/>
</Stack>
<Box
aria-live="polite"
sx={{
gridArea: "status",
width: "100%",
maxWidth: 460,
justifySelf: "stretch",
p: { xs: 2.75, sm: 4.5 },
overflow: "hidden",
borderRadius: { xs: 4, sm: 4.5 },
bgcolor: "oklch(0.995 0.004 205 / 97%)",
boxShadow:
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
animation:
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
}}
>
<Stack spacing={3}>
<Stack direction="row" spacing={2} alignItems="center">
<CircularProgress
size={30}
thickness={4.5}
aria-label="正在连接统一身份认证"
sx={{
flex: "0 0 auto",
color: "oklch(0.57 0.16 242)",
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
"& .MuiCircularProgress-circle": {
animation: "none",
},
},
}}
/>
<Stack spacing={0.5}>
<Typography
component="h2"
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
>
</Typography>
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 14, sm: 15 },
lineHeight: 1.7,
}}
>
</Typography>
</Stack>
</Stack>
{showFallback ? (
<Button
style={{ width: "240px" }}
fullWidth
size="large"
variant="contained"
onClick={() => login({})}
sx={{
minHeight: 48,
borderRadius: 3,
fontSize: 16,
fontWeight: 700,
textTransform: "none",
boxShadow:
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
transitionProperty:
"transform, background-color, box-shadow",
"&:active": { transform: "scale(0.96)" },
}}
>
Sign in
</Button>
<Typography align="center" color={"text.secondary"} fontSize="12px">
Powered by
<Image
style={{ padding: "0 5px" }}
alt="Keycloak"
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg"
width={18}
height={18}
/>
Keycloak
) : null}
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 12, sm: 13 },
lineHeight: 1.7,
textAlign: "center",
}}
>
访
</Typography>
</Stack>
</Box>
</Box>
</Box>
</Container>
);
}
+35 -27
View File
@@ -106,24 +106,30 @@ type DatabaseHealth = {
const businessRoleOptions = [
{ value: "admin", label: "系统管理员" },
{ value: "operator", label: "运行人员" },
{ value: "user", label: "普通用户" },
{ value: "viewer", label: "只读用户" },
];
const projectRoleLabels: Record<string, string> = {
owner: "项目负责人",
admin: "项目管理员",
member: "项目成员",
viewer: "只读成员",
};
const projectRoleOptions = [
{ value: "admin", label: projectRoleLabels.admin },
{ value: "member", label: projectRoleLabels.member },
{ value: "viewer", label: projectRoleLabels.viewer },
];
const projectRolePermissionSummary = [
{
role: "项目成员",
permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析",
},
{
role: "只读成员",
permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读",
},
];
const projectStatusOptions = [
{ value: "active", label: "启用" },
{ value: "inactive", label: "停用" },
@@ -634,7 +640,7 @@ export const SystemAdminPanel = () => {
const loadInitialState = async () => {
try {
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
projectHeaderMode: "omit",
skipAuthRedirect: true,
});
@@ -941,7 +947,7 @@ export const SystemAdminPanel = () => {
const form = databaseForms[role];
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`,
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health-checks`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1340,7 +1346,7 @@ export const SystemAdminPanel = () => {
<Stack spacing={2}>
<SectionHeader
title="项目成员"
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。"
description="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
action={
<Button
variant="outlined"
@@ -1352,6 +1358,25 @@ export const SystemAdminPanel = () => {
</Button>
}
/>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1 }}>
</Typography>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={1}
useFlexGap
flexWrap="wrap"
>
{projectRolePermissionSummary.map((item) => (
<Chip
key={item.role}
label={`${item.role}${item.permissions}`}
variant="outlined"
/>
))}
</Stack>
</Paper>
{!hasProjectId && (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
@@ -1467,7 +1492,6 @@ export const SystemAdminPanel = () => {
)}
{members.map((member) => {
const isSelf = member.user_id === currentAdmin?.id;
const isOwner = member.project_role === "owner";
return (
<TableRow key={member.id} hover>
@@ -1486,15 +1510,6 @@ export const SystemAdminPanel = () => {
</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
{isOwner ? (
<Tooltip title="项目负责人不在系统管理页维护">
<Chip
size="small"
color="warning"
label={getProjectRoleLabel(member.project_role)}
/>
</Tooltip>
) : (
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
<span>
<FormControl
@@ -1520,20 +1535,13 @@ export const SystemAdminPanel = () => {
</FormControl>
</span>
</Tooltip>
)}
</TableCell>
<TableCell>
<StatusChip active={member.is_active} />
</TableCell>
<TableCell align="right">
<Tooltip
title={
isSelf
? "不能操作当前登录用户"
: isOwner
? "项目负责人不在系统管理页维护"
: ""
}
title={isSelf ? "不能操作当前登录用户" : ""}
>
<span>
<Button
@@ -1542,7 +1550,7 @@ export const SystemAdminPanel = () => {
variant="outlined"
startIcon={<RemoveCircleOutlineIcon />}
onClick={() => removeMember(member)}
disabled={isSelf || isOwner}
disabled={isSelf}
>
</Button>
+20 -43
View File
@@ -49,6 +49,8 @@ import {
} from "@mui/icons-material";
import { config } from "@config/config";
import { apiFetch } from "@/lib/apiFetch";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
type AuditLog = {
id: string;
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
end_time: "",
};
const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
{ value: "all", label: "全部状态" },
{ value: "success", label: "成功 2xx" },
@@ -338,8 +342,14 @@ const DetailSection = ({
);
export const AuditLogPanel = () => {
const [adminChecked, setAdminChecked] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const accessLoading = useAccessStore((state) => state.loading);
const permissions = useAccessStore((state) => state.permissions);
const isSystemAdmin = useAccessStore(
(state) => state.context?.is_system_admin === true,
);
const canViewAudit = permissions.includes(permissionCodes.auditView);
const adminChecked = !accessLoading;
const isAuthorized = adminChecked && canViewAudit && isSystemAdmin;
const [logs, setLogs] = useState<AuditLog[]>([]);
const [users, setUsers] = useState<MetadataUser[]>([]);
const [projects, setProjects] = useState<AdminProject[]>([]);
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
const countParams = buildCountParams(appliedFilters);
const [logsResponse, countResponse] = await Promise.all([
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`),
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`),
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
]);
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
setTotalCount(Number(countPayload.count ?? 0));
} else {
const params = buildServerParams(appliedFilters, 0, 1000);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
if (!response.ok) throw new Error(await readErrorText(response));
const payload = (await response.json()) as AuditLog[];
setLogs(payload);
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
}, [appliedFilters, page, rowsPerPage]);
useEffect(() => {
let cancelled = false;
const checkAdmin = async () => {
try {
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
projectHeaderMode: "omit",
skipAuthRedirect: true,
});
if (cancelled) return;
if (!response.ok) {
setIsAuthorized(false);
setAdminChecked(true);
return;
}
const payload = await response.json();
setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin"));
setAdminChecked(true);
} catch (err) {
if (!cancelled) {
setIsAuthorized(false);
setAdminChecked(true);
setError(String(err));
}
}
};
void checkAdmin();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!adminChecked || !isAuthorized) return;
if (!isAuthorized) return;
void loadOptions();
}, [adminChecked, isAuthorized, loadOptions]);
}, [isAuthorized, loadOptions]);
useEffect(() => {
if (!adminChecked || !isAuthorized) return;
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
setError(null);
try {
const params = buildServerParams(appliedFilters, 0, 1000);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`);
const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`);
if (!response.ok) throw new Error(await readErrorText(response));
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
matchesStatusFilter(log, appliedFilters.status),
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
</Stack>
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
<Chip
color="success"
icon={<AdminPanelSettingsIcon />}
label="管理员权限已验证"
label="系统管理员权限已验证"
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
/>
)}
@@ -0,0 +1,45 @@
"use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
export const RoutePermissionGuard = ({
children,
}: {
children: ReactNode;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const requiredPermission = permissionForPath(pathname);
if (requiredPermission && loading) {
return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
<CircularProgress size={28} />
</Box>
);
}
if (requiredPermission && !permissions.includes(requiredPermission)) {
return (
<Box sx={{ p: { xs: 2, md: 4 } }}>
<Alert severity="error" icon={<LockOutlinedIcon />}>
<Stack spacing={0.5}>
<Typography fontWeight={700}>访</Typography>
<Typography variant="body2">
{requiredPermission}
</Typography>
</Stack>
</Alert>
</Box>
);
}
return children;
};
@@ -0,0 +1,61 @@
/* eslint-disable @next/next/no-img-element */
import React from "react";
import { render, screen } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { AgentComposer } from "./AgentComposer";
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img {...props} alt={props.alt ?? ""} />
),
}));
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
div: ({
children,
animate: _animate,
exit: _exit,
initial: _initial,
transition: _transition,
...props
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
<div {...props}>{children}</div>
),
},
}));
describe("AgentComposer", () => {
it("places voice input immediately before send without an attachment action", () => {
render(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
isListening={false}
isSttSupported
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[{ id: "test-model", label: "测试模型" }]}
selectedModel="test-model"
onModelChange={jest.fn()}
approvalMode="request"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
const voiceButton = screen.getByRole("button", { name: "语音输入" });
const sendButton = screen.getByRole("button", { name: "发送" });
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
});
});
+36 -45
View File
@@ -23,7 +23,6 @@ import StopRounded from "@mui/icons-material/StopRounded";
import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
@@ -125,15 +124,9 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={18}
height={18}
style={{
objectFit: "contain",
flexShrink: 0,
}}
<AutoAwesomeRounded
titleAccess="快捷指令图标"
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
/>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
@@ -230,41 +223,6 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
<AttachFileRounded fontSize="small" />
</IconButton>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
<FormControl size="small" sx={{ minWidth: 96 }}>
<Select
value={approvalMode}
@@ -471,6 +429,39 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
</Select>
</FormControl>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
+2 -2
View File
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
sessionId: "session-1",
messages: [{ id: "message-1", role: "user", content: "查压力" }],
});
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
});
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
isTitleManuallyEdited: true,
});
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
title: "新标题",
+4 -4
View File
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
method: "GET",
projectHeaderMode: "include",
skipAuthRedirect: true,
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{
method: "GET",
projectHeaderMode: "include",
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
isTitleManuallyEdited?: boolean,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{
method: "PATCH",
headers: {
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
const deleteBackendChatSession = async (sessionId: string) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
projectHeaderMode: "include",
@@ -0,0 +1,524 @@
import { Box, Skeleton } from "@mui/material";
import type { MapSkeletonVariant } from "./mapComponentSkeletonConfig";
const skeletonSx = {
transform: "none",
bgcolor: "rgba(37, 125, 212, 0.10)",
"&::after": {
background:
"linear-gradient(90deg, transparent, rgba(37, 125, 212, 0.12), transparent)",
},
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
"&::after": {
animation: "none",
},
},
} as const;
const Line = ({ width = "44%", height = 18 }: { width?: string; height?: number }) => (
<Skeleton
variant="text"
animation="wave"
width={width}
height={height}
sx={skeletonSx}
/>
);
const Field = () => (
<Box sx={{ width: "100%" }}>
<Line width="34%" height={18} />
<Skeleton
variant="rounded"
animation="wave"
height={40}
sx={{ ...skeletonSx, mt: 0.5, borderRadius: 1 }}
/>
</Box>
);
const TwoColumns = ({ children }: { children: React.ReactNode }) => (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 1.5,
}}
>
{children}
</Box>
);
const Action = ({
width = "100%",
height = 40,
testId,
}: {
width?: string;
height?: number;
testId?: string;
}) => (
<Skeleton
data-testid={testId}
variant="rounded"
animation="wave"
width={width}
height={height}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
);
const Notice = ({ testId }: { testId?: string }) => (
<Box
data-testid={testId}
sx={{
display: "grid",
gap: 0.5,
p: 1.5,
bgcolor: "rgba(37, 125, 212, 0.07)",
borderRadius: 1,
}}
>
<Line width="58%" height={18} />
<Line width="88%" height={16} />
</Box>
);
const SelectionHeader = ({ actionWidth = "34%" }: { actionWidth?: string }) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1 }}>
<Line width="44%" height={20} />
</Box>
<Action width={actionWidth} height={32} />
</Box>
);
const EmptySelection = ({ height = 44 }: { height?: number }) => (
<Skeleton
variant="rounded"
animation="wave"
height={height}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
);
const Divider = () => (
<Box sx={{ height: 1, flex: "0 0 auto", bgcolor: "rgba(15, 23, 42, 0.08)" }} />
);
const AdvancedRow = ({ testId }: { testId?: string }) => (
<Box
data-testid={testId}
sx={{
minHeight: 40,
display: "flex",
alignItems: "center",
gap: 1,
px: 1.25,
border: "1px solid",
borderColor: "rgba(15, 23, 42, 0.10)",
borderRadius: 1,
}}
>
<Box sx={{ flex: 1 }}>
<Line width="34%" height={18} />
</Box>
<Skeleton
variant="circular"
animation="wave"
width={24}
height={24}
sx={skeletonSx}
/>
</Box>
);
const SelectionList = ({ rows = 3 }: { rows?: number }) => (
<Box
sx={{
display: "grid",
gap: 1,
p: 1.25,
bgcolor: "rgba(15, 23, 42, 0.025)",
borderRadius: 1,
}}
>
{Array.from({ length: rows }, (_, index) => (
<Box
key={index}
sx={{ display: "flex", alignItems: "center", gap: 1 }}
>
<Skeleton
variant="circular"
animation="wave"
width={26}
height={26}
sx={skeletonSx}
/>
<Box sx={{ flex: 1 }}>
<Line width={`${72 - index * 8}%`} height={16} />
</Box>
<Skeleton
variant="rounded"
animation="wave"
width={48}
height={26}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
</Box>
))}
</Box>
);
const AnalysisFormSkeleton = ({ variant }: { variant: MapSkeletonVariant }) => {
switch (variant) {
case "burst-detection":
return (
<>
<Box
data-testid="burst-detection-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
<Field />
<Field />
<Field />
</Box>
<Notice />
<Line width="88%" height={16} />
<Box sx={{ display: "flex", gap: 1.5, mt: "auto", pt: 1.5 }}>
<Action />
<Action />
</Box>
</>
);
case "burst-location":
return (
<>
<Box
data-testid="burst-location-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
<Field />
<Field />
</Box>
<Notice />
<TwoColumns>
<Field />
<Field />
</TwoColumns>
<Field />
<AdvancedRow />
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action />
</Box>
</>
);
case "burst-simulation":
return (
<>
<Box
data-testid="burst-simulation-selection"
sx={{ display: "grid", gap: 1 }}
>
<SelectionHeader actionWidth="32%" />
</Box>
<Box
data-testid="burst-simulation-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
<Field />
<Field />
<Field />
</Box>
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action />
</Box>
</>
);
case "contaminant-simulation":
return (
<>
<Box
data-testid="contaminant-simulation-selection"
sx={{ display: "grid", gap: 1 }}
>
<SelectionHeader actionWidth="32%" />
<EmptySelection />
</Box>
<Box
data-testid="contaminant-simulation-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
{Array.from({ length: 4 }, (_, index) => (
<Field key={index} />
))}
</Box>
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action />
</Box>
</>
);
case "dma-leak-detection":
return (
<>
<Notice testId="dma-notice-skeleton" />
<Box
data-testid="dma-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 1.5 }}
>
{Array.from({ length: 5 }, (_, index) => (
<Field key={index} />
))}
</Box>
<AdvancedRow testId="dma-advanced-skeleton" />
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action testId="dma-primary-action-skeleton" />
</Box>
</>
);
case "flushing-analysis":
return (
<>
<Box
data-testid="flushing-valve-selection"
sx={{ display: "grid", gap: 1 }}
>
<SelectionHeader actionWidth="32%" />
<EmptySelection height={192} />
</Box>
<Divider />
<Box
data-testid="flushing-node-selection"
sx={{ display: "grid", gap: 1 }}
>
<SelectionHeader actionWidth="32%" />
<EmptySelection height={48} />
</Box>
<Divider />
<Box
data-testid="flushing-primary-fields"
data-layout="vertical-vertical-two-columns"
sx={{ display: "grid", gap: 1.5 }}
>
<Field />
<Field />
<TwoColumns>
<Field />
<Field />
</TwoColumns>
</Box>
<Box sx={{ mt: "auto", pt: 1.5 }}>
<Action />
</Box>
</>
);
case "monitoring-place-optimization":
return (
<>
<Box
data-testid="monitoring-primary-fields"
data-layout="vertical"
sx={{ display: "grid", gap: 2 }}
>
{Array.from({ length: 5 }, (_, index) => (
<Field key={index} />
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 1 }}>
<Action width="132px" />
</Box>
</>
);
default:
return null;
}
};
const DeviceListSkeleton = ({ cleaning }: { cleaning: boolean }) => (
<Box
data-testid={cleaning ? "cleaning-device-content" : "network-device-content"}
sx={{ display: "grid", gap: 2 }}
>
<Skeleton
variant="rounded"
animation="wave"
height={40}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
{Array.from({ length: 3 }, (_, index) => (
<Skeleton
key={index}
variant="rounded"
animation="wave"
height={38}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
))}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ flex: 1 }}>
<Line width="58%" height={16} />
</Box>
{cleaning && (
<Skeleton
data-testid="cleaning-action-skeleton"
variant="circular"
animation="wave"
width={32}
height={32}
sx={skeletonSx}
/>
)}
<Skeleton
variant="circular"
animation="wave"
width={32}
height={32}
sx={skeletonSx}
/>
</Box>
<Box sx={{ height: 1, bgcolor: "rgba(15, 23, 42, 0.08)" }} />
<SelectionList rows={6} />
</Box>
);
const RiskChartSkeleton = () => (
<Box
data-testid="health-risk-chart-content"
sx={{ display: "grid", height: "100%", minHeight: 360, gap: 2 }}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Line width="32%" height={18} />
<Box sx={{ flex: 1 }} />
<Action width="22%" />
</Box>
<Box
sx={{
position: "relative",
display: "flex",
alignItems: "flex-end",
justifyContent: "space-around",
gap: 2,
minHeight: 300,
px: 3,
pt: 3,
pb: 2,
bgcolor: "rgba(15, 23, 42, 0.025)",
borderRadius: 1,
boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.10)",
}}
>
{[36, 58, 44, 76, 62, 84].map((height, index) => (
<Skeleton
key={index}
variant="rounded"
animation="wave"
width="9%"
height={`${height}%`}
sx={{ ...skeletonSx, borderRadius: "6px 6px 2px 2px" }}
/>
))}
</Box>
</Box>
);
export function MapPageSkeletonContent({
variant,
}: {
variant: MapSkeletonVariant;
}) {
if (variant === "network-simulation") {
return <DeviceListSkeleton cleaning={false} />;
}
if (variant === "scada-data-cleaning") {
return <DeviceListSkeleton cleaning />;
}
if (variant === "health-risk-analysis") {
return <RiskChartSkeleton />;
}
return (
<Box
data-testid={`${variant}-content`}
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
height: "100%",
minHeight: 0,
}}
>
<AnalysisFormSkeleton variant={variant} />
</Box>
);
}
export function MapTimelineSkeleton() {
return (
<Box
role="status"
aria-live="polite"
aria-label="正在加载时间轴"
data-component-skeleton="map-timeline"
data-testid="map-timeline-skeleton"
sx={{
position: "absolute",
left: "50%",
bottom: 16,
width: "min(950px, calc(100% - 32px))",
transform: "translateX(-50%)",
display: "grid",
gap: 1.5,
p: 2,
bgcolor: "rgba(255, 255, 255, 0.96)",
borderRadius: 1.5,
boxShadow:
"0 14px 30px rgba(15, 23, 42, 0.16), 0 2px 6px rgba(15, 23, 42, 0.08)",
pointerEvents: "none",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
{Array.from({ length: 5 }, (_, index) => (
<Skeleton
key={index}
variant="circular"
animation="wave"
width={32}
height={32}
sx={skeletonSx}
/>
))}
<Box sx={{ flex: 1 }} />
<Skeleton
variant="rounded"
animation="wave"
width={132}
height={32}
sx={{ ...skeletonSx, borderRadius: 1 }}
/>
</Box>
<Skeleton
variant="rounded"
animation="wave"
height={8}
sx={{ ...skeletonSx, borderRadius: 999 }}
/>
</Box>
);
}
@@ -0,0 +1,149 @@
import { render, screen, within } from "@testing-library/react";
import {
MapPanelSkeleton,
MapTimelineSkeleton,
} from "./MapComponentSkeletons";
import {
MAP_SKELETON_CONFIGS,
MAP_SKELETON_VARIANTS,
type MapSkeletonVariant,
} from "./mapComponentSkeletonConfig";
describe("map component skeletons", () => {
it("keeps the loading state inside the analysis panel geometry", () => {
render(<MapPanelSkeleton variant="burst-simulation" />);
const panel = screen.getByRole("status", {
name: "正在加载爆管分析",
});
expect(panel).toHaveAttribute("data-component-skeleton", "map-panel");
expect(panel).toHaveAttribute("data-panel-side", "right");
expect(panel).toHaveAttribute("data-panel-width", "520");
expect(panel).toHaveStyle({
position: "absolute",
pointerEvents: "none",
});
expect(screen.getByTestId("burst-simulation-content")).toBeInTheDocument();
});
it("keeps an explicit panel configuration for every heavy map component", () => {
expect(Object.keys(MAP_SKELETON_CONFIGS).sort()).toEqual(
[...MAP_SKELETON_VARIANTS].sort(),
);
});
it("keeps known panel chrome out of the skeleton layer", () => {
render(<MapPanelSkeleton variant="burst-simulation" />);
const header = screen.getByTestId("map-panel-header");
const tabs = screen.getByTestId("map-panel-tabs");
expect(header.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument();
expect(tabs.querySelector(".MuiSkeleton-root")).not.toBeInTheDocument();
expect(screen.queryByText("正在加载组件")).not.toBeInTheDocument();
expect(
within(tabs).getByText("分析要件"),
).toBeInTheDocument();
});
it.each(MAP_SKELETON_VARIANTS)(
"renders the component-specific panel for %s",
(variant: MapSkeletonVariant) => {
const config = MAP_SKELETON_CONFIGS[variant];
render(<MapPanelSkeleton variant={variant} />);
const panel = screen.getByRole("status", {
name: `正在加载${config.title}`,
});
expect(panel).toHaveAttribute("data-component-skeleton", "map-panel");
expect(panel).toHaveAttribute("data-panel-side", config.side);
expect(panel).toHaveAttribute(
"data-panel-width",
String(config.panelWidth),
);
expect(screen.getByText(config.panelTitle)).toBeInTheDocument();
expect(screen.queryAllByTestId("map-panel-tab-label")).toHaveLength(
config.tabLabels.length,
);
},
);
it("distinguishes cleaning controls from the simulation device list", () => {
const { rerender } = render(
<MapPanelSkeleton variant="network-simulation" />,
);
expect(screen.getByTestId("network-device-content")).toBeInTheDocument();
rerender(<MapPanelSkeleton variant="scada-data-cleaning" />);
expect(screen.getByTestId("cleaning-device-content")).toBeInTheDocument();
expect(screen.getByTestId("cleaning-action-skeleton")).toBeInTheDocument();
});
it("matches the DMA parameter panel's vertical control distribution", () => {
render(<MapPanelSkeleton variant="dma-leak-detection" />);
const fields = screen.getByTestId("dma-primary-fields");
expect(screen.getByTestId("dma-notice-skeleton")).toBeInTheDocument();
expect(fields).toHaveAttribute("data-layout", "vertical");
expect(fields.children).toHaveLength(5);
expect(screen.getByTestId("dma-advanced-skeleton")).toBeInTheDocument();
expect(screen.getByTestId("dma-primary-action-skeleton")).toHaveStyle({
width: "100%",
});
});
it.each([
["burst-detection", "burst-detection-primary-fields", "vertical", 3],
["burst-location", "burst-location-primary-fields", "vertical", 2],
["burst-simulation", "burst-simulation-primary-fields", "vertical", 3],
[
"contaminant-simulation",
"contaminant-simulation-primary-fields",
"vertical",
4,
],
[
"flushing-analysis",
"flushing-primary-fields",
"vertical-vertical-two-columns",
3,
],
[
"monitoring-place-optimization",
"monitoring-primary-fields",
"vertical",
5,
],
] as const)(
"matches the primary control distribution for %s",
(variant, testId, layout, childCount) => {
render(<MapPanelSkeleton variant={variant} />);
const fields = screen.getByTestId(testId);
expect(fields).toHaveAttribute("data-layout", layout);
expect(fields.children).toHaveLength(childCount);
},
);
it("keeps the two flushing selection regions separate", () => {
render(<MapPanelSkeleton variant="flushing-analysis" />);
expect(screen.getByTestId("flushing-valve-selection")).toBeInTheDocument();
expect(screen.getByTestId("flushing-node-selection")).toBeInTheDocument();
});
it("renders the timeline as an independent loading boundary", () => {
render(<MapTimelineSkeleton />);
expect(
screen.getByRole("status", { name: "正在加载时间轴" }),
).toHaveAttribute("data-component-skeleton", "map-timeline");
});
});
@@ -0,0 +1,160 @@
"use client";
import { Box, CircularProgress, Typography } from "@mui/material";
import { MapPageSkeletonContent } from "./MapComponentSkeletonLayouts";
import {
MAP_SKELETON_CONFIGS,
type MapSkeletonVariant,
} from "./mapComponentSkeletonConfig";
export { MapTimelineSkeleton } from "./MapComponentSkeletonLayouts";
export interface MapPanelSkeletonProps {
variant: MapSkeletonVariant;
}
/**
* 地图业务面板的组件级加载占位。
* 只占据最终面板区域,不替换地图、工具栏或其他已经完成加载的组件。
*/
export function MapPanelSkeleton({ variant }: MapPanelSkeletonProps) {
const config = MAP_SKELETON_CONFIGS[variant];
const horizontalPosition =
config.side === "left"
? { left: { xs: 12, md: 16 }, right: "auto" }
: { right: { xs: 12, md: 16 }, left: "auto" };
return (
<Box
role="status"
aria-live="polite"
aria-label={`正在加载${config.title}`}
data-component-skeleton="map-panel"
data-testid="map-panel-skeleton"
data-panel-side={config.side}
data-panel-width={config.panelWidth}
sx={{
position: "absolute",
top: { xs: 12, md: config.panelTop },
width: {
xs: "calc(100% - 24px)",
sm: `min(${Math.min(config.panelWidth, 640)}px, calc(100% - 32px))`,
md: config.panelWidth,
},
height: {
xs: "calc(100% - 24px)",
md: `min(${config.panelMaxHeight}px, calc(100% - ${config.panelTop + 16}px))`,
},
zIndex: 1300,
display: "flex",
flexDirection: "column",
overflow: "hidden",
bgcolor: "rgba(255, 255, 255, 0.97)",
borderRadius: 1.5,
boxShadow:
"0 20px 34px rgba(15, 23, 42, 0.18), 0 4px 10px rgba(15, 23, 42, 0.08)",
pointerEvents: "none",
...horizontalPosition,
}}
>
<Box
data-testid="map-panel-header"
sx={{
minHeight: 64,
display: "flex",
alignItems: "center",
gap: 1.25,
px: 2.5,
py: 1.5,
bgcolor: "#257DD4",
color: "#fff",
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
component="span"
lang="zh-CN"
sx={{
display: "block",
fontSize: 18,
fontWeight: 600,
lineHeight: 1.4,
}}
>
{config.panelTitle}
</Typography>
</Box>
<CircularProgress
aria-label={`正在加载${config.title}`}
size={20}
thickness={4}
sx={{ color: "rgba(255, 255, 255, 0.90)" }}
/>
</Box>
{config.tabLabels.length > 0 && (
<Box
data-testid="map-panel-tabs"
sx={{
minHeight: 48,
display: "grid",
gridTemplateColumns: `repeat(${config.tabLabels.length}, minmax(0, 1fr))`,
alignItems: "center",
gap: 1,
px: 2,
bgcolor: "#fff",
boxShadow: "inset 0 -1px 0 rgba(15, 23, 42, 0.08)",
}}
>
{config.tabLabels.map((label, index) => (
<Box
key={label}
data-testid="map-panel-tab-label"
sx={{ display: "grid", justifyItems: "center", gap: 0.5 }}
>
<Typography
component="span"
lang="zh-CN"
sx={{
fontSize: 14,
fontWeight: 500,
lineHeight: 1.5,
color: index === 0 ? "#257DD4" : "text.secondary",
}}
>
{label}
</Typography>
{index === 0 && (
<Box
sx={{
width: "78%",
height: 2,
bgcolor: "rgba(37, 125, 212, 0.34)",
borderRadius: 999,
}}
/>
)}
</Box>
))}
</Box>
)}
<Box
sx={{
flex: 1,
minHeight: 0,
overflow: "hidden",
p: variant === "health-risk-analysis" ? 2 : 2.5,
bgcolor:
variant === "network-simulation" ||
variant === "scada-data-cleaning"
? "rgba(248, 250, 252, 0.98)"
: "#fff",
}}
>
<MapPageSkeletonContent variant={variant} />
</Box>
</Box>
);
}
-191
View File
@@ -1,191 +0,0 @@
import { Box, Skeleton, CircularProgress } from "@mui/material";
/**
* 地图页面骨架屏组件
* 提供即时视觉反馈,模拟地图界面布局
*/
export function MapSkeleton() {
return (
<Box
sx={{
width: "100%",
height: "100%",
position: "relative",
bgcolor: "background.default",
overflow: "hidden",
}}
>
{/* 主地图区域骨架 */}
<Skeleton
variant="rectangular"
animation="wave"
sx={{
width: "100%",
height: "100%",
bgcolor: "action.hover",
}}
/>
{/* 中央加载指示器 */}
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 10,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
}}
>
<CircularProgress size={48} thickness={4} color="primary" />
</Box>
{/* 左侧工具栏骨架 (垂直) */}
<Box
sx={{
position: "absolute",
top: 20,
left: 20,
display: "flex",
flexDirection: "column",
gap: 1.5,
zIndex: 5,
}}
>
{[1, 2, 3, 4].map((i) => (
<Skeleton
key={i}
variant="circular"
width={40}
height={40}
animation="wave"
sx={{ boxShadow: 1 }}
/>
))}
</Box>
{/* 右侧控制面板骨架 (抽屉式) */}
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
width: { xs: "100%", sm: 360 },
height: "100%",
bgcolor: "background.paper",
borderLeft: 1,
borderColor: "divider",
p: 3,
zIndex: 5,
display: { xs: "none", md: "flex" },
flexDirection: "column",
boxShadow: -2,
}}
>
<Skeleton variant="text" width="60%" height={40} sx={{ mb: 3 }} />
{/* 面板内容区块 */}
<Box sx={{ flex: 1, overflow: "hidden" }}>
<Skeleton variant="rectangular" width="100%" height={100} sx={{ mb: 2, borderRadius: 1 }} />
<Skeleton variant="text" width="40%" height={24} sx={{ mb: 1 }} />
<Skeleton variant="rectangular" width="100%" height={180} sx={{ mb: 2, borderRadius: 1 }} />
<Box sx={{ mt: 2 }}>
{[1, 2, 3].map((i) => (
<Box key={i} sx={{ display: "flex", gap: 2, mb: 2 }}>
<Skeleton variant="circular" width={36} height={36} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="80%" />
<Skeleton variant="text" width="50%" />
</Box>
</Box>
))}
</Box>
</Box>
</Box>
{/* 底部时间轴/控制条骨架 */}
<Box
sx={{
position: "absolute",
bottom: 30,
left: "50%",
transform: "translateX(-50%)",
width: { xs: "90%", md: "60%" },
height: 64,
bgcolor: "background.paper",
borderRadius: 4,
boxShadow: 3,
p: 2,
display: "flex",
alignItems: "center",
gap: 2,
zIndex: 5,
}}
>
<Skeleton variant="circular" width={32} height={32} />
<Skeleton variant="rectangular" width="100%" height={8} sx={{ borderRadius: 4 }} />
<Skeleton variant="text" width={40} />
</Box>
{/* 缩放控制骨架 (右下) */}
<Box
sx={{
position: "absolute",
bottom: 110,
right: { xs: 20, md: 380 }, // Adjust if drawer is open
display: "flex",
flexDirection: "column",
gap: 1,
zIndex: 4,
}}
>
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
<Skeleton variant="rectangular" width={36} height={36} sx={{ borderRadius: 1 }} />
</Box>
</Box>
);
}
/**
* 简化版骨架屏 - 用于非地图页面
*/
export function SimpleSkeleton() {
return (
<Box
sx={{
width: "100%",
height: "100%",
p: 3,
bgcolor: "background.default",
}}
>
<Skeleton width="40%" height={40} animation="wave" sx={{ mb: 3 }} />
<Skeleton width="100%" height={60} animation="wave" sx={{ mb: 2 }} />
<Skeleton width="100%" height={300} animation="wave" sx={{ mb: 2 }} />
<Box sx={{ display: "flex", gap: 2, mb: 2 }}>
<Skeleton
variant="rectangular"
width="30%"
height={150}
animation="wave"
/>
<Skeleton
variant="rectangular"
width="30%"
height={150}
animation="wave"
/>
<Skeleton
variant="rectangular"
width="30%"
height={150}
animation="wave"
/>
</Box>
</Box>
);
}
@@ -0,0 +1,120 @@
export const MAP_SKELETON_VARIANTS = [
"network-simulation",
"scada-data-cleaning",
"health-risk-analysis",
"monitoring-place-optimization",
"burst-detection",
"burst-location",
"burst-simulation",
"contaminant-simulation",
"dma-leak-detection",
"flushing-analysis",
] as const;
export type MapSkeletonVariant = (typeof MAP_SKELETON_VARIANTS)[number];
export interface MapSkeletonConfig {
title: string;
panelTitle: string;
side: "left" | "right";
panelWidth: number;
panelTop: number;
panelMaxHeight: number;
tabLabels: readonly string[];
}
export const MAP_SKELETON_CONFIGS: Record<
MapSkeletonVariant,
MapSkeletonConfig
> = {
"network-simulation": {
title: "管网模拟",
panelTitle: "SCADA 设备列表",
side: "left",
panelWidth: 360,
panelTop: 80,
panelMaxHeight: 860,
tabLabels: [],
},
"scada-data-cleaning": {
title: "数据清洗",
panelTitle: "SCADA 设备列表",
side: "left",
panelWidth: 360,
panelTop: 80,
panelMaxHeight: 860,
tabLabels: [],
},
"health-risk-analysis": {
title: "健康风险分析",
panelTitle: "管道健康风险统计",
side: "right",
panelWidth: 640,
panelTop: 16,
panelMaxHeight: 614,
tabLabels: [],
},
"monitoring-place-optimization": {
title: "监测点优化",
panelTitle: "监测点优化",
side: "right",
panelWidth: 520,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["优化要件", "结果编辑", "方案查询"],
},
"burst-detection": {
title: "爆管侦测",
panelTitle: "爆管侦测",
side: "right",
panelWidth: 450,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["侦测参数", "方案查询", "侦测结果"],
},
"burst-location": {
title: "爆管定位",
panelTitle: "爆管定位",
side: "right",
panelWidth: 450,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["定位参数", "方案查询", "定位结果"],
},
"burst-simulation": {
title: "爆管分析",
panelTitle: "爆管分析",
side: "right",
panelWidth: 520,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["分析要件", "方案查询", "分析报告", "关阀分析"],
},
"contaminant-simulation": {
title: "水质模拟",
panelTitle: "水质模拟",
side: "right",
panelWidth: 520,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["分析要件", "方案查询", "模拟结果"],
},
"dma-leak-detection": {
title: "DMA 漏损识别",
panelTitle: "DMA 漏损识别",
side: "right",
panelWidth: 450,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["识别参数", "方案查询", "识别结果"],
},
"flushing-analysis": {
title: "管道冲洗分析",
panelTitle: "管道冲洗分析",
side: "right",
panelWidth: 450,
panelTop: 16,
panelMaxHeight: 850,
tabLabels: ["分析参数", "方案查询"],
},
};
@@ -11,13 +11,13 @@ describe("burst detection request", () => {
const state = createBurstDetectionAnalysisParametersState();
state.schemeName = " latest-case ";
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({
network: "fengyang",
expect(buildBurstDetectionRequest(state)).toEqual({
scheme_name: "latest-case",
sampling_interval_minutes: 15,
});
expect(state.detectionMode).toBe("latest");
expect(state.targetTime?.minute() % 15).toBe(0);
expect(state.targetTime).not.toBeNull();
expect(state.targetTime!.minute() % 15).toBe(0);
});
it("sends one target time for historical replay", () => {
@@ -32,10 +32,8 @@ describe("burst detection request", () => {
samplingIntervalMinutes: 30,
samplingIntervalSource: "manual",
},
"fengyang",
),
).toEqual({
network: "fengyang",
scheme_name: "history-case",
sampling_interval_minutes: 30,
target_time: targetTime.toISOString(),
@@ -92,9 +92,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState,
network: string,
) => ({
network,
scheme_name: parameters.schemeName.trim(),
sampling_interval_minutes: parameters.samplingIntervalMinutes,
...(parameters.detectionMode === "historical" && parameters.targetTime
@@ -129,7 +127,7 @@ const AnalysisParameters: React.FC<Props> = ({
let active = true;
setFrequencyLoading(true);
api
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
.get("/api/v1/scada-info")
.then((response) => {
if (!active) return;
const interval = resolvePressureSamplingInterval(
@@ -186,8 +184,8 @@ const AnalysisParameters: React.FC<Props> = ({
try {
const response = await api.post(
"/api/v1/burst-detection/detect/",
buildBurstDetectionRequest(parametersState, NETWORK_NAME),
"/api/v1/burst-detections",
buildBurstDetectionRequest(parametersState),
);
onResult(response.data as BurstDetectionResult);
open?.({
@@ -23,6 +23,7 @@ import { bbox, featureCollection } from "@turf/turf";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { BurstDetectionResult, BurstDetectionRow } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface BurstDetectionResultsState {
selectedDay: number | null;
@@ -73,17 +74,11 @@ const formatDateTime = (value?: string) =>
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
<ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
</Box>
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
</Typography>
<Typography variant="body2" className="max-w-xs text-gray-500">
14
</Typography>
</Box>
<PanelEmptyState
icon={<ShowChartIcon />}
title="尚未生成侦测结果"
description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
);
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
@@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
import {
BurstDetectionResult,
BurstDetectionSchemeDetail,
@@ -42,6 +43,7 @@ export interface BurstDetectionSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
hasQueried: boolean;
}
export const createBurstDetectionSchemeQueryState =
@@ -49,6 +51,7 @@ export const createBurstDetectionSchemeQueryState =
queryAll: true,
queryDate: dayjs(),
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<Props> = ({
@@ -64,7 +67,7 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange,
createBurstDetectionSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
@@ -127,14 +130,17 @@ const SchemeQuery: React.FC<Props> = ({
const handleQuery = async () => {
setLoading(true);
try {
const params: Record<string, string> = { network: NETWORK_NAME };
const params: Record<string, string> = {
scheme_type: "burst_detection",
};
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get("/api/v1/burst-detection/schemes/", { params });
const response = await api.get("/api/v1/schemes", { params });
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
open?.({
type: "success",
message: "查询成功",
@@ -154,8 +160,8 @@ const SchemeQuery: React.FC<Props> = ({
const handleViewSchemeResult = async (schemeName: string) => {
try {
const response = await api.get(
`/api/v1/burst-detection/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME } },
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ params: { scheme_type: "burst_detection" } },
);
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
result_payload?: BurstDetectionResult;
@@ -200,7 +206,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox
size="small"
checked={queryAll}
onChange={(event) => setQueryField("queryAll", event.target.checked)}
onChange={(event) => {
setQueryField("queryAll", event.target.checked);
setQueryField("hasQueried", false);
}}
/>
}
label={<Typography variant="body2"></Typography>}
@@ -209,7 +218,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={(value) => setQueryField("queryDate", value)}
onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
@@ -231,12 +243,7 @@ const SchemeQuery: React.FC<Props> = ({
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400">
<Typography variant="body2"></Typography>
<Typography variant="caption" className="mt-1">
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="px-2 text-gray-500">
@@ -28,6 +28,7 @@ import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import { BurstLocationResult } from "./types";
import { getBurstLocationErrorNotice } from "./burstLocationError";
interface Props {
onResult: (result: BurstLocationResult) => void;
@@ -125,7 +126,7 @@ const AnalysisParameters: React.FC<Props> = ({
setSchemeLoading(true);
try {
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME },
params: { scheme_type: "burst_analysis" },
});
const burstSchemes = (response.data as SchemeItem[]).filter(
(scheme) => scheme.scheme_type === "burst_analysis",
@@ -222,9 +223,8 @@ const AnalysisParameters: React.FC<Props> = ({
: undefined;
const response = await api.post(
`${config.BACKEND_URL}/api/v1/burst-location/locate/`,
`${config.BACKEND_URL}/api/v1/burst-locations`,
{
network: NETWORK_NAME,
data_source: dataSource,
scheme_name: schemeName.trim() || undefined,
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
@@ -263,11 +263,12 @@ const AnalysisParameters: React.FC<Props> = ({
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
});
} catch (error: any) {
const notice = getBurstLocationErrorNotice(error);
open?.({
key: "burst-location-analysis-error",
type: "error",
message: "提交分析失败",
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
message: notice.message,
description: notice.description,
});
} finally {
setRunning(false);
@@ -304,22 +305,30 @@ const AnalysisParameters: React.FC<Props> = ({
</Select>
</FormControl>
<Alert
severity="info"
severity={isSimulationMode ? "warning" : "info"}
variant="outlined"
sx={{
mt: 1,
alignItems: "flex-start",
borderColor: "info.light",
backgroundColor: "#eff6ff",
borderColor: isSimulationMode ? "warning.light" : "info.light",
backgroundColor: isSimulationMode ? "#fff8e1" : "#eff6ff",
"& .MuiAlert-message": { width: "100%" },
}}
>
<Typography variant="caption" className="block font-semibold text-blue-900">
<Typography
variant="caption"
className="block font-semibold"
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
>
{isSimulationMode ? "模拟方案分析前置条件" : "数据选择规则"}
</Typography>
<Typography variant="caption" className="block leading-5 text-blue-800">
<Typography
variant="caption"
className="block leading-5"
sx={{ color: isSimulationMode ? "warning.dark" : "info.dark" }}
>
{isSimulationMode
? "当前为模拟方案:爆管数据取所选方案模拟结果,正常数据取实时模拟结果,两者使用同一爆管时间窗。"
? "爆管数据取所选方案,正常数据取同一时间窗的实时模拟结果。所选时间窗必须已有实时模拟正常基线,否则无法分析;系统不会自动补数据。"
: "当前为监测数据:爆管数据取所选监测时间窗,正常数据默认取前一天同一时段的监测数据。"}
</Typography>
</Alert>
@@ -31,6 +31,7 @@ import { Stroke, Style, Circle, Fill } from "ol/style";
import { bbox, featureCollection } from "@turf/turf";
import { BurstCandidate, BurstLocationResult } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface Props {
result: BurstLocationResult | null;
@@ -128,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
};
const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
</Box>
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
</Typography>
<Typography variant="body2" className="max-w-xs text-gray-500">
</Typography>
</Box>
<PanelEmptyState
icon={<MapIcon />}
title="尚未生成定位结果"
description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
);
const LocationResults: React.FC<Props> = ({ result }) => {
@@ -42,6 +42,8 @@ import {
BurstSchemeRecord,
} from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props {
onViewResult: (result: BurstLocationResult) => void;
@@ -56,6 +58,7 @@ export interface BurstLocationSchemeQueryState {
queryDate: Dayjs | null;
expandedId: number | null;
simulationBurstIdsByName: Record<string, string[]>;
hasQueried: boolean;
}
export const createBurstLocationSchemeQueryState =
@@ -64,6 +67,7 @@ export const createBurstLocationSchemeQueryState =
queryDate: dayjs(),
expandedId: null,
simulationBurstIdsByName: {},
hasQueried: false,
});
const SchemeQuery: React.FC<Props> = ({
@@ -82,10 +86,11 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange,
createBurstLocationSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const creatorName = useSchemeCreatorName();
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
@@ -222,18 +227,17 @@ const SchemeQuery: React.FC<Props> = ({
const handleQuery = async () => {
setLoading(true);
try {
// API call to fetch schemes
// Adjust URL as needed
let url = `${config.BACKEND_URL}/api/v1/burst-location/schemes/`;
const params: Record<string, string> = { network: NETWORK_NAME };
const params: Record<string, string> = {
scheme_type: "burst_location",
};
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const [response, simulationResponse] = await Promise.all([
api.get(url, { params }),
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params: { network: NETWORK_NAME },
params: { scheme_type: "burst_analysis" },
}),
]);
const nextSchemes = response.data as BurstSchemeRecord[];
@@ -254,6 +258,7 @@ const SchemeQuery: React.FC<Props> = ({
),
),
);
setQueryField("hasQueried", true);
open?.({
type: "success",
message: "查询成功",
@@ -274,8 +279,8 @@ const SchemeQuery: React.FC<Props> = ({
const handleViewSchemeResult = async (schemeName: string) => {
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/burst-location/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME } },
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{ params: { scheme_type: "burst_location" } },
);
const schemeRecord = response.data as BurstSchemeRecord & {
result_payload?: BurstLocationResult;
@@ -318,7 +323,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox
size="small"
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
/>
}
label={<Typography variant="body2"></Typography>}
@@ -327,7 +335,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={(value) => setQueryField("queryDate", value)}
onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
@@ -348,39 +359,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box>
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -526,7 +505,7 @@ const SchemeQuery: React.FC<Props> = ({
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.username || "-"}
{creatorName(scheme.username)}
</Typography>
</Box>
</Box>
@@ -0,0 +1,45 @@
import { getBurstLocationErrorNotice } from "./burstLocationError";
describe("getBurstLocationErrorNotice", () => {
it("explains a missing realtime normal-pressure baseline", () => {
const notice = getBurstLocationErrorNotice({
response: {
data: {
detail: "正常压力数据 在时间窗内无有效模拟数据: 10186",
},
},
});
expect(notice.message).toBe("缺少正常模拟基线");
expect(notice.description).toContain("实时模拟正常压力数据");
expect(notice.description).toContain("10186");
expect(notice.description).toContain("系统不会自动补数据");
});
it("distinguishes incomplete burst-scheme data", () => {
const notice = getBurstLocationErrorNotice({
response: {
data: {
detail: "爆管压力数据 在时间窗内无有效模拟数据: 10186",
},
},
});
expect(notice).toEqual({
message: "所选方案数据不完整",
description:
"所选方案时间窗内没有可用的爆管压力数据,缺失点位:10186。请选择数据完整的模拟方案。",
});
});
it("preserves unrelated backend details", () => {
expect(
getBurstLocationErrorNotice({
response: { data: { detail: "分析参数不合法" } },
}),
).toEqual({
message: "提交分析失败",
description: "分析参数不合法",
});
});
});
@@ -0,0 +1,50 @@
export interface BurstLocationErrorNotice {
message: string;
description: string;
}
const DATA_GAP_PATTERN =
/^(爆管压力数据|正常压力数据|爆管流量数据|正常流量数据) 在时间窗内无有效模拟数据: (.+)$/;
const extractErrorDetail = (error: unknown): string => {
const candidate = error as {
message?: string;
response?: { data?: { detail?: unknown } };
};
const detail = candidate?.response?.data?.detail;
if (typeof detail === "string" && detail.trim()) {
return detail.trim();
}
if (typeof candidate?.message === "string" && candidate.message.trim()) {
return candidate.message.trim();
}
return "请求失败";
};
export const getBurstLocationErrorNotice = (
error: unknown,
): BurstLocationErrorNotice => {
const detail = extractErrorDetail(error);
const match = detail.match(DATA_GAP_PATTERN);
if (!match) {
return {
message: "提交分析失败",
description: detail,
};
}
const [, dataLabel, missingIds] = match;
if (dataLabel.startsWith("正常")) {
return {
message: "缺少正常模拟基线",
description: `所选方案时间窗内没有可用的实时模拟${dataLabel},缺失点位:${missingIds}。请选择已有正常基线覆盖的方案或时间窗,系统不会自动补数据。`,
};
}
return {
message: "所选方案数据不完整",
description: `所选方案时间窗内没有可用的${dataLabel},缺失点位:${missingIds}。请选择数据完整的模拟方案。`,
};
};
@@ -322,16 +322,15 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
: "";
const modify_total_duration = duration;
const params = {
network: network,
modify_pattern_start_time: modify_pattern_start_time,
burst_ID: burst_ID,
burst_id: burst_ID,
burst_size: burst_size,
modify_total_duration: modify_total_duration,
scheme_name: schemeName,
};
try {
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, {
await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
params,
paramsSerializer: {
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
@@ -0,0 +1,278 @@
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,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
jest.mock("@/utils/mapQueryService", () => ({
queryFeaturesByIds: jest.fn(),
}));
const scheme: SchemeRecord = {
id: 17,
schemeName: "burst-report-demo",
type: "burst_analysis",
username: "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("guides the user to choose a scheme when no report is active", () => {
render(
<AnalysisReport
scheme={null}
valveResult={null}
disabledValves={[]}
generatedAt={null}
/>,
);
expect(screen.getByText("尚未选择分析方案")).toBeInTheDocument();
expect(
screen.getByText(
"请在“方案查询”中打开一个方案,再查看分析报告。",
),
).toBeInTheDocument();
});
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("shows only the affected count for a non-isolatable result", async () => {
const legacyAffectedNodes = Array.from(
{ length: 100 },
(_, index) => `legacy-node-${index}`,
);
const nonIsolatableResult = {
...valveResult,
affected_nodes: legacyAffectedNodes,
affected_node_count: 85747,
must_close_valves: [],
isolatable: false,
};
render(
<AnalysisReport
scheme={scheme}
valveResult={nonIsolatableResult}
disabledValves={[]}
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
/>,
);
const preview = within(
screen.getByTestId("burst-analysis-report-preview"),
);
expect(preview.getByText("85747 个")).toBeInTheDocument();
expect(
preview.getByText("不可隔离,未生成受影响节点清单。"),
).toBeInTheDocument();
expect(preview.queryByText("legacy-node-0")).not.toBeInTheDocument();
expect(preview.queryByText("legacy-node-99")).not.toBeInTheDocument();
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("normalizes legacy valve results without changing isolatable lists", () => {
expect(
normalizeValveIsolationResult({
...valveResult,
affected_nodes: ["J-1", "J-2", "J-3"],
must_close_valves: [],
isolatable: false,
}),
).toMatchObject({
affected_nodes: [],
affected_node_count: 3,
isolatable: false,
});
expect(normalizeValveIsolationResult(valveResult)).toMatchObject({
affected_nodes: ["J-1", "J-2"],
affected_node_count: 2,
isolatable: 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,545 @@
"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 {
clearPipeDiameterCache,
getCachedPipeDiameters,
getPipeDiameterDisplay,
getPipeDiameterQueryKey,
loadPipeDiameters,
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types";
import { getAffectedNodeCount } from "./valveIsolationScope";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
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";
export const clearAnalysisReportDiameterCache = clearPipeDiameterCache;
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;
const valveDetailRows: Array<[string, string[] | undefined]> =
matchedValveResult
? [
["已分析事故管段", matchedValveResult.accident_elements],
["必关阀门", matchedValveResult.must_close_valves],
["可选阀门", matchedValveResult.optional_valves],
["不可用阀门", disabledValves],
]
: [];
if (matchedValveResult?.isolatable) {
valveDetailRows.push(["受影响节点", matchedValveResult.affected_nodes]);
}
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.username || "未记录"],
["方案创建时间", 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}`],
["受影响节点", `${getAffectedNodeCount(matchedValveResult)}`],
].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>
{valveDetailRows.map(([label, values]) => (
<Box key={label} sx={{ breakInside: "avoid" }}>
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
{label}
</Typography>
<IdList values={values} />
</Box>
))}
{!matchedValveResult.isolatable && (
<Alert severity="info" variant="outlined">
</Alert>
)}
</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 = getPipeDiameterQueryKey(pipeIds);
const cachedDiameters = getCachedPipeDiameters(pipeIds);
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 (getCachedPipeDiameters(pipeIds)) {
return;
}
let cancelled = false;
loadPipeDiameters(pipeIds)
.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 (
<PanelEmptyState
icon={<DescriptionOutlined />}
title="尚未选择分析方案"
description="请在“方案查询”中打开一个方案,再查看分析报告。"
/>
);
}
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",
username: "operator",
create_time: "2026-07-30T08:00:00+08:00",
startTime: "2026-07-30T09:00:00+08:00",
schemeDetail: {
burst_ID: ["P-1", "P-2"],
burst_size: [120, 80],
},
});
return (
<>
<button onClick={() => onViewReport(createScheme(1, "selected-scheme"))}>
mock report action
</button>
<button onClick={() => onViewReport(createScheme(2, "other-scheme"))}>
mock other report action
</button>
</>
);
},
createBurstSchemeQueryState: () => ({}),
}));
jest.mock("./AnalysisReport", () => ({
__esModule: true,
default: ({
scheme,
valveResult,
}: {
scheme: { schemeName: string } | null;
valveResult: { accident_elements: string[] } | null;
}) => (
<div>
report:{scheme?.schemeName ?? "empty"}:valve:
{valveResult?.accident_elements.join(",") ?? "none"}
</div>
),
matchesValveAnalysis: (
scheme: { schemeDetail: { burst_ID: string[] } },
valveResult: { accident_elements: string[] } | null,
) =>
valveResult !== null &&
valveResult.accident_elements.every((pipeId) =>
scheme.schemeDetail.burst_ID.includes(pipeId),
),
}));
jest.mock("./ValveIsolation", () => ({
__esModule: true,
default: ({
allowedPipeIds,
sourceSchemeName,
onResultChange,
}: {
allowedPipeIds?: string[];
sourceSchemeName?: string;
onResultChange: (result: Record<string, unknown>) => void;
}) => (
<>
<div data-testid="valve-scope">
valve scope:{sourceSchemeName ?? "independent"}:
{allowedPipeIds?.join(",") ?? "any"}
</div>
<button
onClick={() =>
onResultChange({
accident_elements: ["P-1"],
affected_nodes: ["J-1"],
must_close_valves: ["V-1"],
optional_valves: [],
isolatable: true,
})
}
>
mock valve result
</button>
</>
),
createValveIsolationState: () => ({
selectedPipeId: null,
activeStep: 0,
expandedResult: true,
disabledValves: [],
}),
}));
describe("BurstPipeAnalysisPanel", () => {
it("replaces the obsolete location tab and opens the selected report", () => {
render(<BurstPipeAnalysisPanel />);
expect(
screen.queryByRole("tab", { name: /定位结果/ }),
).not.toBeInTheDocument();
expect(
screen.getByRole("tab", { name: /分析报告/ }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock report action" }),
);
expect(
screen.getByText("report:selected-scheme:valve:none"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
expect(screen.getByTestId("valve-scope")).toHaveTextContent(
"valve scope:selected-scheme:P-1,P-2",
);
});
it("does not reuse valve results across schemes with the same pipe", () => {
render(<BurstPipeAnalysisPanel />);
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock report action" }),
);
fireEvent.click(screen.getByRole("tab", { name: /关阀分析/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock valve result" }),
);
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
fireEvent.click(
screen.getByRole("button", { name: "mock other report action" }),
);
expect(
screen.getByText("report:other-scheme:valve:none"),
).toBeInTheDocument();
});
});
@@ -15,7 +15,7 @@ import {
ChevronLeft,
Analytics as AnalyticsIcon,
Search as SearchIcon,
MyLocation as MyLocationIcon,
DescriptionOutlined as ReportIcon,
Handyman as HandymanIcon,
} from "@mui/icons-material";
import AnalysisParameters, {
@@ -26,15 +26,12 @@ import SchemeQuery, {
createBurstSchemeQueryState,
type BurstSchemeQueryState,
} from "./SchemeQuery";
import LocationResults from "./LocationResults";
import AnalysisReport, { matchesValveAnalysis } from "./AnalysisReport";
import ValveIsolation, {
createValveIsolationState,
type ValveIsolationState,
} from "./ValveIsolation";
import { api } from "@/lib/api";
import { config } from "@config/config";
import { useNotification } from "@refinedev/core";
import { LocationResult, SchemeRecord, ValveIsolationResult } from "./types";
import { SchemeRecord, ValveIsolationResult } from "./types";
interface TabPanelProps {
children?: React.ReactNode;
@@ -75,16 +72,16 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
// 持久化方案查询结果
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
// 定位结果数据
const [locationResults, setLocationResults] = useState<LocationResult[]>([]);
const [reportScheme, setReportScheme] = useState<SchemeRecord | null>(null);
const [reportGeneratedAt, setReportGeneratedAt] = useState<Date | null>(null);
// 关阀分析结果和加载状态
const [valveAnalysisLoading, setValveAnalysisLoading] = useState(false);
const [valveAnalysisResult, setValveAnalysisResult] = useState<ValveIsolationResult | null>(null);
const [valveAnalysisSchemeName, setValveAnalysisSchemeName] =
useState<string | null>(null);
const [valveIsolationState, setValveIsolationState] =
useState<ValveIsolationState>(createValveIsolationState);
const { open } = useNotification();
// 使用受控或非受控状态
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const handleToggle = () => {
@@ -99,21 +96,25 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
setCurrentTab(newValue);
};
const handleLocateScheme = async (scheme: SchemeRecord) => {
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/burst-locate-result/${scheme.schemeName}`,
);
setLocationResults(response.data);
setCurrentTab(2); // 切换到定位结果标签页
} catch (error) {
console.error("获取定位结果失败:", error);
open?.({
type: "error",
message: "获取定位结果失败",
description: "无法从服务器获取该方案的定位结果",
});
const handleViewReport = (scheme: SchemeRecord) => {
if (
valveAnalysisSchemeName !== scheme.schemeName ||
!matchesValveAnalysis(scheme, valveAnalysisResult)
) {
setValveAnalysisResult(null);
setValveAnalysisSchemeName(null);
setValveIsolationState(createValveIsolationState());
}
setReportScheme(scheme);
setReportGeneratedAt(new Date());
setCurrentTab(2);
};
const handleValveAnalysisResultChange = (
result: ValveIsolationResult | null,
) => {
setValveAnalysisResult(result);
setValveAnalysisSchemeName(result ? reportScheme?.schemeName ?? null : null);
};
const drawerWidth = 520;
@@ -226,9 +227,9 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
label="方案查询"
/>
<Tab
icon={<MyLocationIcon fontSize="small" />}
icon={<ReportIcon fontSize="small" />}
iconPosition="start"
label="定位结果"
label="分析报告"
/>
<Tab
icon={<HandymanIcon fontSize="small" />}
@@ -250,15 +251,18 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
<SchemeQuery
schemes={schemes}
onSchemesChange={setSchemes}
onLocate={handleLocateScheme}
onViewReport={handleViewReport}
state={queryState}
onStateChange={setQueryState}
/>
</TabPanel>
<TabPanel value={currentTab} index={2}>
<LocationResults
results={locationResults}
<AnalysisReport
scheme={reportScheme}
valveResult={valveAnalysisResult}
disabledValves={valveIsolationState.disabledValves}
generatedAt={reportGeneratedAt}
/>
</TabPanel>
@@ -267,9 +271,13 @@ const BurstPipeAnalysisPanel: React.FC<BurstPipeAnalysisPanelProps> = ({
loading={valveAnalysisLoading}
result={valveAnalysisResult}
onLoadingChange={setValveAnalysisLoading}
onResultChange={setValveAnalysisResult}
onResultChange={handleValveAnalysisResultChange}
state={valveIsolationState}
onStateChange={setValveIsolationState}
allowedPipeIds={
reportScheme?.schemeDetail?.burst_ID
}
sourceSchemeName={reportScheme?.schemeName}
/>
</TabPanel>
</Box>
@@ -1,416 +0,0 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Box,
Typography,
Chip,
IconButton,
Tooltip,
Link,
} from "@mui/material";
import {
LocationOn as LocationIcon,
} from "@mui/icons-material";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { useMap } from "@components/olmap/core/MapComponent";
import { GeoJSON } from "ol/format";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Stroke, Style, Icon } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature";
import {
along,
lineString,
length,
toMercator,
bbox,
featureCollection,
} from "@turf/turf";
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import moment from "moment";
import "moment-timezone";
import { LocationResult } from "./types";
import { FLOW_DISPLAY_UNIT } from "@utils/units";
interface LocationResultsProps {
results?: LocationResult[];
}
const LocationResults: React.FC<LocationResultsProps> = ({
results = [],
}) => {
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const map = useMap();
// 格式化时间为 UTC+8
const formatTime = (timeStr: string) => {
return moment(timeStr).utcOffset(8).format("YYYY-MM-DD HH:mm:ss");
};
const handleLocatePipes = (pipeIds: string[]) => {
if (pipeIds.length > 0) {
queryFeaturesByIds(pipeIds, "geo_pipes_mat").then((features) => {
if (features.length > 0) {
// 设置高亮要素
setHighlightFeatures(features);
// 将 OpenLayers Feature 转换为 GeoJSON Feature
const geojsonFormat = new GeoJSON();
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, { maxZoom: 18, duration: 1000 });
}
}
});
}
};
// 初始化管道图层和高亮图层
useEffect(() => {
if (!map) return;
const burstPipeStyle = function (feature: FeatureLike) {
const styles = [];
// 线条样式(底层发光,主线条,内层高亮线)
styles.push(
new Style({
stroke: new Stroke({
color: "rgba(255, 0, 0, 0.3)",
width: 12,
}),
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 0, 0, 1)",
width: 6,
lineDash: [15, 10],
}),
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 102, 102, 1)",
width: 3,
lineDash: [15, 10],
}),
}),
);
const geometry = feature.getGeometry();
const lineCoords =
geometry?.getType() === "LineString"
? (geometry as any).getCoordinates()
: null;
if (geometry && lineCoords) {
const lineCoordsWGS84 = lineCoords.map((coord: []) => {
const [lon, lat] = toLonLat(coord);
return [lon, lat];
});
// 计算中点
const lineStringFeature = lineString(lineCoordsWGS84);
const lineLength = length(lineStringFeature);
const midPoint = along(lineStringFeature, lineLength / 2).geometry
.coordinates;
// 在中点添加 icon 样式
const midPointMercator = toMercator(midPoint);
styles.push(
new Style({
geometry: new Point(midPointMercator),
image: new Icon({
src: "/icons/burst_pipe.svg",
scale: 0.2,
anchor: [0.5, 1],
}),
}),
);
}
return styles;
};
// 创建高亮图层
const highlightLayer = new VectorLayer({
source: new VectorSource(),
style: burstPipeStyle,
maxZoom: 24,
minZoom: 12,
properties: {
name: "爆管管段高亮",
value: "burst_pipe_highlight",
queryable: false,
},
});
map.addLayer(highlightLayer);
highlightLayerRef.current = highlightLayer;
return () => {
highlightLayerRef.current = null;
map.removeLayer(highlightLayer);
};
}, [map]);
// 高亮要素的函数
useEffect(() => {
const source = highlightLayerRef.current?.getSource();
if (!source) {
return;
}
// 清除之前的高亮
source.clear();
// 添加新的高亮要素
highlightFeatures.forEach((feature) => {
if (feature instanceof Feature) {
source.addFeature(feature);
}
});
}, [highlightFeatures]);
// 取第一条记录或空对象
const result = results.length > 0 ? results[0] : null;
return (
<Box className="flex flex-col h-full">
{/* 结果展示 */}
<Box className="flex-1 overflow-auto bg-white rounded border border-gray-200">
{!result ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<circle
cx="40"
cy="40"
r="25"
stroke="currentColor"
strokeWidth="2"
/>
<circle cx="40" cy="40" r="5" fill="currentColor" />
<line
x1="40"
y1="15"
x2="40"
y2="25"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="40"
y1="55"
x2="40"
y2="65"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="15"
y1="40"
x2="25"
y2="40"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="55"
y1="40"
x2="65"
y2="40"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"></Typography>
<Typography variant="body2" className="mt-1">
</Typography>
</Box>
) : (
<Box className="p-5 h-full overflow-auto">
{/* 头部:标识信息 */}
<Box className="mb-5">
<Box className="flex items-center gap-2 mb-1">
<Typography
variant="h6"
className="font-bold text-gray-900"
title={result.burst_incident}
>
{result.burst_incident}
</Typography>
<Chip
label={
result.type === "burst_analysis" ? "爆管模拟" : result.type
}
size="small"
color="primary"
variant="outlined"
sx={{
fontWeight: 600,
fontSize: "0.75rem",
height: "24px",
}}
/>
</Box>
<Typography variant="caption" className="text-gray-500">
ID: {result.id}
</Typography>
</Box>
{/* 主要信息:三栏卡片布局 */}
<Box className="grid grid-cols-3 gap-3 mb-5">
{/* 检测时间卡片 */}
<Box className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-3 border border-blue-200 shadow-sm hover:shadow-md transition-shadow">
<Box className="flex items-center gap-1.5 mb-2">
<Box className="w-1.5 h-1.5 rounded-full bg-blue-600"></Box>
<Typography
variant="caption"
className="text-blue-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-blue-900 leading-tight"
sx={{ fontSize: "0.875rem" }}
>
{formatTime(result.detect_time)}
</Typography>
</Box>
{/* 漏损量卡片 */}
<Box className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-3 border border-orange-200 shadow-sm hover:shadow-md transition-shadow">
<Box className="flex items-center gap-1.5 mb-2">
<Box className="w-1.5 h-1.5 rounded-full bg-orange-600"></Box>
<Typography
variant="caption"
className="text-orange-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-orange-900"
sx={{ fontSize: "0.875rem" }}
>
{result.leakage !== null
? `${result.leakage.toFixed(2)} ${FLOW_DISPLAY_UNIT}`
: "N/A"}
</Typography>
</Box>
{/* 定位管段数量卡片 */}
<Box className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-3 border border-green-200 shadow-sm hover:shadow-md transition-shadow">
<Box className="flex items-center gap-1.5 mb-2">
<Box className="w-1.5 h-1.5 rounded-full bg-green-600"></Box>
<Typography
variant="caption"
className="text-green-700 font-semibold uppercase tracking-wide"
sx={{ fontSize: "0.7rem" }}
>
</Typography>
</Box>
<Typography
variant="body2"
className="font-bold text-green-900"
sx={{ fontSize: "0.875rem" }}
>
{result.locate_result ? result.locate_result.length : 0}{" "}
</Typography>
</Box>
</Box>
{/* 定位管段详细列表 */}
{result.locate_result && result.locate_result.length > 0 && (
<Box className="bg-white rounded-lg p-4 border-2 border-blue-200 shadow-sm">
<Box className="flex items-center justify-between mb-3">
<Typography
variant="body1"
className="text-gray-900 font-bold"
sx={{ fontSize: "0.95rem" }}
>
</Typography>
<Box className="flex items-center gap-2">
<Tooltip title="定位所有管道">
<IconButton
size="small"
onClick={() => handleLocatePipes(result.locate_result!)}
color="primary"
sx={{
backgroundColor: "rgba(37, 125, 212, 0.1)",
"&:hover": {
backgroundColor: "rgba(37, 125, 212, 0.2)",
},
}}
>
<LocationIcon sx={{ fontSize: "1.2rem" }} />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box className="grid grid-cols-2 gap-2">
{result.locate_result.map((pipeId, idx) => (
<Box
key={idx}
className="bg-gradient-to-r from-blue-50 to-white rounded-lg px-3 py-2 border border-blue-200 hover:border-blue-400 hover:shadow-md transition-all cursor-pointer group"
onClick={() => handleLocatePipes([pipeId])}
sx={{
"&:active": {
transform: "scale(0.98)",
boxShadow: "0 1px 2px rgba(25, 118, 210, 0.2)",
},
}}
>
<Box className="flex items-center justify-between">
<Typography
variant="body2"
className="font-semibold text-blue-700 group-hover:text-blue-900"
>
{pipeId}
</Typography>
<Box className="flex items-center gap-1">
{/* <Tooltip title="定位管段">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleLocatePipes([pipeId]);
}}
sx={{
"&:hover": {
backgroundColor: "rgba(37, 125, 212, 0.1)",
},
}}
>
<LocationIcon sx={{ fontSize: "1rem" }} />
</IconButton>
</Tooltip> */}
</Box>
</Box>
</Box>
))}
</Box>
</Box>
)}
</Box>
)}
</Box>
</Box>
);
};
export default LocationResults;
@@ -18,6 +18,7 @@ import {
Link,
} from "@mui/material";
import {
DescriptionOutlined as ReportIcon,
Info as InfoIcon,
LocationOn as LocationIcon,
} from "@mui/icons-material";
@@ -52,14 +53,18 @@ import { toLonLat } from "ol/proj";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemaItem, SchemeRecord } from "./types";
import {
getCachedPipeDiameters,
getPipeDiameterDisplay,
loadPipeDiameters,
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps {
schemes?: SchemeRecord[];
onSchemesChange?: (schemes: SchemeRecord[]) => void;
onLocate?: (scheme: SchemeRecord) => void;
onViewReport?: (scheme: SchemeRecord) => void;
network?: string;
state?: BurstSchemeQueryState;
onStateChange?: (state: BurstSchemeQueryState) => void;
@@ -74,6 +79,7 @@ export interface BurstSchemeQueryState {
selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null;
hasQueried: boolean;
}
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
@@ -83,12 +89,13 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
selectedDate: undefined,
timeRange: undefined,
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes,
onSchemesChange,
onLocate,
onViewReport,
network = NETWORK_NAME,
state,
onStateChange,
@@ -105,6 +112,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate,
timeRange,
expandedId,
hasQueried,
} = queryState;
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
@@ -112,6 +120,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const creatorName = useSchemeCreatorName();
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null); // 地图容器元素
const [pipeDiametersByScheme, setPipeDiametersByScheme] = useState<
Record<number, PipeDiameterMap>
@@ -152,9 +161,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true);
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
);
const params: Record<string, string> = {
scheme_type: SCHEME_TYPE,
};
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
let filteredResults = response.data;
if (!queryAll) {
@@ -169,16 +184,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
user: item.username,
username: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
open?.({
type: "error",
type: "success",
message: "查询结果",
description: queryAll
? "没有找到任何方案"
@@ -236,40 +252,23 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
return;
}
const cachedDiameters = getCachedPipeDiameters(pipeIds);
if (cachedDiameters) {
setPipeDiametersByScheme((previous) => ({
...previous,
[expandedId]: cachedDiameters,
}));
return;
}
let cancelled = false;
setLoadingDiameterByScheme((previous) => ({
...previous,
[expandedId]: true,
}));
const loadPipeDiameters = async () => {
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
const foundPipeIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingPipeIds = pipeIds.filter(
(pipeId) => !foundPipeIds.has(pipeId),
);
if (missingPipeIds.length > 0) {
const fallbackFeatures = await queryFeaturesByIds(
missingPipeIds,
"geo_pipes",
);
features = [...features, ...fallbackFeatures];
}
const nextDiameters: 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);
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
});
const applyPipeDiameters = async () => {
const nextDiameters = await loadPipeDiameters(pipeIds);
if (cancelled) {
return;
}
@@ -284,7 +283,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
}));
};
loadPipeDiameters().catch((error) => {
applyPipeDiameters().catch((error) => {
console.error("查询管径失败:", error);
if (!cancelled) {
setLoadingDiameterByScheme((previous) => ({
@@ -452,7 +451,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -465,9 +467,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -495,39 +500,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */}
<Box className="flex-1 overflow-auto">
{filteredSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -592,14 +565,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="查看定位结果">
<Tooltip title="查看分析报告">
<IconButton
size="small"
onClick={() => onLocate?.(scheme)}
onClick={() => onViewReport?.(scheme)}
color="primary"
className="p-1"
>
<LocationIcon fontSize="small" />
<ReportIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
@@ -715,7 +688,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="font-medium text-gray-900"
>
{scheme.user}
{creatorName(scheme.username)}
</Typography>
</Box>
<Box className="flex items-center gap-2">
@@ -52,9 +52,16 @@ import {
import { Point } from "ol/geom";
import { toLonLat } from "ol/proj";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import {
getAffectedNodeCount,
isAllowedAccidentPipe,
normalizeValveIsolationResult,
} from "./valveIsolationScope";
interface ValveIsolationProps {
initialPipeIds?: string[];
allowedPipeIds?: string[];
sourceSchemeName?: string;
shouldFetch?: boolean;
onFetchComplete?: () => void;
loading?: boolean;
@@ -81,6 +88,8 @@ export const createValveIsolationState = (): ValveIsolationState => ({
const ValveIsolation: React.FC<ValveIsolationProps> = ({
initialPipeIds = [],
allowedPipeIds,
sourceSchemeName,
shouldFetch = false,
onFetchComplete,
loading: externalLoading,
@@ -160,14 +169,30 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
return;
}
setSelectedPipeId(pipeId);
const normalizedPipeId = String(pipeId);
if (!isAllowedAccidentPipe(normalizedPipeId, allowedPipeIds)) {
open?.({
type: "error",
message: "请选择当前爆管方案中的事故管段",
});
return;
}
setSelectedPipeId(normalizedPipeId);
setHighlightFeature(feature);
setIsSelecting(false);
setResult(null); // 清除旧结果
}
}
},
[isSelecting, map, open, setResult, setSelectedPipeId],
[
allowedPipeIds,
isSelecting,
map,
open,
setResult,
setSelectedPipeId,
],
);
useEffect(() => {
@@ -207,6 +232,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
"must_close" | "optional" | "affected_node" | "pipe"
>("affected_node");
const selectSchemeAccidentPipe = (pipeId: string) => {
if (!isAllowedAccidentPipe(pipeId, allowedPipeIds)) return;
setSelectedPipeId(pipeId);
setHighlightFeature(null);
setHighlightFeatures([]);
setResult(null);
setActiveStep(0);
setExpandedResult(true);
setDisabledValves([]);
};
const handleLocatePipes = (pipeIds: string[], highlight: boolean = true) => {
if (pipeIds.length > 0) {
@@ -311,6 +346,13 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
open?.({ type: "error", message: "请在地图上选择要分析的管段" });
return;
}
if (ids.some((pipeId) => !isAllowedAccidentPipe(pipeId, allowedPipeIds))) {
open?.({
type: "error",
message: "关阀分析仅限当前爆管方案中的事故管段",
});
return;
}
setLoading(true);
const isExpandSearch = disabled.length > 0;
if (!isExpandSearch) {
@@ -319,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
}
try {
const params: any = {
network: NETWORK_NAME,
accident_element: ids,
};
if (disabled.length > 0) {
params.disabled_valves = disabled;
}
const response = await api.get(
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
const response = await api.post<ValveIsolationResult>(
`${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
undefined,
{
params,
paramsSerializer: {
@@ -334,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
},
},
);
setResult(response.data);
setResult(normalizeValveIsolationResult(response.data));
if (!isExpandSearch) {
setActiveStep(1);
} else {
@@ -352,7 +394,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
setLoading(false);
}
},
[open, setActiveStep, setDisabledValves, setLoading, setResult],
[
allowedPipeIds,
open,
setActiveStep,
setDisabledValves,
setLoading,
setResult,
],
);
// 监听外部传入的分析请求
@@ -666,7 +715,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
{[
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
{ label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" },
{ label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
{ label: "影响节点", value: getAffectedNodeCount(result), color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" },
].map((item, index) => (
<Box
key={index}
@@ -832,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</Box>
)}
{!result.isolatable && (
<Alert severity="info" variant="outlined">
</Alert>
)}
{/* 受影响节点 */}
{result.affected_nodes && result.affected_nodes.length > 0 && (
{result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && (
<Box>
<Box className="flex items-center justify-between mb-2">
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
@@ -912,6 +967,16 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
</StepLabel>
<StepContent>
<Box className="ml-4 pl-4 border-l-2 border-gray-200 space-y-3">
{allowedPipeIds !== undefined ? (
<Alert severity="info" variant="outlined">
{sourceSchemeName || "未命名方案"}
</Alert>
) : (
<Alert severity="warning" variant="outlined">
</Alert>
)}
{/* 选择管段 */}
<Paper elevation={0} className="p-3 bg-white border border-gray-200">
<Box className="flex items-center justify-between mb-2">
@@ -955,6 +1020,36 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
)}
</Box>
{allowedPipeIds !== undefined && (
<Box className="mb-3 rounded border border-blue-100 bg-blue-50 p-2">
<Box className="mb-2 flex flex-wrap gap-1.5">
{allowedPipeIds.map((pipeId) => (
<Chip
key={pipeId}
label={pipeId}
size="small"
color={selectedPipeId === pipeId ? "primary" : "default"}
variant={selectedPipeId === pipeId ? "filled" : "outlined"}
onClick={() => selectSchemeAccidentPipe(pipeId)}
/>
))}
</Box>
<Button
variant="outlined"
size="small"
fullWidth
disabled={allowedPipeIds.length === 0}
onClick={() =>
allowedPipeIds[0] &&
selectSchemeAccidentPipe(allowedPipeIds[0])
}
startIcon={<CheckCircleIcon />}
>
</Button>
</Box>
)}
{isSelecting && (
<Box className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
💡
@@ -1,4 +1,48 @@
import { getPipeDiameterDisplay } from "./schemePipeDiameters";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import {
clearPipeDiameterCache,
getPipeDiameterDisplay,
loadPipeDiameters,
} from "./schemePipeDiameters";
jest.mock("@/utils/mapQueryService", () => ({
queryFeaturesByIds: jest.fn(),
}));
const feature = (id: string, diameter: number) => ({
getProperties: () => ({ id, diameter }),
});
afterEach(() => {
clearPipeDiameterCache();
jest.clearAllMocks();
});
describe("loadPipeDiameters", () => {
it("shares an in-flight WFS request and reuses its resolved result", async () => {
let resolveFeatures: ((features: ReturnType<typeof feature>[]) => void) | undefined;
const pendingFeatures = new Promise<ReturnType<typeof feature>[]>((resolve) => {
resolveFeatures = resolve;
});
(queryFeaturesByIds as jest.Mock).mockImplementation(
async () => pendingFeatures,
);
const firstRequest = loadPipeDiameters(["P-1"]);
const duplicateRequest = loadPipeDiameters(["P-1"]);
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
resolveFeatures?.([feature("P-1", 315)]);
await expect(firstRequest).resolves.toEqual({ "P-1": 315 });
await expect(duplicateRequest).resolves.toEqual({ "P-1": 315 });
await expect(loadPipeDiameters(["P-1"])).resolves.toEqual({
"P-1": 315,
});
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
});
});
describe("getPipeDiameterDisplay", () => {
it("shows the actual diameter for one burst pipe", () => {
@@ -1,5 +1,94 @@
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import config from "@config/config";
export type PipeDiameterMap = Record<string, number | null | undefined>;
const diameterCache = new Map<string, PipeDiameterMap>();
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
const normalizePipeIds = (pipeIds: string[]) =>
[...new Set(pipeIds.map((pipeId) => String(pipeId).trim()).filter(Boolean))];
export const getPipeDiameterQueryKey = (pipeIds: string[]) =>
[
config.MAP_URL,
config.MAP_WORKSPACE,
...normalizePipeIds(pipeIds).sort(),
].join("\u0000");
export const getCachedPipeDiameters = (pipeIds: string[]) =>
diameterCache.get(getPipeDiameterQueryKey(pipeIds));
export const clearPipeDiameterCache = () => {
diameterCache.clear();
diameterRequestCache.clear();
};
export const loadPipeDiameters = async (
pipeIds: string[],
): Promise<PipeDiameterMap> => {
const normalizedPipeIds = normalizePipeIds(pipeIds);
if (normalizedPipeIds.length === 0) {
return {};
}
const queryKey = getPipeDiameterQueryKey(normalizedPipeIds);
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(
normalizedPipeIds,
"geo_pipes_mat",
);
const foundPipeIds = new Set(
features.map((feature) => String(feature.getProperties().id)),
);
const missingPipeIds = normalizedPipeIds.filter(
(pipeId) => !foundPipeIds.has(pipeId),
);
if (missingPipeIds.length > 0) {
const fallbackFeatures = await queryFeaturesByIds(
missingPipeIds,
"geo_pipes",
);
features = [...features, ...fallbackFeatures];
}
const diameters: PipeDiameterMap = Object.fromEntries(
normalizedPipeIds.map((pipeId) => [pipeId, null]),
);
features.forEach((feature) => {
const properties = feature.getProperties();
const pipeId = String(properties.id);
const diameter = Number(properties.diameter);
if (pipeId in diameters) {
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);
}
}
};
export const getPipeDiameterDisplay = (
pipeIds: string[] | undefined,
diameters: PipeDiameterMap | undefined,
+2 -10
View File
@@ -11,7 +11,7 @@ export interface SchemeRecord {
id: number;
schemeName: string;
type: string;
user: string;
username: string;
create_time: string;
startTime: string;
// 详情信息
@@ -28,18 +28,10 @@ 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[];
affected_node_count?: number;
must_close_valves: string[];
optional_valves: string[];
isolatable: boolean;
@@ -0,0 +1,17 @@
import type { ValveIsolationResult } from "./types";
export const isAllowedAccidentPipe = (
pipeId: string,
allowedPipeIds: string[] | undefined,
) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId);
export const getAffectedNodeCount = (result: ValveIsolationResult) =>
result.affected_node_count ?? result.affected_nodes?.length ?? 0;
export const normalizeValveIsolationResult = (
result: ValveIsolationResult,
): ValveIsolationResult => ({
...result,
affected_nodes: result.isolatable ? result.affected_nodes ?? [] : [],
affected_node_count: getAffectedNodeCount(result),
});
@@ -228,7 +228,6 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
console.log("默认设置 pattern 为 CONSTANT");
}
const params = {
network,
start_time: start_time,
source: sourceNode,
concentration,
@@ -237,7 +236,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
scheme_name: schemeName,
};
await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, {
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
params,
});
@@ -40,6 +40,8 @@ import Feature from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps {
schemes?: ContaminantSchemeRecord[];
@@ -59,6 +61,7 @@ export interface ContaminantSchemeQueryState {
selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null;
hasQueried: boolean;
}
export const createContaminantSchemeQueryState =
@@ -69,6 +72,7 @@ export const createContaminantSchemeQueryState =
selectedDate: undefined,
timeRange: undefined,
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -91,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate,
timeRange,
expandedId,
hasQueried,
} = queryState;
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
@@ -101,6 +106,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>([]);
const [loading, setLoading] = useState<boolean>(false);
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
const creatorName = useSchemeCreatorName();
const { open } = useNotification();
const map = useMap();
@@ -216,9 +222,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
if (!queryAll && !queryDate) return;
setLoading(true);
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
);
const params: Record<string, string> = {
scheme_type: SCHEME_TYPE,
};
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
let filteredResults = response.data;
if (!queryAll) {
@@ -235,16 +247,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
user: item.username,
username: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
open?.({
type: "error",
type: "success",
message: "查询结果",
description: queryAll
? "没有找到任何方案"
@@ -342,7 +355,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -355,9 +371,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -384,39 +403,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<Box className="flex-1 overflow-auto">
{filteredSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -591,7 +578,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="font-medium text-gray-900"
>
{scheme.user}
{creatorName(scheme.username)}
</Typography>
</Box>
<Box className="flex items-center gap-2">
@@ -9,7 +9,7 @@ export interface ContaminantSchemeRecord {
id: number;
schemeName: string;
type: string;
user: string;
username: string;
create_time: string;
startTime: string;
schemeDetail?: ContaminantSchemeDetail;
@@ -18,7 +18,7 @@ import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config";
import { config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { LeakageResultDetail } from "./types";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
@@ -82,7 +82,7 @@ const AnalysisParameters: React.FC<Props> = ({
const parsedQSum = Number(qSumInput);
const qSumIsValid =
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 360;
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0;
const isValid = useMemo(() => {
if (!schemeName.trim() || !startTime || !endTime) return false;
@@ -94,7 +94,7 @@ const AnalysisParameters: React.FC<Props> = ({
open?.({
type: "error",
message: "请完善参数并确认时间范围合法",
description: !qSumIsValid ? `总漏损流量不小于 360 ${FLOW_DISPLAY_UNIT}` : undefined,
description: !qSumIsValid ? `总漏损流量不小于 0 ${FLOW_DISPLAY_UNIT}` : undefined,
});
return;
}
@@ -108,9 +108,8 @@ const AnalysisParameters: React.FC<Props> = ({
});
try {
const response = await api.post(
`${config.BACKEND_URL}/api/v1/leakage/identify/`,
`${config.BACKEND_URL}/api/v1/leakage-identifications`,
{
network: NETWORK_NAME,
scheme_name: schemeName.trim(),
dma_count: dmaCount,
scada_start: startTime.toISOString(),
@@ -235,11 +234,11 @@ const AnalysisParameters: React.FC<Props> = ({
setFormField("qSum", value);
}
}}
inputProps={{ min: 360, step: 10 }}
inputProps={{ min: 0, step: 10 }}
error={qSumInput.trim() !== "" && !qSumIsValid}
helperText={
qSumInput.trim() !== "" && !qSumIsValid
? `不小于 360 ${FLOW_DISPLAY_UNIT}`
? `小于 0 ${FLOW_DISPLAY_UNIT}`
: " "
}
/>
@@ -11,11 +11,15 @@ import {
TableHead,
TableRow,
} from "@mui/material";
import { FormatListBulleted } from "@mui/icons-material";
import {
FormatListBulleted,
TroubleshootOutlined,
} from "@mui/icons-material";
import dayjs from "dayjs";
import { getAreaColor } from "./utils";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { LeakageResultDetail } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface Props {
result: LeakageResultDetail | null;
@@ -31,39 +35,11 @@ const RecognitionResults: React.FC<Props> = ({ result }) => {
if (!result || !sortedRows.length) {
return (
<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"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
<PanelEmptyState
icon={<TroubleshootOutlined />}
title="尚未生成识别结果"
description="请在“识别参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"></Typography>
<Typography variant="body2" className="mt-1">
</Typography>
</Box>
);
}
@@ -26,6 +26,8 @@ import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props {
onViewResult: (result: LeakageResultDetail) => void;
@@ -39,12 +41,14 @@ export interface DMALeakSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
hasQueried: boolean;
}
export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({
queryAll: true,
queryDate: dayjs(),
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<Props> = ({
@@ -60,9 +64,10 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange,
createDMALeakSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const creatorName = useSchemeCreatorName();
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes;
const sortedSchemes = useMemo(
@@ -79,18 +84,21 @@ const SchemeQuery: React.FC<Props> = ({
const handleQuery = async () => {
setLoading(true);
try {
const params: Record<string, string> = { network: NETWORK_NAME };
const params: Record<string, string> = {
scheme_type: "dma_leak_identification",
};
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get(`${config.BACKEND_URL}/api/v1/leakage/schemes/`, {
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
const nextSchemes = response.data as LeakageSchemeRecord[];
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (nextSchemes.length === 0) {
open?.({
type: "error",
type: "success",
message: "查询结果",
description: queryAll
? "没有找到任何方案"
@@ -117,8 +125,12 @@ const SchemeQuery: React.FC<Props> = ({
const handleViewSchemeResult = async (schemeName: string) => {
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/leakage/schemes/${encodeURIComponent(schemeName)}`,
{ params: { network: NETWORK_NAME } },
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
{
params: {
scheme_type: "dma_leak_identification",
},
},
);
onViewResult(response.data as LeakageResultDetail);
} catch (error: any) {
@@ -140,7 +152,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox
size="small"
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
/>
}
label={<Typography variant="body2"></Typography>}
@@ -149,7 +164,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={(value) => setQueryField("queryDate", value)}
onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
@@ -170,39 +188,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box>
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -257,7 +243,7 @@ const SchemeQuery: React.FC<Props> = ({
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.username || "-"}
{creatorName(scheme.username)}
</Typography>
</Box>
<Box className="grid grid-cols-[78px_1fr] items-center gap-x-2">
@@ -11,7 +11,11 @@ import {
Alert,
Divider,
} from "@mui/material";
import { Close as CloseIcon } from "@mui/icons-material";
import {
AdjustOutlined,
Close as CloseIcon,
WaterDropOutlined,
} from "@mui/icons-material";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
@@ -31,6 +35,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface ValveItem {
id: string;
@@ -302,11 +307,10 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
const params = {
scheme_name: schemeName,
network: NETWORK_NAME,
start_time: formattedTime,
valves: valves.map(v => v.id),
valves_k: valves.map(v => v.k),
drainage_node_ID: drainageNode,
drainage_node_id: drainageNode,
flush_flow: flushFlow,
duration: duration
};
@@ -315,7 +319,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
// but axios usually handles array as valves[]=1&valves[]=2
// FastAPI default expects repeated query params.
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing-analysis`, {
const response = await api.post(`${config.BACKEND_URL}/api/v1/flushing-analyses`, undefined, {
params,
// Ensure arrays are sent as repeated keys: valves=1&valves=2
paramsSerializer: {
@@ -383,9 +387,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
</Box>
))}
{valves.length === 0 && (
<Typography variant="caption" className="text-gray-400 text-center py-20">
</Typography>
<PanelEmptyState
variant="compact"
icon={<AdjustOutlined />}
title="尚未选择阀门"
description="点击“选择阀门”,然后在地图上添加。"
/>
)}
</Stack>
</Box>
@@ -412,7 +419,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
💡
</Box>
)}
<Stack spacing={1} className="h-12 overflow-auto">
<Stack spacing={1} className="min-h-16 overflow-auto">
{drainageNode && (
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography>
@@ -428,9 +435,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
</Box>
)}
{!drainageNode && (
<Typography variant="caption" className="text-gray-400 text-center py-2">
</Typography>
<PanelEmptyState
variant="compact"
icon={<WaterDropOutlined />}
title="尚未选择排水节点"
description="点击“选择节点”,然后在地图上指定排水点。"
/>
)}
</Stack>
</Box>
@@ -42,6 +42,8 @@ import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemeRecord, SchemaItem } from "./types";
import { FLOW_DISPLAY_UNIT } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps {
schemes?: SchemeRecord[];
@@ -60,6 +62,7 @@ export interface FlushingSchemeQueryState {
showTimeline: boolean;
selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined;
hasQueried: boolean;
}
export const createFlushingSchemeQueryState =
@@ -70,6 +73,7 @@ export const createFlushingSchemeQueryState =
showTimeline: false,
selectedDate: undefined,
timeRange: undefined,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -91,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
showTimeline,
selectedDate,
timeRange,
hasQueried,
} = queryState;
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -100,6 +105,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [mapContainer, setMapContainer] = useState<HTMLElement | null>(null);
const creatorName = useSchemeCreatorName();
const { open } = useNotification();
const map = useMap();
@@ -262,9 +268,15 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true);
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/schemes?network=${network}`,
);
const params: Record<string, string> = {
scheme_type: SCHEME_TYPE,
};
if (!queryAll && queryDate) {
params.query_date = queryDate.startOf("day").toISOString();
}
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
params,
});
let filteredResults = response.data;
@@ -283,16 +295,17 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
id: item.scheme_id,
schemeName: item.scheme_name,
type: item.scheme_type,
user: item.username,
username: item.username,
create_time: item.create_time,
startTime: item.scheme_start_time,
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
open?.({
type: "error",
type: "success",
message: "未找到相关方案",
description: "请尝试更改查询条件",
});
@@ -362,7 +375,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -375,9 +391,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -405,39 +424,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* Results List */}
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -472,7 +459,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="text-gray-500 block"
>
: {scheme.user} · : {formatTime(scheme.create_time)}
: {creatorName(scheme.username)} · :{" "}
{formatTime(scheme.create_time)}
</Typography>
</Box>
<Box className="flex gap-1 ml-2">
@@ -572,7 +560,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
:
</Typography>
<Typography variant="caption" className="font-medium text-gray-900">
{scheme.user}
{creatorName(scheme.username)}
</Typography>
</Box>
@@ -9,7 +9,7 @@ export interface SchemeRecord {
id: number;
schemeName: string;
type: string;
user: string;
username: string;
create_time: string;
startTime: string;
// 详情信息
@@ -8,6 +8,8 @@ interface HealthRiskContextType {
setPredictionResults: Dispatch<SetStateAction<PredictionResult[]>>;
currentYear: number;
setCurrentYear: Dispatch<SetStateAction<number>>;
analysisQueryTime: string | null;
setAnalysisQueryTime: Dispatch<SetStateAction<string | null>>;
}
const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undefined);
@@ -15,6 +17,7 @@ const HealthRiskContext = createContext<HealthRiskContextType | undefined>(undef
export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [predictionResults, setPredictionResults] = useState<PredictionResult[]>([]);
const [currentYear, setCurrentYear] = useState<number>(4);
const [analysisQueryTime, setAnalysisQueryTime] = useState<string | null>(null);
return (
<HealthRiskContext.Provider
@@ -23,6 +26,8 @@ export const HealthRiskProvider: React.FC<{ children: ReactNode }> = ({ children
setPredictionResults,
currentYear,
setCurrentYear,
analysisQueryTime,
setAnalysisQueryTime,
}}
>
{children}
@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from "@testing-library/react";
import HealthRiskReport from "./HealthRiskReport";
import { PredictionResult } from "./types";
jest.mock("echarts-for-react", () => ({
__esModule: true,
default: () => <div data-testid="report-chart" />,
}));
const predictionResults: PredictionResult[] = [
{
link_id: "PIPE-007",
survival_function: {
x: [4, 5],
y: [0.2, 0.15],
a: 0,
b: 0,
},
},
{
link_id: "PIPE-021",
survival_function: {
x: [4, 5],
y: [0.8, 0.7],
a: 0,
b: 0,
},
},
];
describe("HealthRiskReport", () => {
it("renders automatic report identity and priority results", () => {
render(
<HealthRiskReport
open
onClose={jest.fn()}
predictionResults={predictionResults}
currentYear={4}
analysisQueryTime="2026-07-30T01:02:03.000Z"
networkName="fengyang"
generatedAt={new Date("2026-07-30T03:04:05.000Z")}
/>,
);
expect(
screen.getByRole("heading", { name: "管网健康风险体检报告" }),
).toBeInTheDocument();
expect(screen.getByText("fengyang")).toBeInTheDocument();
expect(screen.getByText("第 4 年")).toBeInTheDocument();
expect(screen.getByText("PIPE-007")).toBeInTheDocument();
expect(screen.getAllByTestId("report-chart")).toHaveLength(2);
});
it("prints the report and restores document state after printing", () => {
const originalTitle = document.title;
const print = jest
.spyOn(window, "print")
.mockImplementation(() => undefined);
render(
<HealthRiskReport
open
onClose={jest.fn()}
predictionResults={predictionResults}
currentYear={4}
analysisQueryTime="2026-07-30T01:02:03.000Z"
networkName="fengyang"
generatedAt={new Date("2026-07-30T03:04:05.000Z")}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "打印" }));
expect(print).toHaveBeenCalledTimes(1);
expect(document.body).toHaveClass("health-risk-report-printing");
expect(document.title).toContain("管网健康风险体检报告-fengyang");
window.dispatchEvent(new Event("afterprint"));
expect(document.body).not.toHaveClass("health-risk-report-printing");
expect(document.title).toBe(originalTitle);
print.mockRestore();
});
});
@@ -0,0 +1,746 @@
"use client";
import React, { useEffect, useMemo, useRef } from "react";
import ReactECharts from "echarts-for-react";
import {
Box,
Button,
Dialog,
GlobalStyles,
IconButton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import {
Close,
PrintOutlined,
WaterDropOutlined,
} from "@mui/icons-material";
import { PredictionResult } from "./types";
import { buildHealthRiskReportData } from "./healthRiskReport";
interface HealthRiskReportProps {
open: boolean;
onClose: () => void;
predictionResults: PredictionResult[];
currentYear: number;
analysisQueryTime: string | null;
networkName: string;
generatedAt: Date;
}
const REPORT_BLUE = "#0b4f87";
const REPORT_INK = "#172435";
const REPORT_MUTED = "#5f6f80";
const REPORT_LINE = "#d8e0e8";
const reportFontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif';
const formatDateTime = (value: Date | string | null) => {
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 formatPercent = (value: number | null, fractionDigits = 1) =>
value === null ? "无有效数据" : `${(value * 100).toFixed(fractionDigits)}%`;
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<Stack
direction="row"
alignItems="center"
spacing={1.25}
sx={{ mb: 1.5 }}
>
<Box
aria-hidden="true"
sx={{ width: 24, height: 3, bgcolor: REPORT_BLUE, flexShrink: 0 }}
/>
<Typography
component="h2"
sx={{
color: REPORT_INK,
fontSize: 17,
fontWeight: 700,
lineHeight: 1.4,
}}
>
{children}
</Typography>
</Stack>
);
const HealthRiskReport: React.FC<HealthRiskReportProps> = ({
open,
onClose,
predictionResults,
currentYear,
analysisQueryTime,
networkName,
generatedAt,
}) => {
const printStateRef = useRef<{ title: string } | null>(null);
const reportData = useMemo(
() => buildHealthRiskReportData(predictionResults, currentYear),
[currentYear, predictionResults],
);
useEffect(
() => () => {
if (printStateRef.current) {
document.title = printStateRef.current.title;
document.body.classList.remove("health-risk-report-printing");
}
},
[],
);
const restoreAfterPrint = () => {
if (!printStateRef.current) return;
document.title = printStateRef.current.title;
printStateRef.current = null;
document.body.classList.remove("health-risk-report-printing");
};
const handlePrint = () => {
if (printStateRef.current) return;
printStateRef.current = { title: document.title };
document.title = `管网健康风险体检报告-${networkName || "未命名管网"}-${new Intl.DateTimeFormat(
"zh-CN",
{ year: "numeric", month: "2-digit", day: "2-digit" },
)
.format(generatedAt)
.replaceAll("/", "-")}`;
document.body.classList.add("health-risk-report-printing");
window.addEventListener("afterprint", restoreAfterPrint, { once: true });
window.print();
};
const distributionOption = {
animation: false,
grid: { top: 4, right: 38, bottom: 28, left: 148 },
xAxis: {
type: "value",
name: "管段数",
minInterval: 1,
axisLine: { lineStyle: { color: "#8b99a8" } },
axisLabel: { color: REPORT_MUTED, fontSize: 10 },
splitLine: { lineStyle: { color: "#e7edf2" } },
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
},
yAxis: {
type: "category",
inverse: true,
data: reportData.distribution.map((item) => item.label),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: REPORT_INK, fontSize: 10 },
},
tooltip: { show: false },
series: [
{
type: "bar",
barMaxWidth: 14,
data: reportData.distribution.map((item) => ({
value: item.count,
itemStyle: { color: item.color },
})),
label: {
show: true,
position: "right",
color: REPORT_INK,
fontSize: 10,
},
},
],
};
const trendOption = {
animation: false,
grid: { top: 24, right: 24, bottom: 38, left: 48 },
xAxis: {
type: "category",
name: "预测年份",
boundaryGap: false,
data: reportData.trend.map((item) => item.year),
axisLine: { lineStyle: { color: "#8b99a8" } },
axisLabel: {
color: REPORT_MUTED,
fontSize: 10,
interval: Math.max(0, Math.ceil(reportData.trend.length / 9) - 1),
},
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
},
yAxis: {
type: "value",
name: "较高及以上风险管段数",
minInterval: 1,
axisLine: { show: false },
axisLabel: { color: REPORT_MUTED, fontSize: 10 },
splitLine: { lineStyle: { color: "#e7edf2" } },
nameTextStyle: { color: REPORT_MUTED, fontSize: 10 },
},
tooltip: { show: false },
series: [
{
type: "line",
smooth: true,
symbol: "none",
lineStyle: { color: "#c83d3d", width: 2.5 },
areaStyle: { color: "rgba(200, 61, 61, 0.08)" },
data: reportData.trend.map((item) => item.count),
},
],
};
const metricItems = [
{ label: "分析管段", value: reportData.totalCount.toLocaleString("zh-CN") },
{ label: "有效结果", value: reportData.validCount.toLocaleString("zh-CN") },
{
label: "平均生存概率",
value: formatPercent(reportData.averageProbability),
},
{
label: "较高及以上风险",
value: `${reportData.highRiskCount.toLocaleString("zh-CN")}`,
detail: formatPercent(reportData.highRiskRatio),
danger: reportData.highRiskCount > 0,
},
];
return (
<>
<GlobalStyles
styles={{
"@page": { size: "A4 portrait", margin: 0 },
"@media print": {
"html, body": {
width: "210mm",
minHeight: "297mm",
margin: 0,
padding: 0,
backgroundColor: "#fff",
},
"body.health-risk-report-printing > *:not(.health-risk-report-dialog)":
{
display: "none !important",
},
".health-risk-report-dialog": {
position: "static !important",
display: "block !important",
},
".health-risk-report-dialog .MuiBackdrop-root, .health-risk-report-actions":
{
display: "none !important",
},
".health-risk-report-dialog .MuiDialog-container": {
display: "block !important",
height: "auto !important",
},
".health-risk-report-dialog .MuiDialog-paper": {
width: "210mm !important",
maxWidth: "none !important",
minHeight: "297mm !important",
maxHeight: "none !important",
margin: "0 !important",
overflow: "visible !important",
boxShadow: "none !important",
},
".health-risk-report-preview": {
padding: "0 !important",
overflow: "visible !important",
backgroundColor: "#fff !important",
},
"#health-risk-print-root": {
width: "210mm !important",
minHeight: "297mm !important",
padding: "12mm !important",
boxShadow: "none !important",
printColorAdjust: "exact",
WebkitPrintColorAdjust: "exact",
},
".health-risk-report-section, .health-risk-report-metrics": {
breakInside: "avoid",
pageBreakInside: "avoid",
},
".health-risk-report-table-row": {
breakInside: "avoid",
pageBreakInside: "avoid",
},
},
}}
/>
<Dialog
open={open}
onClose={onClose}
maxWidth={false}
fullWidth
className="health-risk-report-dialog"
slotProps={{
paper: {
sx: {
width: "100%",
height: "100%",
maxHeight: "100%",
m: 0,
borderRadius: 0,
bgcolor: "#e8edf2",
},
},
}}
>
<Stack
className="health-risk-report-actions"
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{
position: "sticky",
top: 0,
zIndex: 2,
minHeight: 64,
px: { xs: 1.5, sm: 3 },
bgcolor: "#fff",
boxShadow: "0 1px 4px rgba(23, 36, 53, 0.16)",
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<IconButton
onClick={onClose}
aria-label="关闭报告预览"
sx={{ width: 40, height: 40 }}
>
<Close />
</IconButton>
<Box>
<Typography sx={{ fontWeight: 700, color: REPORT_INK }}>
</Typography>
<Typography variant="caption" sx={{ color: REPORT_MUTED }}>
A4 PDF
</Typography>
</Box>
</Stack>
<Button
variant="contained"
startIcon={<PrintOutlined />}
onClick={handlePrint}
sx={{
minHeight: 40,
borderRadius: 1.5,
bgcolor: REPORT_BLUE,
"&:hover": { bgcolor: "#083f6d" },
"&:active": { transform: "scale(0.96)" },
}}
>
</Button>
</Stack>
<Box
className="health-risk-report-preview"
sx={{
flex: 1,
overflow: "auto",
px: { xs: 0, sm: 2 },
py: { xs: 0, sm: 3 },
}}
>
<Box
id="health-risk-print-root"
lang="zh-CN"
sx={{
boxSizing: "border-box",
width: { xs: "100%", sm: "min(210mm, 100%)" },
minHeight: "297mm",
mx: "auto",
p: { xs: 2, sm: "12mm" },
bgcolor: "#fff",
color: REPORT_INK,
boxShadow: { xs: "none", sm: "0 12px 32px rgba(23,36,53,0.16)" },
fontFamily: reportFontFamily,
fontVariantNumeric: "tabular-nums",
}}
>
<Box
component="header"
sx={{
bgcolor: REPORT_BLUE,
color: "#f4f8fb",
px: { xs: 2.5, sm: 4 },
py: { xs: 2.5, sm: 3.5 },
}}
>
<Stack
direction={{ xs: "column", sm: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", sm: "center" }}
spacing={2}
>
<Box>
<Stack direction="row" alignItems="center" spacing={1}>
<WaterDropOutlined aria-hidden="true" />
<Typography
lang="en"
sx={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.08em" }}
>
TJWATER
</Typography>
</Stack>
<Typography
component="h1"
sx={{
mt: 1.5,
fontSize: { xs: 24, sm: 30 },
fontWeight: 700,
lineHeight: 1.25,
textWrap: "balance",
}}
>
</Typography>
<Typography
sx={{
mt: 0.75,
color: "#cfe1ef",
fontSize: 13,
lineHeight: 1.7,
}}
>
Pipeline health risk assessment
</Typography>
</Box>
<Box
sx={{
minWidth: { sm: 168 },
width: { xs: "100%", sm: "auto" },
pt: { xs: 2, sm: 0 },
pl: { xs: 0, sm: 3 },
borderTop: {
xs: "1px solid rgba(224, 238, 248, 0.28)",
sm: "none",
},
borderLeft: {
xs: "none",
sm: "1px solid rgba(224, 238, 248, 0.28)",
},
}}
>
<Stack direction="row" alignItems="center" spacing={0.8}>
<Box
aria-hidden="true"
sx={{
width: 7,
height: 7,
flexShrink: 0,
borderRadius: "50%",
bgcolor:
reportData.highRiskCount > 0 ? "#ffb3a7" : "#9dd7bd",
}}
/>
<Typography sx={{ color: "#cfe1ef", fontSize: 11 }}>
</Typography>
</Stack>
<Typography
sx={{
mt: 0.75,
fontWeight: 700,
fontSize: 20,
lineHeight: 1.35,
}}
>
{reportData.highRiskCount > 0 ? "需重点关注" : "持续监测"}
</Typography>
</Box>
</Stack>
</Box>
<Box
component="dl"
sx={{
m: 0,
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)" },
borderBottom: `1px solid ${REPORT_LINE}`,
}}
>
{[
["管网项目", networkName || "未命名管网"],
["预测时点", `${currentYear}`],
["分析数据时间", formatDateTime(analysisQueryTime)],
["报告生成时间", formatDateTime(generatedAt)],
].map(([label, value]) => (
<Box
key={label}
sx={{
display: "grid",
gridTemplateColumns: "96px 1fr",
gap: 1,
px: 2,
py: 1.25,
borderBottom: { xs: `1px solid ${REPORT_LINE}`, sm: "none" },
}}
>
<Typography
component="dt"
sx={{ color: REPORT_MUTED, fontSize: 12 }}
>
{label}
</Typography>
<Typography
component="dd"
sx={{
m: 0,
color: REPORT_INK,
fontSize: 12,
fontWeight: 600,
overflowWrap: "anywhere",
}}
>
{value}
</Typography>
</Box>
))}
</Box>
<Box
component="section"
className="health-risk-report-section"
sx={{ mt: 3 }}
>
<SectionTitle></SectionTitle>
<Box
sx={{
bgcolor:
reportData.highRiskCount > 0 ? "#fff4f1" : "#eff8f4",
px: 2.25,
py: 1.75,
}}
>
<Typography sx={{ fontSize: 13, lineHeight: 1.8, color: REPORT_INK }}>
{reportData.conclusion}
</Typography>
{reportData.invalidCount > 0 && (
<Typography sx={{ mt: 0.5, fontSize: 11, color: REPORT_MUTED }}>
{reportData.invalidCount}
</Typography>
)}
</Box>
</Box>
<Box
className="health-risk-report-metrics"
sx={{
mt: 2.25,
display: "grid",
gridTemplateColumns: { xs: "repeat(2, 1fr)", sm: "repeat(4, 1fr)" },
borderTop: `1px solid ${REPORT_LINE}`,
borderBottom: `1px solid ${REPORT_LINE}`,
}}
>
{metricItems.map((item, index) => (
<Box
key={item.label}
sx={{
px: 1.5,
py: 1.75,
borderRight:
index < metricItems.length - 1
? `1px solid ${REPORT_LINE}`
: "none",
}}
>
<Typography sx={{ color: REPORT_MUTED, fontSize: 11 }}>
{item.label}
</Typography>
<Typography
sx={{
mt: 0.5,
color: item.danger ? "#a62f2f" : REPORT_INK,
fontSize: 20,
fontWeight: 700,
lineHeight: 1.25,
}}
>
{item.value}
</Typography>
{item.detail && (
<Typography sx={{ mt: 0.25, color: REPORT_MUTED, fontSize: 10 }}>
{item.detail}
</Typography>
)}
</Box>
))}
</Box>
<Box
component="section"
className="health-risk-report-section"
sx={{ mt: 3 }}
>
<SectionTitle></SectionTitle>
<Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}>
</Typography>
<ReactECharts
option={distributionOption}
style={{ width: "100%", height: 300 }}
opts={{ renderer: "svg" }}
notMerge
/>
</Box>
<Box
component="section"
className="health-risk-report-section"
sx={{ mt: 3 }}
>
<SectionTitle></SectionTitle>
<Typography sx={{ mb: 1, color: REPORT_MUTED, fontSize: 11 }}>
0.3
</Typography>
<ReactECharts
option={trendOption}
style={{ width: "100%", height: 240 }}
opts={{ renderer: "svg" }}
notMerge
/>
</Box>
<Box component="section" sx={{ mt: 3 }}>
<SectionTitle></SectionTitle>
<Typography sx={{ mb: 1.25, color: REPORT_MUTED, fontSize: 11 }}>
20
</Typography>
{reportData.priorityPipes.length > 0 ? (
<TableContainer>
<Table
size="small"
aria-label="重点风险管段"
sx={{ tableLayout: "fixed" }}
>
<TableHead>
<TableRow sx={{ bgcolor: "#edf3f7" }}>
<TableCell sx={{ width: 52, fontWeight: 700 }}></TableCell>
<TableCell sx={{ width: "34%", fontWeight: 700 }}>
</TableCell>
<TableCell align="right" sx={{ width: 110, fontWeight: 700 }}>
</TableCell>
<TableCell sx={{ fontWeight: 700 }}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{reportData.priorityPipes.map((pipe, index) => (
<TableRow
key={`${pipe.linkId}-${index}`}
className="health-risk-report-table-row"
>
<TableCell>{index + 1}</TableCell>
<TableCell
sx={{ fontWeight: 600, overflowWrap: "anywhere" }}
>
{pipe.linkId}
</TableCell>
<TableCell align="right">
{formatPercent(pipe.probability, 2)}
</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<Box
aria-hidden="true"
sx={{
width: 9,
height: 9,
flexShrink: 0,
bgcolor: pipe.color,
borderRadius: "50%",
}}
/>
<Typography sx={{ fontSize: 12 }}>
{pipe.riskLabel}
</Typography>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Box sx={{ bgcolor: "#f4f7f9", px: 2, py: 2 }}>
<Typography sx={{ color: REPORT_MUTED, fontSize: 12 }}>
</Typography>
</Box>
)}
</Box>
<Box
component="section"
className="health-risk-report-section"
sx={{ mt: 3 }}
>
<SectionTitle></SectionTitle>
<Box component="ol" sx={{ m: 0, pl: 3 }}>
{reportData.recommendations.map((recommendation) => (
<Typography
component="li"
key={recommendation}
sx={{
pl: 0.5,
mb: 0.75,
color: REPORT_INK,
fontSize: 12,
lineHeight: 1.75,
}}
>
{recommendation}
</Typography>
))}
</Box>
</Box>
<Box
component="footer"
sx={{
mt: 3.5,
pt: 1.5,
borderTop: `1px solid ${REPORT_LINE}`,
display: "flex",
justifyContent: "space-between",
gap: 2,
color: REPORT_MUTED,
}}
>
<Typography sx={{ fontSize: 10 }}>TJWater </Typography>
<Typography sx={{ fontSize: 10, textAlign: "right" }}>
0.3
</Typography>
</Box>
</Box>
</Box>
</Dialog>
</>
);
};
export default HealthRiskReport;
@@ -11,10 +11,19 @@ import {
Stack,
Slide,
Fade,
Button,
} from "@mui/material";
import { ChevronLeft, ChevronRight, BarChart } from "@mui/icons-material";
import {
ChevronLeft,
ChevronRight,
BarChart,
DescriptionOutlined,
} from "@mui/icons-material";
import { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types";
import { useHealthRisk } from "./HealthRiskContext";
import { useProject } from "@/contexts/ProjectContext";
import HealthRiskReport from "./HealthRiskReport";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
const SIMPLE_LABELS = [
"0.0 - 0.1",
@@ -30,8 +39,13 @@ const SIMPLE_LABELS = [
];
const HealthRiskStatistics: React.FC = () => {
const { predictionResults, currentYear } = useHealthRisk();
const { predictionResults, currentYear, analysisQueryTime } = useHealthRisk();
const project = useProject();
const [isExpanded, setIsExpanded] = React.useState<boolean>(true);
const [isReportOpen, setIsReportOpen] = React.useState<boolean>(false);
const [reportGeneratedAt, setReportGeneratedAt] = React.useState(
() => new Date(),
);
const [hoveredYearIndex, setHoveredYearIndex] = React.useState<number | null>(
null
);
@@ -216,24 +230,11 @@ const HealthRiskStatistics: React.FC = () => {
};
const renderEmpty = () => (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "text.secondary",
}}
>
<BarChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<PanelEmptyState
icon={<BarChart />}
title="暂无风险预测结果"
description="请先运行健康风险预测,完成后将在这里展示风险分布统计。"
/>
);
return (
@@ -278,6 +279,41 @@ const HealthRiskStatistics: React.FC = () => {
/>
</div>
<Stack direction="row" spacing={1}>
<Tooltip
title={
predictionResults.length > 0
? "生成健康风险体检报告"
: "请先完成健康风险分析"
}
>
<span>
<Button
size="small"
variant="outlined"
startIcon={<DescriptionOutlined fontSize="small" />}
disabled={predictionResults.length === 0}
onClick={() => {
setReportGeneratedAt(new Date());
setIsReportOpen(true);
}}
sx={{
minHeight: 32,
color: "white",
borderColor: "rgba(255,255,255,0.62)",
"&:hover": {
borderColor: "white",
backgroundColor: "rgba(255,255,255,0.1)",
},
"&.Mui-disabled": {
color: "rgba(255,255,255,0.48)",
borderColor: "rgba(255,255,255,0.24)",
},
}}
>
</Button>
</span>
</Tooltip>
<Tooltip title="收起">
<IconButton
size="small"
@@ -308,6 +344,15 @@ const HealthRiskStatistics: React.FC = () => {
</div>
</div>
</Slide>
<HealthRiskReport
open={isReportOpen}
onClose={() => setIsReportOpen(false)}
predictionResults={predictionResults}
currentYear={currentYear}
analysisQueryTime={analysisQueryTime}
networkName={project?.networkName || "未命名管网"}
generatedAt={reportGeneratedAt}
/>
</>
);
};
@@ -8,6 +8,7 @@ import { ShowChart } from "@mui/icons-material";
import ReactECharts from "echarts-for-react";
import "dayjs/locale/zh-cn";
import { useHealthRisk } from "./HealthRiskContext";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface PredictDataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
@@ -58,26 +59,11 @@ const PredictDataPanel: React.FC<PredictDataPanelProps> = ({
}, [filteredResults]);
const renderEmpty = () => (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 8,
color: "text.secondary",
height: "100%",
}}
>
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<PanelEmptyState
icon={<ShowChart />}
title="暂无可展示的管道预测"
description="请先在地图上选择已完成健康风险分析的管道。"
/>
);
const renderChart = () => {
@@ -137,6 +137,7 @@ const Timeline: React.FC<TimelineProps> = ({
setPredictionResults,
currentYear,
setCurrentYear,
setAnalysisQueryTime,
} = useHealthRisk();
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
@@ -391,12 +392,13 @@ const Timeline: React.FC<TimelineProps> = ({
});
try {
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
`${config.BACKEND_URL}/api/v1/pipeline-health-predictions?query_time=${query_time}`,
);
if (response.ok) {
const results: PredictionResult[] = await response.json();
setPredictionResults(results);
setAnalysisQueryTime(calculationDateTime.toISOString());
open?.({
type: "success",
message: `模拟预测完成,获取到 ${results.length} 条管道数据`,
@@ -404,7 +406,11 @@ const Timeline: React.FC<TimelineProps> = ({
} else {
// 读取后端 HTTPException 返回的 detail 信息
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.detail || "模拟预测失败";
const errorMessage =
typeof errorData.detail === "string" &&
errorData.detail.includes("未找到流速数据")
? "所选时间暂无可用的管网模拟数据,请确认该时刻已完成模拟计算并生成流速结果后重试。"
: errorData.detail || "模拟预测失败";
open?.({
type: "error",
message: errorMessage,
@@ -0,0 +1,84 @@
import {
buildHealthRiskReportData,
getProbabilityAtYear,
getRiskBandIndex,
PRIORITY_PIPE_LIMIT,
} from "./healthRiskReport";
import { PredictionResult } from "./types";
const createResult = (
linkId: string,
years: number[],
probabilities: number[],
): PredictionResult => ({
link_id: linkId,
survival_function: {
x: years,
y: probabilities,
a: 0,
b: 0,
},
});
describe("healthRiskReport", () => {
it("matches probability by the exact forecast year", () => {
const result = createResult("P-1", [4, 6], [0.8, 0.2]);
expect(getProbabilityAtYear(result, 6)).toBe(0.2);
expect(getProbabilityAtYear(result, 5)).toBeNull();
});
it("rejects missing and out-of-range probabilities", () => {
expect(
getProbabilityAtYear(createResult("P-1", [4], [Number.NaN]), 4),
).toBeNull();
expect(getProbabilityAtYear(createResult("P-2", [4], [1.1]), 4)).toBeNull();
});
it("keeps risk break boundaries consistent with the existing legend", () => {
expect(getRiskBandIndex(0)).toBe(0);
expect(getRiskBandIndex(0.1)).toBe(0);
expect(getRiskBandIndex(0.10001)).toBe(1);
expect(getRiskBandIndex(1)).toBe(9);
});
it("builds the current snapshot and high-risk trend", () => {
const report = buildHealthRiskReportData(
[
createResult("P-1", [4, 5], [0.2, 0.1]),
createResult("P-2", [4, 5], [0.5, 0.25]),
createResult("P-3", [4, 5], [0.9, 0.8]),
createResult("P-4", [5], [0.4]),
],
4,
);
expect(report.totalCount).toBe(4);
expect(report.validCount).toBe(3);
expect(report.invalidCount).toBe(1);
expect(report.highRiskCount).toBe(1);
expect(report.highRiskRatio).toBeCloseTo(1 / 3);
expect(report.averageProbability).toBeCloseTo(0.533333);
expect(report.distribution.reduce((sum, item) => sum + item.count, 0)).toBe(
3,
);
expect(report.trend).toEqual([
{ year: 4, count: 1, validCount: 3, ratio: 1 / 3 },
{ year: 5, count: 2, validCount: 4, ratio: 0.5 },
]);
});
it("sorts priority pipes and limits the printed detail", () => {
const results = Array.from({ length: PRIORITY_PIPE_LIMIT + 5 }, (_, index) =>
createResult(`P-${PRIORITY_PIPE_LIMIT + 5 - index}`, [4], [0.2]),
);
const report = buildHealthRiskReportData(results, 4);
expect(report.priorityPipes).toHaveLength(PRIORITY_PIPE_LIMIT);
expect(report.priorityPipes[0].linkId).toBe("P-1");
expect(report.priorityPipes.at(-1)?.linkId).toBe(
`P-${PRIORITY_PIPE_LIMIT}`,
);
});
});
@@ -0,0 +1,201 @@
import {
PredictionResult,
RAINBOW_COLORS,
RISK_BREAKS,
RISK_LABELS,
} from "./types";
export const HIGH_RISK_MAX_PROBABILITY = 0.3;
export const PRIORITY_PIPE_LIMIT = 20;
export interface RiskDistributionItem {
label: string;
color: string;
count: number;
ratio: number;
}
export interface HighRiskTrendPoint {
year: number;
count: number;
validCount: number;
ratio: number;
}
export interface PriorityPipeItem {
linkId: string;
probability: number;
riskLabel: string;
color: string;
}
export interface HealthRiskReportData {
totalCount: number;
validCount: number;
invalidCount: number;
averageProbability: number | null;
highRiskCount: number;
highRiskRatio: number;
distribution: RiskDistributionItem[];
trend: HighRiskTrendPoint[];
priorityPipes: PriorityPipeItem[];
conclusion: string;
recommendations: string[];
}
const isValidProbability = (value: unknown): value is number =>
typeof value === "number" &&
Number.isFinite(value) &&
value >= 0 &&
value <= 1;
export const getProbabilityAtYear = (
result: PredictionResult,
year: number,
): number | null => {
const yearIndex = result.survival_function.x.findIndex(
(value) => value === year,
);
if (yearIndex < 0) return null;
const probability = result.survival_function.y[yearIndex];
return isValidProbability(probability) ? probability : null;
};
export const getRiskBandIndex = (probability: number): number => {
const index = RISK_BREAKS.findIndex((upperBound) => probability <= upperBound);
return index >= 0 ? index : RISK_BREAKS.length - 1;
};
export const buildHealthRiskReportData = (
results: PredictionResult[],
currentYear: number,
): HealthRiskReportData => {
const currentValues = results.flatMap((result) => {
const probability = getProbabilityAtYear(result, currentYear);
return probability === null ? [] : [{ result, probability }];
});
const validCount = currentValues.length;
const highRiskCount = currentValues.filter(
({ probability }) => probability <= HIGH_RISK_MAX_PROBABILITY,
).length;
const distribution = RISK_LABELS.map((label, index) => {
const count = currentValues.filter(
({ probability }) => getRiskBandIndex(probability) === index,
).length;
return {
label,
color: RAINBOW_COLORS[index],
count,
ratio: validCount > 0 ? count / validCount : 0,
};
});
const priorityPipes = currentValues
.filter(
({ probability }) => probability <= HIGH_RISK_MAX_PROBABILITY,
)
.sort(
(left, right) =>
left.probability - right.probability ||
left.result.link_id.localeCompare(right.result.link_id, "zh-CN", {
numeric: true,
}),
)
.slice(0, PRIORITY_PIPE_LIMIT)
.map(({ result, probability }) => {
const bandIndex = getRiskBandIndex(probability);
return {
linkId: result.link_id,
probability,
riskLabel: RISK_LABELS[bandIndex],
color: RAINBOW_COLORS[bandIndex],
};
});
const trendByYear = new Map<
number,
{ count: number; validCount: number }
>();
results.forEach((result) => {
const seenYears = new Set<number>();
result.survival_function.x.forEach((year, index) => {
if (
typeof year !== "number" ||
!Number.isFinite(year) ||
seenYears.has(year)
) {
return;
}
seenYears.add(year);
const probability = result.survival_function.y[index];
if (!isValidProbability(probability)) return;
const point = trendByYear.get(year) ?? { count: 0, validCount: 0 };
point.validCount += 1;
if (probability <= HIGH_RISK_MAX_PROBABILITY) point.count += 1;
trendByYear.set(year, point);
});
});
const trend = Array.from(trendByYear, ([year, point]) => ({
year,
count: point.count,
validCount: point.validCount,
ratio: point.validCount > 0 ? point.count / point.validCount : 0,
})).sort((left, right) => left.year - right.year);
const averageProbability =
validCount > 0
? currentValues.reduce(
(total, { probability }) => total + probability,
0,
) / validCount
: null;
const highRiskRatio = validCount > 0 ? highRiskCount / validCount : 0;
const mediumRiskCount = currentValues.filter(
({ probability }) =>
probability > HIGH_RISK_MAX_PROBABILITY && probability <= 0.6,
).length;
const conclusion =
validCount === 0
? `预测第 ${currentYear} 年没有可用于报告的有效生存概率数据。`
: highRiskCount > 0
? `预测第 ${currentYear} 年发现 ${highRiskCount} 条较高及以上风险管段,占有效结果的 ${(highRiskRatio * 100).toFixed(1)}%。建议优先核查重点管段,并结合现场检测确认处置顺序。`
: `预测第 ${currentYear} 年未发现较高及以上风险管段。当前结果仍需结合巡检记录持续跟踪。`;
const recommendations: string[] = [];
if (highRiskCount > 0) {
recommendations.push(
"优先核查重点风险管段的运行状态、历史故障和周边施工情况,必要时安排现场检测。",
);
}
if (mediumRiskCount > 0) {
recommendations.push(
`对其余 ${mediumRiskCount} 条中等风险管段提高监测频率,关注压力波动和风险趋势变化。`,
);
}
if (highRiskCount === 0 && mediumRiskCount === 0 && validCount > 0) {
recommendations.push(
"保持现有巡检和监测频率,在新的在线模拟数据产生后重新评估。",
);
}
recommendations.push(
"本报告为模型预测结果,仅用于辅助决策,不能替代现场检测、专业鉴定和安全处置流程。",
);
return {
totalCount: results.length,
validCount,
invalidCount: results.length - validCount,
averageProbability,
highRiskCount,
highRiskRatio,
distribution,
trend,
priorityPipes,
conclusion,
recommendations,
};
};
@@ -7,9 +7,9 @@ export interface SurvivalFunction {
export interface PredictionResult {
link_id: string;
diameter: number;
velocity: number;
pressure: number;
diameter?: number;
velocity?: number;
pressure?: number;
survival_function: SurvivalFunction;
}
@@ -0,0 +1,123 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import MonitoringPlaceOptimizationPanel, {
getTabIndicatorSx,
getTabIndicatorTransform,
} from "./MonitoringPlaceOptimizationPanel";
import { getSensorPlacementScheme } from "./schemeApi";
import type { SensorPlacementScheme } from "./types";
const mockSchemeEditorRender = jest.fn();
jest.mock("@refinedev/core", () => ({
useNotification: () => ({ open: jest.fn() }),
}));
jest.mock("./OptimizationParameters", () => ({
__esModule: true,
default: () => <div>optimization parameters</div>,
createOptimizationParametersState: () => ({}),
}));
jest.mock("./SchemeQuery", () => ({
__esModule: true,
default: ({ onEdit }: { onEdit: (schemeId: number) => void }) => (
<button onClick={() => onEdit(7)}></button>
),
createMonitoringSchemeQueryState: () => ({}),
}));
jest.mock("./SchemeEditor", () => ({
__esModule: true,
default: ({
scheme,
active,
}: {
scheme: SensorPlacementScheme;
active: boolean;
}) => {
mockSchemeEditorRender(active);
return <div data-testid="scheme-editor">{scheme.scheme_name}</div>;
},
}));
jest.mock("./schemeApi", () => ({
getSensorPlacementScheme: jest.fn(),
}));
jest.mock("@components/olmap/common/PanelEmptyState", () => ({
__esModule: true,
default: () => <div>empty state</div>,
}));
const mockGetSensorPlacementScheme = jest.mocked(getSensorPlacementScheme);
const scheme: SensorPlacementScheme = {
id: 7,
scheme_name: "测试方案",
sensor_number: 1,
min_diameter: 100,
username: "operator",
create_time: "2026-07-30T08:00:00+08:00",
sensor_location: ["J-1"],
sensor_points: [],
can_edit: true,
};
describe("MonitoringPlaceOptimizationPanel", () => {
beforeEach(() => {
mockSchemeEditorRender.mockClear();
});
it("applies equal-width positioning to the rendered tab indicator", () => {
expect(getTabIndicatorTransform(0)).toBe("translateX(0%)");
expect(getTabIndicatorTransform(1)).toBe("translateX(100%)");
expect(getTabIndicatorTransform(2)).toBe("translateX(200%)");
expect(getTabIndicatorSx(1)).toMatchObject({
left: "0 !important",
width: "33.333333% !important",
transform: "translateX(100%)",
});
const { container } = render(<MonitoringPlaceOptimizationPanel />);
const indicator = container.querySelector<HTMLElement>(
".MuiTabs-indicator",
);
expect(indicator).not.toBeNull();
expect(indicator).toHaveStyle({ transform: "translateX(0%)" });
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
expect(indicator).toHaveStyle({ transform: "translateX(200%)" });
});
it("prepares a queried scheme before switching to the result editor", async () => {
let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
mockGetSensorPlacementScheme.mockReturnValue(
new Promise((resolve) => {
resolveScheme = resolve;
}),
);
render(<MonitoringPlaceOptimizationPanel />);
const queryTab = screen.getByRole("tab", { name: /方案查询/ });
const editorTab = screen.getByRole("tab", { name: /结果编辑/ });
fireEvent.click(queryTab);
fireEvent.click(screen.getByRole("button", { name: "打开测试方案" }));
expect(queryTab).toHaveAttribute("aria-selected", "true");
expect(editorTab).toHaveAttribute("aria-selected", "false");
await act(async () => {
resolveScheme(scheme);
await Promise.resolve();
});
expect(editorTab).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("scheme-editor")).toBeVisible();
expect(mockSchemeEditorRender.mock.calls.map(([active]) => active)).toEqual([
false,
true,
]);
});
});
@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
Box,
Drawer,
@@ -9,6 +9,7 @@ import {
Typography,
IconButton,
Tooltip,
CircularProgress,
} from "@mui/material";
import {
ChevronRight,
@@ -16,6 +17,7 @@ import {
Sensors as SensorsIcon,
Analytics as AnalyticsIcon,
Search as SearchIcon,
TableView as TableIcon,
} from "@mui/icons-material";
import OptimizationParameters, {
createOptimizationParametersState,
@@ -25,16 +27,12 @@ import SchemeQuery, {
createMonitoringSchemeQueryState,
type MonitoringSchemeQueryState,
} from "./SchemeQuery";
interface SchemeRecord {
id: number;
schemeName: string;
sensorNumber: number;
minDiameter: number;
user: string;
create_time: string;
sensorLocation?: string[];
}
import SchemeEditor from "./SchemeEditor";
import { getSensorPlacementScheme } from "./schemeApi";
import type { SchemeRecord, SensorPlacementScheme } from "./types";
import { NETWORK_NAME } from "@/config/config";
import { useNotification } from "@refinedev/core";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface TabPanelProps {
children?: React.ReactNode;
@@ -49,13 +47,44 @@ const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
hidden={value !== index}
className="flex-1 overflow-hidden flex flex-col"
>
{value === index && (
<Box className="flex-1 overflow-auto p-4">{children}</Box>
)}
</div>
);
};
const PANEL_MOTION_DURATION_MS = 280;
const PANEL_MOTION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
export const getTabIndicatorTransform = (tabIndex: number) =>
`translateX(${tabIndex * 100}%)`;
export const getTabIndicatorSx = (tabIndex: number) => ({
backgroundColor: "#257DD4",
left: "0 !important",
width: "33.333333% !important",
transform: getTabIndicatorTransform(tabIndex),
transition: `transform ${PANEL_MOTION_DURATION_MS}ms ${PANEL_MOTION_EASING}`,
"@media (prefers-reduced-motion: reduce)": {
transition: "none",
},
});
interface PreparedSchemeEditorProps
extends React.ComponentProps<typeof SchemeEditor> {
onReady?: () => void;
}
const PreparedSchemeEditor: React.FC<PreparedSchemeEditorProps> = ({
onReady,
...props
}) => {
useEffect(() => {
onReady?.();
}, [onReady]);
return <SchemeEditor {...props} />;
};
interface MonitoringPlaceOptimizationPanelProps {
open?: boolean;
onToggle?: () => void;
@@ -66,6 +95,13 @@ const MonitoringPlaceOptimizationPanel: React.FC<
> = ({ open: controlledOpen, onToggle }) => {
const [internalOpen, setInternalOpen] = useState(true);
const [currentTab, setCurrentTab] = useState(0);
const [activeScheme, setActiveScheme] =
useState<SensorPlacementScheme | null>(null);
const [loadingScheme, setLoadingScheme] = useState(false);
const [pendingOpenSchemeId, setPendingOpenSchemeId] = useState<number | null>(
null,
);
const { open: notify } = useNotification();
// 持久化方案查询结果
const [schemes, setSchemes] = useState<SchemeRecord[]>([]);
@@ -89,7 +125,47 @@ const MonitoringPlaceOptimizationPanel: React.FC<
setCurrentTab(newValue);
};
const drawerWidth = 520;
const drawerWidth = currentTab === 1 ? 820 : 520;
const handleSchemeEditorReady = useCallback(() => {
setCurrentTab(1);
setPendingOpenSchemeId(null);
}, []);
const handleOpenScheme = async (schemeId: number) => {
setLoadingScheme(true);
try {
const scheme = await getSensorPlacementScheme(schemeId);
setActiveScheme(scheme);
setLoadingScheme(false);
setPendingOpenSchemeId(scheme.id);
} catch (error) {
const detail =
(error as { response?: { data?: { detail?: string } } }).response?.data
?.detail || "无法读取方案详情";
notify?.({
type: "error",
message: "方案加载失败",
description: detail,
});
setLoadingScheme(false);
}
};
const handleSchemeSaved = (scheme: SensorPlacementScheme) => {
setActiveScheme(scheme);
setSchemes((current) =>
current.map((record) =>
record.id === scheme.id
? {
...record,
sensorNumber: scheme.sensor_number,
sensorLocation: scheme.sensor_location,
}
: record,
),
);
};
return (
<>
@@ -132,16 +208,23 @@ const MonitoringPlaceOptimizationPanel: React.FC<
right: 16,
height: "calc(100vh - 32px)",
maxHeight: "850px",
maxWidth: "calc(100vw - 32px)",
borderRadius: "12px",
boxShadow:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
backdropFilter: "blur(8px)",
opacity: 0.95,
transition: "transform 0.3s ease-in-out, opacity 0.3s ease-in-out",
transition:
`width ${PANEL_MOTION_DURATION_MS}ms ${PANEL_MOTION_EASING}, ` +
"transform 300ms ease-in-out, opacity 300ms ease-in-out",
willChange: "width, transform, opacity",
border: "none",
"&:hover": {
opacity: 1,
},
"@media (prefers-reduced-motion: reduce)": {
transition: "none",
},
},
}}
>
@@ -183,9 +266,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
"& .Mui-selected": {
color: "#257DD4",
},
"& .MuiTabs-indicator": {
backgroundColor: "#257DD4",
},
"& .MuiTabs-indicator": getTabIndicatorSx(currentTab),
}}
>
<Tab
@@ -193,6 +274,11 @@ const MonitoringPlaceOptimizationPanel: React.FC<
iconPosition="start"
label="优化要件"
/>
<Tab
icon={<TableIcon fontSize="small" />}
iconPosition="start"
label="结果编辑"
/>
<Tab
icon={<SearchIcon fontSize="small" />}
iconPosition="start"
@@ -206,19 +292,64 @@ const MonitoringPlaceOptimizationPanel: React.FC<
<OptimizationParameters
state={optimizationState}
onStateChange={setOptimizationState}
onSchemeCreated={(scheme) => {
setActiveScheme(scheme);
setCurrentTab(1);
setSchemes((current) => [
...current,
{
id: scheme.id,
schemeName: scheme.scheme_name,
sensorNumber: scheme.sensor_number,
minDiameter: scheme.min_diameter,
username: scheme.username,
create_time: scheme.create_time,
sensorLocation: scheme.sensor_location,
},
]);
}}
/>
</TabPanel>
<TabPanel value={currentTab} index={1}>
{loadingScheme ? (
<Box
sx={{
minHeight: 320,
display: "grid",
placeItems: "center",
}}
>
<CircularProgress size={30} />
</Box>
) : activeScheme ? (
<PreparedSchemeEditor
scheme={activeScheme}
network={NETWORK_NAME}
active={isOpen && currentTab === 1}
onSaved={handleSchemeSaved}
onReady={
pendingOpenSchemeId === activeScheme.id
? handleSchemeEditorReady
: undefined
}
/>
) : (
<PanelEmptyState
icon={<TableIcon />}
title="暂无可编辑结果"
description="请先在“优化要件”中创建方案,或在“方案查询”中打开已有方案。"
/>
)}
</TabPanel>
<TabPanel value={currentTab} index={2}>
<SchemeQuery
schemes={schemes}
onSchemesChange={setSchemes}
state={queryState}
onStateChange={setQueryState}
onLocate={(id) => {
console.log("定位方案:", id);
// TODO: 在地图上定位
}}
onEdit={handleOpenScheme}
/>
</TabPanel>
</Box>
@@ -7,22 +7,15 @@ import {
Button,
Typography,
MenuItem,
Stack,
} from "@mui/material";
import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { useNotification } from "@refinedev/core";
import { useGetIdentity } from "@refinedev/core";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
type IUser = {
id: string;
name?: string;
};
import { optimizeSensorPlacement } from "./schemeApi";
import type { SensorPlacementScheme } from "./types";
export interface OptimizationParametersState {
sensorType: string;
method: string;
sensorCount: number;
minDiameter: number;
@@ -31,7 +24,6 @@ export interface OptimizationParametersState {
export const createOptimizationParametersState =
(): OptimizationParametersState => ({
sensorType: "pressure",
method: "kmeans",
sensorCount: 5,
minDiameter: 5,
@@ -41,45 +33,32 @@ export const createOptimizationParametersState =
interface OptimizationParametersProps {
state?: OptimizationParametersState;
onStateChange?: (state: OptimizationParametersState) => void;
onSchemeCreated?: (scheme: SensorPlacementScheme) => void;
}
const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
state,
onStateChange,
onSchemeCreated,
}) => {
const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>();
const [parametersState, , setFormField] = useControllableObjectState(
state,
onStateChange,
createOptimizationParametersState(),
);
const { sensorType, method, sensorCount, minDiameter, schemeName } =
const { method, sensorCount, minDiameter, schemeName } =
parametersState;
const [network] = useState<string>(NETWORK_NAME);
const network = NETWORK_NAME;
const [analyzing, setAnalyzing] = useState<boolean>(false);
// 传感器类型选项
const sensorTypeOptions = [
{ value: "pressure", label: "压力" },
{ value: "flow", label: "流量" },
];
// 方法选项
const methodOptions = [
{ value: "kmeans", label: "聚类分析" },
{ value: "sensitivity", label: "灵敏度分析" },
];
// 获取传感器类型的中文标签
const getSensorTypeLabel = (value: string) => {
return (
sensorTypeOptions.find((option) => option.value === value)?.label || value
);
};
// 创建方案
const handleCreateScheme = async () => {
// 验证输入
@@ -109,48 +88,21 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
setAnalyzing(true);
if (!user || !user.id) {
open?.({
type: "error",
message: "用户信息无效",
});
return;
}
try {
// 发送优化请求
const response = await api.post(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
null,
{
params: {
network: network,
const created = await optimizeSensorPlacement({
scheme_name: schemeName,
sensor_type: sensorType,
method: method,
sensor_type: "pressure",
method: method as "sensitivity" | "kmeans",
sensor_count: sensorCount,
min_diameter: minDiameter,
user_id: user.id,
user_name: user.name,
},
}
);
console.log("响应数据:", response.data); // 添加日志以便调试
// 兼容后端返回字符串 "success" 或对象 { success: true }
if (response.data.success === true || response.data === "success") {
});
open?.({
type: "success",
message: "方案创建成功",
description: `方案 "${schemeName}" 已完成优化分析`,
});
// 重置方案名称
onSchemeCreated?.(created);
setFormField("schemeName", "Fangan" + new Date().getTime());
} else {
throw new Error(response.data?.message || "创建失败");
}
} catch (error: any) {
console.error("创建方案失败:", error);
open?.({
@@ -175,11 +127,10 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
</Typography>
<TextField
select
fullWidth
size="small"
value={sensorType}
onChange={(e) => setFormField("sensorType", e.target.value)}
value="压力"
slotProps={{ input: { readOnly: true } }}
sx={{
"& .MuiOutlinedInput-root": {
"&:hover fieldset": {
@@ -190,13 +141,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
},
},
}}
>
{sensorTypeOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
/>
</Box>
{/* 方法选择 */}
@@ -270,7 +215,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
variant="subtitle2"
className="mb-2 font-semibold text-gray-700"
>
{getSensorTypeLabel(sensorType)}
</Typography>
<TextField
fullWidth
@@ -0,0 +1,624 @@
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import {
Download as DownloadIcon,
Print as PrintIcon,
} from "@mui/icons-material";
import OlMap from "ol/Map";
import View from "ol/View";
import VectorTileLayer from "ol/layer/VectorTile";
import type VectorTileSource from "ol/source/VectorTile";
import type { StyleLike } from "ol/style/Style";
import {
TileFeatureIndex,
clipLineStringPartsToExtent,
lineStringFromFlatCoordinates,
} from "@components/olmap/core/tileFeatureIndex";
import {
A3_LANDSCAPE_HEIGHT,
A3_LANDSCAPE_WIDTH,
canvasToPngBlob,
getPaddedDrawingExtent,
renderEngineeringDrawing,
} from "./engineeringDrawing";
import type {
NetworkDrawingData,
SensorPlacementScheme,
SensorPointRow,
} from "./types";
interface SchemeDrawingDialogProps {
open: boolean;
onClose: () => void;
scheme: SensorPlacementScheme;
rows: SensorPointRow[];
network: string;
map: OlMap | null;
dirty: boolean;
}
const DRAWING_MAP_RENDER_TIMEOUT_MS = 10_000;
const DRAWING_MAP_SIZE: [number, number] = [
A3_LANDSCAPE_WIDTH - 300,
A3_LANDSCAPE_HEIGHT - 650,
];
type DrawingPipeLayer = {
getSource?: () => VectorTileSource | null;
getExtent?: () => number[] | undefined;
getStyle?: () => StyleLike | null | undefined;
};
const getDrawingPipeLayer = (map: OlMap) =>
map.getAllLayers().find((layer) => layer.get("value") === "pipes") as
DrawingPipeLayer | undefined;
export const fitMapToFullNetwork = (
map: OlMap,
): [number, number, number, number] => {
const extent = getDrawingPipeLayer(map)?.getExtent?.();
if (
!extent ||
extent.length !== 4 ||
!extent.every(Number.isFinite) ||
extent[0] >= extent[2] ||
extent[1] >= extent[3]
) {
throw new Error("主地图中未配置有效的管网完整范围");
}
const size = map.getSize();
if (!size) throw new Error("地图尺寸不可用");
const networkExtent: [number, number, number, number] = [
extent[0],
extent[1],
extent[2],
extent[3],
];
const view = map.getView();
const drawingExtent = getPaddedDrawingExtent(
networkExtent,
size[0] / size[1],
);
view.cancelAnimations();
view.fit(drawingExtent, {
padding: [0, 0, 0, 0],
size,
});
return networkExtent;
};
interface DrawingMapSession {
map: OlMap;
extent: [number, number, number, number];
dispose: () => void;
}
const createDrawingMapSession = (mainMap: OlMap): DrawingMapSession => {
const mainPipeLayer = getDrawingPipeLayer(mainMap);
const source = mainPipeLayer?.getSource?.();
const extent = mainPipeLayer?.getExtent?.();
if (!source) throw new Error("主地图中未找到管网图层");
if (
!extent ||
extent.length !== 4 ||
!extent.every(Number.isFinite) ||
extent[0] >= extent[2] ||
extent[1] >= extent[3]
) {
throw new Error("主地图中未配置有效的管网完整范围");
}
const target = document.createElement("div");
target.setAttribute("aria-hidden", "true");
Object.assign(target.style, {
position: "fixed",
left: "-100000px",
top: "0",
width: `${DRAWING_MAP_SIZE[0]}px`,
height: `${DRAWING_MAP_SIZE[1]}px`,
pointerEvents: "none",
});
document.body.appendChild(target);
let drawingMap: OlMap | null = null;
try {
const networkExtent: [number, number, number, number] = [
extent[0],
extent[1],
extent[2],
extent[3],
];
const pipeLayer = new VectorTileLayer({
source,
style: mainPipeLayer?.getStyle?.(),
});
pipeLayer.set("value", "pipes");
pipeLayer.setExtent(networkExtent);
drawingMap = new OlMap({
target,
pixelRatio: 1,
view: new View({
projection: mainMap.getView().getProjection(),
}),
layers: [pipeLayer],
controls: [],
interactions: [],
});
drawingMap.setSize(DRAWING_MAP_SIZE);
fitMapToFullNetwork(drawingMap);
let disposed = false;
return {
map: drawingMap,
extent: networkExtent,
dispose: () => {
if (disposed) return;
disposed = true;
drawingMap?.setTarget(undefined);
drawingMap?.getLayers().clear();
target.remove();
},
};
} catch (reason) {
drawingMap?.setTarget(undefined);
target.remove();
throw reason;
}
};
const waitForDrawingMapRender = (map: OlMap) =>
new Promise<void>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout>;
const cleanup = () => {
clearTimeout(timeout);
map.un("moveend", requestRender);
map.un("rendercomplete", handleRenderComplete);
};
const handleRenderComplete = () => {
cleanup();
resolve();
};
const requestRender = () => {
map.once("rendercomplete", handleRenderComplete);
map.render();
};
timeout = setTimeout(() => {
cleanup();
reject(new Error("工程图管网加载超时,请稍后重试"));
}, DRAWING_MAP_RENDER_TIMEOUT_MS);
if (map.getView().getAnimating()) {
map.once("moveend", requestRender);
} else {
requestRender();
}
});
const readNetworkDataFromMap = (
map: OlMap,
extent: [number, number, number, number],
): NetworkDrawingData => {
const pipeLayer = getDrawingPipeLayer(map);
const source = pipeLayer?.getSource?.();
if (!source) throw new Error("工程图中未找到管网图层");
const index = new TileFeatureIndex("engineering-drawing-pipes", source);
index.scanLoadedTiles();
const snapshot = index.getSnapshot(map, map.getView().getZoom() ?? 0);
const paths = snapshot.instances.flatMap((instance) => {
if (!instance.geometryType.includes("LineString")) return [];
return clipLineStringPartsToExtent(
lineStringFromFlatCoordinates(instance.flatCoordinates, instance.stride),
instance.tileExtent,
);
});
if (!paths.length) throw new Error("工程图管网尚未加载完成,请稍后重试");
const nodes: string[] = [];
const links: string[] = [];
const nodeIds = new globalThis.Map<string, string>();
const getNodeId = ([x, y]: number[]) => {
const key = `${x}:${y}`;
const existing = nodeIds.get(key);
if (existing) return existing;
const nodeId = `drawing_node_${nodeIds.size + 1}`;
nodeIds.set(key, nodeId);
nodes.push(`${nodeId}:junction:${x}:${y}`);
return nodeId;
};
paths.forEach((path) => {
for (let index = 1; index < path.length; index += 1) {
const startId = getNodeId(path[index - 1]);
const endId = getNodeId(path[index]);
links.push(`drawing_link_${links.length + 1}:pipe:${startId}:${endId}`);
}
});
return { nodes, links, extent };
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
(character) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[character] ?? character,
);
export const DrawingPreview = ({
src,
label,
}: {
src: string;
label: string;
}) => (
<Box
role="img"
aria-label={label}
sx={{
width: "100%",
height: "100%",
backgroundImage: `url("${src}")`,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "contain",
}}
/>
);
const SchemeDrawingDialog: React.FC<SchemeDrawingDialogProps> = ({
open,
onClose,
scheme,
rows,
network,
map,
dirty,
}) => {
const [networkData, setNetworkData] = useState<NetworkDrawingData | null>(
null,
);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const replacePreviewUrl = useCallback((nextUrl: string | null) => {
const previousUrl = previewUrlRef.current;
previewUrlRef.current = nextUrl;
setPreviewUrl(nextUrl);
if (previousUrl && previousUrl !== nextUrl) {
URL.revokeObjectURL(previousUrl);
}
}, []);
const filename = useMemo(
() => `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_布置图.png`,
[dirty, scheme.scheme_name],
);
useEffect(
() => () => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
},
[],
);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
setNetworkData(null);
replacePreviewUrl(null);
if (!map) {
setError("主地图尚未初始化,请稍后重试");
setLoading(false);
return;
}
let drawingSession: DrawingMapSession;
try {
drawingSession = createDrawingMapSession(map);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "无法适配完整管网范围",
);
setLoading(false);
return;
}
waitForDrawingMapRender(drawingSession.map)
.then(() =>
readNetworkDataFromMap(
drawingSession.map,
drawingSession.extent,
),
)
.then((data) => {
if (!cancelled) {
setNetworkData(data);
}
})
.catch((reason) => {
if (!cancelled) {
setError(
reason instanceof Error ? reason.message : "无法读取工程图管网数据",
);
}
})
.finally(() => {
drawingSession.dispose();
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
drawingSession.dispose();
};
}, [map, network, open, replacePreviewUrl]);
useEffect(() => {
if (!open || !networkData) return;
let cancelled = false;
setLoading(true);
setError(null);
renderEngineeringDrawing({
scheme,
rows,
network,
networkData,
dirty,
width: 1600,
})
.then(canvasToPngBlob)
.then((blob) => {
if (cancelled) return;
replacePreviewUrl(URL.createObjectURL(blob));
})
.catch((reason) => {
if (!cancelled)
setError(reason instanceof Error ? reason.message : "出图失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [
dirty,
network,
networkData,
open,
rows,
scheme,
replacePreviewUrl,
]);
const createFullResolutionBlob = async () => {
if (!networkData) throw new Error("管网数据尚未加载");
const canvas = await renderEngineeringDrawing({
scheme,
rows,
network,
networkData,
dirty,
width: A3_LANDSCAPE_WIDTH,
});
return canvasToPngBlob(canvas);
};
const handleDownload = async () => {
setLoading(true);
setError(null);
try {
downloadBlob(await createFullResolutionBlob(), filename);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "PNG 下载失败");
} finally {
setLoading(false);
}
};
const handlePrint = async () => {
const printWindow = window.open("", "_blank");
if (!printWindow) {
setError("浏览器阻止了打印窗口,请允许本站打开新窗口");
return;
}
printWindow.opener = null;
printWindow.document.write(
'<!doctype html><html lang="zh-CN"><body style="font-family: sans-serif; padding: 24px;">正在生成 A3 工程图...</body></html>',
);
printWindow.document.close();
setLoading(true);
setError(null);
try {
const blob = await createFullResolutionBlob();
const url = URL.createObjectURL(blob);
const safeSchemeName = escapeHtml(scheme.scheme_name);
printWindow.document.open();
printWindow.document.write(`
<!doctype html>
<html lang="zh-CN">
<head>
<title>${safeSchemeName} </title>
<style>
@page { size: A3 landscape; margin: 0; }
html, body { margin: 0; width: 100%; height: 100%; }
img { display: block; width: 100%; height: 100%; object-fit: contain; }
</style>
</head>
<body><img src="${url}" alt="${safeSchemeName} 压力监测点布置图"></body>
</html>
`);
printWindow.document.close();
const image = printWindow.document.querySelector("img");
image?.addEventListener("load", () => {
printWindow.focus();
printWindow.print();
URL.revokeObjectURL(url);
});
} catch (reason) {
printWindow.close();
setError(reason instanceof Error ? reason.message : "打印失败");
} finally {
setLoading(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="lg"
aria-labelledby="scheme-drawing-title"
PaperProps={{ sx: { borderRadius: 3, overflow: "hidden" } }}
>
<DialogTitle id="scheme-drawing-title" sx={{ pb: 1 }}>
</DialogTitle>
<DialogContent sx={{ bgcolor: "#eef2f5", p: 0 }}>
<Box data-testid="drawing-content-spacing" sx={{ px: 3, pt: 1.5, pb: 2 }}>
<Box
data-testid="drawing-format-summary"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
columnGap: 2,
rowGap: 0.75,
mb: 1.5,
px: 1.5,
py: 1,
minHeight: 44,
bgcolor: "rgba(37, 125, 212, 0.06)",
border: "1px solid rgba(37, 125, 212, 0.14)",
borderRadius: 2,
flexWrap: "wrap",
}}
>
<Typography variant="body2" sx={{ lineHeight: 1.5 }}>
<Box component="span" sx={{ color: "text.secondary", mr: 1 }}>
</Box>
<Box component="span" sx={{ color: "text.primary", fontWeight: 700 }}>
线稿
</Box>
</Typography>
<Stack
direction="row"
spacing={1}
divider={
<Box
aria-hidden="true"
sx={{ width: 1, height: 14, bgcolor: "rgba(15, 23, 42, 0.14)" }}
/>
}
sx={{
color: "text.secondary",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
<Typography variant="caption">A3 </Typography>
<Typography variant="caption">300 DPI</Typography>
<Typography variant="caption">4961 × 3508</Typography>
</Stack>
</Box>
{error && (
<Alert severity="warning" sx={{ mb: 1.5 }}>
{error}
</Alert>
)}
<Box
sx={{
position: "relative",
aspectRatio: `${4961} / ${3508}`,
bgcolor: "#fff",
boxShadow: "0 8px 28px rgba(15, 23, 42, 0.14)",
overflow: "hidden",
}}
>
{previewUrl && (
<DrawingPreview
src={previewUrl}
label={`${scheme.scheme_name} 压力监测点布置图预览`}
/>
)}
{loading && (
<Box
sx={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
bgcolor: "rgba(255,255,255,0.72)",
}}
>
<CircularProgress size={30} />
</Box>
)}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose}></Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleDownload}
disabled={loading || !networkData}
>
PNG
</Button>
<Button
variant="contained"
startIcon={<PrintIcon />}
onClick={handlePrint}
disabled={loading || !networkData}
>
</Button>
</DialogActions>
</Dialog>
);
};
export default SchemeDrawingDialog;
@@ -0,0 +1,210 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import SchemeEditor from "./SchemeEditor";
import type { SensorPlacementScheme } from "./types";
const mockOpen = jest.fn();
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
const mockMap = {
addLayer: jest.fn(),
removeLayer: jest.fn(),
on: jest.fn((eventName: string, handler: (event: unknown) => void) => {
if (eventName === "singleclick") {
mockSingleClickHandler = handler;
}
}),
un: jest.fn(),
forEachFeatureAtPixel: jest.fn(() => undefined),
getView: jest.fn(() => ({
animate: jest.fn(),
getZoom: jest.fn(() => 16),
getResolution: jest.fn(() => 1),
})),
};
jest.mock("@refinedev/core", () => ({
useNotification: () => ({ open: mockOpen }),
}));
jest.mock("@components/olmap/core/MapComponent", () => ({
useMap: () => mockMap,
}));
jest.mock("@mui/x-data-grid", () => ({
DataGrid: (props: {
density?: string;
initialState?: { density?: string };
rowHeight?: number;
columnHeaderHeight?: number;
}) => (
<div
data-testid="scheme-grid"
data-density={props.density ?? "uncontrolled"}
data-initial-density={props.initialState?.density ?? ""}
data-row-height={props.rowHeight ?? "automatic"}
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
/>
),
GridToolbar: () => null,
}));
jest.mock("@mui/x-data-grid/locales", () => ({
zhCN: {
components: {
MuiDataGrid: {
defaultProps: {
localeText: {},
},
},
},
},
}));
jest.mock("ol/Feature", () => ({
__esModule: true,
default: class {
private values: Record<string, unknown>;
constructor(values: Record<string, unknown>) {
this.values = values;
}
get(key: string) {
return this.values[key];
}
setId() {}
},
}));
jest.mock("ol/geom/Point", () => ({
__esModule: true,
default: class {
constructor(_coordinates: number[]) {}
},
}));
jest.mock("ol/source/Vector", () => ({
__esModule: true,
default: class {
clear() {}
addFeature() {}
},
}));
jest.mock("ol/layer/Vector", () => ({
__esModule: true,
default: class {
private source: { clear: () => void; addFeature: () => void };
constructor(options: {
source: { clear: () => void; addFeature: () => void };
}) {
this.source = options.source;
}
getSource() {
return this.source;
}
setVisible() {}
},
}));
jest.mock("ol/style", () => ({
Circle: class {},
Fill: class {},
Stroke: class {},
Style: class {},
Text: class {},
}));
jest.mock("ol/proj", () => ({
fromLonLat: (coordinates: number[]) => coordinates,
toLonLat: (coordinates: number[]) => coordinates,
}));
jest.mock("@/utils/mapQueryService", () => ({
handleMapClickSelectFeatures: jest.fn(),
}));
jest.mock("./SchemeDrawingDialog", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("./schemeApi", () => ({
exportSensorPlacementExcel: jest.fn(),
overwriteSensorPlacementScheme: jest.fn(),
}));
const scheme: SensorPlacementScheme = {
id: 1,
scheme_name: "测试方案",
sensor_number: 1,
min_diameter: 300,
username: "operator",
create_time: "2026-07-30T08:00:00+08:00",
sensor_location: ["J1"],
sensor_points: [
{
node_id: "J1",
project_x: 10,
project_y: 20,
map_x: 13500010,
map_y: 3600020,
longitude: 121,
latitude: 31,
elevation: 5,
},
],
can_edit: true,
};
describe("SchemeEditor notifications", () => {
beforeEach(() => {
jest.clearAllMocks();
mockSingleClickHandler = undefined;
});
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
render(<SchemeEditor scheme={scheme} network="fengyang" />);
fireEvent.click(screen.getByRole("button", { name: "替换" }));
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
act(() => {
mockSingleClickHandler?.({
pixel: [0, 0],
coordinate: [13500000, 3600000],
stopPropagation: jest.fn(),
});
});
expect(mockOpen).toHaveBeenCalledWith({
type: "error",
message: "请先点击要替换的监测点",
});
});
it("lets the Data Grid density selector control row sizing", () => {
render(<SchemeEditor scheme={scheme} network="fengyang" />);
expect(screen.getByTestId("scheme-grid")).toHaveAttribute(
"data-density",
"uncontrolled",
);
expect(screen.getByTestId("scheme-grid")).toHaveAttribute(
"data-initial-density",
"compact",
);
expect(screen.getByTestId("scheme-grid")).toHaveAttribute(
"data-row-height",
"automatic",
);
expect(screen.getByTestId("scheme-grid")).toHaveAttribute(
"data-column-header-height",
"automatic",
);
});
});
@@ -0,0 +1,815 @@
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import {
AddLocationAlt as AddIcon,
DeleteOutline as DeleteIcon,
Download as DownloadIcon,
EditLocationAlt as ReplaceIcon,
LocationOn as LocateIcon,
Map as MapIcon,
Redo as RedoIcon,
RestartAlt as ResetIcon,
Save as SaveIcon,
Undo as UndoIcon,
} from "@mui/icons-material";
import {
DataGrid,
GridToolbar,
type GridColDef,
type GridRowSelectionModel,
} from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales";
import Feature, { type FeatureLike } from "ol/Feature";
import Point from "ol/geom/Point";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Circle, Fill, Stroke, Style, Text } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config } from "@/config/config";
import { useMap } from "@components/olmap/core/MapComponent";
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
import {
addSensorPoint,
createSchemeEditorState,
deleteSensorPoint,
isSchemeDirty,
redoSchemeEdit,
replaceSensorPoint,
resetSchemeEdit,
summarizeChanges,
toSensorPointRows,
undoSchemeEdit,
} from "./schemeEditor";
import {
exportSensorPlacementExcel,
overwriteSensorPlacementScheme,
} from "./schemeApi";
import SchemeDrawingDialog from "./SchemeDrawingDialog";
import type {
AdjustmentStatus,
SensorPlacementScheme,
SensorPoint,
SensorPointRow,
} from "./types";
type EditMode = "idle" | "add" | "replace" | "delete";
interface SchemeEditorProps {
scheme: SensorPlacementScheme;
network: string;
active?: boolean;
onSaved?: (scheme: SensorPlacementScheme) => void;
}
const STATUS_LABELS: Record<AdjustmentStatus, string> = {
current: "当前方案",
original: "原方案",
added: "新增",
replaced: "替换",
};
const STATUS_COLORS: Record<
AdjustmentStatus,
"default" | "success" | "warning" | "info"
> = {
current: "default",
original: "info",
added: "success",
replaced: "warning",
};
const markerStyle = (feature: FeatureLike) => {
const status = feature.get("adjustment_status") as AdjustmentStatus;
const selected = Boolean(feature.get("selected"));
const color =
status === "added" ? "#16865b" : status === "replaced" ? "#d06b16" : "#c9252d";
return new Style({
image: new Circle({
radius: selected ? 11 : 9,
fill: new Fill({ color: "#ffffff" }),
stroke: new Stroke({ color, width: selected ? 4 : 3 }),
}),
text: new Text({
text: String(feature.get("sequence") ?? ""),
font: '700 11px -apple-system, "PingFang SC", sans-serif',
fill: new Fill({ color }),
offsetY: 0.5,
}),
zIndex: selected ? 20 : 10,
});
};
const errorDescription = (error: unknown) => {
const candidate = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return candidate.response?.data?.detail || candidate.message || "请求失败";
};
const SchemeEditor: React.FC<SchemeEditorProps> = ({
scheme,
network,
active = true,
onSaved,
}) => {
const map = useMap();
const { open } = useNotification();
const [editor, setEditor] = useState(() => createSchemeEditorState(scheme));
const [mode, setMode] = useState<EditMode>("idle");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [replaceSourceId, setReplaceSourceId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [drawingOpen, setDrawingOpen] = useState(false);
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
useEffect(() => {
setEditor(createSchemeEditorState(scheme));
setMode("idle");
setReplaceSourceId(null);
setSelectedNodeId(null);
}, [scheme]);
const rows = useMemo(() => toSensorPointRows(editor), [editor]);
const dirty = isSchemeDirty(editor);
const changes = summarizeChanges(editor);
useEffect(() => {
if (!map) return;
const layer = new VectorLayer({
source: new VectorSource(),
style: markerStyle,
properties: {
name: "监测点方案编辑",
value: "sensor_scheme_editor",
queryable: false,
},
zIndex: 120,
});
markerLayerRef.current = layer;
map.addLayer(layer);
return () => {
markerLayerRef.current = null;
map.removeLayer(layer);
};
}, [map]);
useEffect(() => {
markerLayerRef.current?.setVisible(active);
}, [active]);
useEffect(() => {
const source = markerLayerRef.current?.getSource();
if (!source) return;
source.clear();
rows.forEach((row) => {
const feature = new Feature({
geometry: new Point([row.map_x, row.map_y]),
node_id: row.node_id,
sequence: row.sequence,
adjustment_status: row.adjustment_status,
selected: row.node_id === selectedNodeId,
});
feature.setId(`sensor-scheme-${row.node_id}`);
source.addFeature(feature);
});
}, [rows, selectedNodeId]);
const resolveJunction = useCallback(
async (event: any): Promise<SensorPoint | null> => {
if (!map) return null;
const feature = await handleMapClickSelectFeatures(event, map);
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
if (!nodeId) return null;
const featureGeometry = feature?.getGeometry();
const featureCoordinate =
featureGeometry instanceof Point
? featureGeometry.getCoordinates()
: event.coordinate;
const projectedFeatureCoordinate =
Math.abs(featureCoordinate[0]) <= 180 &&
Math.abs(featureCoordinate[1]) <= 90
? fromLonLat(featureCoordinate)
: featureCoordinate;
const resolution = map.getView().getResolution() ?? 1;
const isAlignedWithMap =
Math.hypot(
projectedFeatureCoordinate[0] - event.coordinate[0],
projectedFeatureCoordinate[1] - event.coordinate[1],
) <=
resolution * 20;
const [mapX, mapY] = isAlignedWithMap
? projectedFeatureCoordinate
: event.coordinate;
try {
const response = await api.get<{
id: string;
x: number;
y: number;
elevation: number;
}>(`${config.BACKEND_URL}/api/v1/junctions/properties`, {
params: { junction: nodeId },
});
if (!response.data?.id) return null;
const [longitude, latitude] = toLonLat([mapX, mapY]);
return {
node_id: String(response.data.id),
project_x: Number(response.data.x),
project_y: Number(response.data.y),
map_x: mapX,
map_y: mapY,
longitude,
latitude,
elevation: Number(response.data.elevation),
};
} catch {
return null;
}
},
[map],
);
useEffect(() => {
if (!active || !map || mode === "idle" || !scheme.can_edit) return;
let handling = false;
const processClick = async (event: any) => {
if (handling) return;
event.stopPropagation?.();
handling = true;
try {
const markerLayer = markerLayerRef.current;
const marker = markerLayer
? map.forEachFeatureAtPixel(
event.pixel,
(feature) => feature as Feature,
{
hitTolerance: 8,
layerFilter: (layer) => layer === markerLayer,
},
)
: undefined;
const markerNodeId = marker ? String(marker.get("node_id")) : null;
if (mode === "delete") {
if (!markerNodeId) {
open?.({ type: "error", message: "请点击要删除的监测点" });
return;
}
setEditor((current) => deleteSensorPoint(current, markerNodeId));
setSelectedNodeId(null);
return;
}
if (mode === "replace" && !replaceSourceId) {
if (!markerNodeId) {
open?.({ type: "error", message: "请先点击要替换的监测点" });
return;
}
setReplaceSourceId(markerNodeId);
setSelectedNodeId(markerNodeId);
return;
}
const candidate = await resolveJunction(event);
if (!candidate) {
open?.({ type: "error", message: "请选择有效的管网节点" });
return;
}
if (mode === "add") {
const exists = editor.points.some(
(point) => point.node_id === candidate.node_id,
);
if (exists) {
open?.({ type: "error", message: "该节点已在当前方案中" });
return;
}
setEditor((current) => addSensorPoint(current, candidate));
setSelectedNodeId(candidate.node_id);
} else if (mode === "replace" && replaceSourceId) {
const duplicate = editor.points.some(
(point) =>
point.node_id === candidate.node_id &&
point.node_id !== replaceSourceId,
);
if (duplicate) {
open?.({ type: "error", message: "目标节点已在当前方案中" });
return;
}
setEditor((current) =>
replaceSensorPoint(current, replaceSourceId, candidate),
);
setSelectedNodeId(candidate.node_id);
setReplaceSourceId(null);
setMode("idle");
}
} finally {
handling = false;
}
};
const handleClick = (event: any) => {
void processClick(event);
};
map.on("singleclick", handleClick);
return () => {
map.un("singleclick", handleClick);
};
}, [
editor.points,
active,
map,
mode,
network,
open,
replaceSourceId,
resolveJunction,
scheme.can_edit,
]);
const activateMode = useCallback(
(nextMode: EditMode, sourceNodeId?: string) => {
setMode((current) =>
current === nextMode && !sourceNodeId ? "idle" : nextMode,
);
setReplaceSourceId(sourceNodeId ?? null);
if (sourceNodeId) setSelectedNodeId(sourceNodeId);
},
[],
);
const handleDelete = useCallback((nodeId: string) => {
setEditor((current) => deleteSensorPoint(current, nodeId));
if (selectedNodeId === nodeId) setSelectedNodeId(null);
}, [selectedNodeId]);
const locateRow = useCallback((row: SensorPointRow) => {
setSelectedNodeId(row.node_id);
if (!map) return;
map.getView().animate({
center: [row.map_x, row.map_y],
zoom: Math.max(map.getView().getZoom() ?? 16, 18),
duration: 300,
});
}, [map]);
const handleSave = async () => {
setSaving(true);
try {
const updated = await overwriteSensorPlacementScheme(
scheme.id,
editor.baseline.map((point) => point.node_id),
editor.points.map((point) => point.node_id),
);
setEditor(createSchemeEditorState(updated));
onSaved?.(updated);
setSaveDialogOpen(false);
open?.({
type: "success",
message: "方案已覆盖保存",
description: `当前共 ${updated.sensor_number} 个监测点`,
});
} catch (error) {
open?.({
type: "error",
message:
(error as { response?: { status?: number } }).response?.status === 409
? "方案已发生变化"
: "方案保存失败",
description: errorDescription(error),
});
} finally {
setSaving(false);
}
};
const handleExport = async () => {
setExporting(true);
try {
const blob = await exportSensorPlacementExcel(
scheme.id,
editor.points.map((point) => point.node_id),
editor.statuses,
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_监测点清单.xlsx`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
open?.({
type: "error",
message: "Excel 导出失败",
description: errorDescription(error),
});
} finally {
setExporting(false);
}
};
const columns = useMemo<GridColDef<SensorPointRow>[]>(
() => [
{
field: "sequence",
headerName: "序号",
width: 64,
align: "center",
headerAlign: "center",
sortable: false,
},
{ field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 },
{
field: "longitude",
headerName: "经度",
minWidth: 125,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(6),
},
{
field: "latitude",
headerName: "纬度",
minWidth: 125,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(6),
},
{
field: "project_x",
headerName: "工程 X",
minWidth: 130,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "project_y",
headerName: "工程 Y",
minWidth: 130,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "map_x",
headerName: "地图 X",
minWidth: 130,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "map_y",
headerName: "地图 Y",
minWidth: 130,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "elevation",
headerName: "高程",
width: 92,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "adjustment_status",
headerName: "调整状态",
width: 108,
renderCell: ({ value }) => {
const status = value as AdjustmentStatus;
return (
<Chip
label={STATUS_LABELS[status]}
color={STATUS_COLORS[status]}
variant="outlined"
size="small"
/>
);
},
},
{
field: "actions",
headerName: "操作",
width: scheme.can_edit ? 138 : 54,
sortable: false,
filterable: false,
renderCell: ({ row }) => (
<Stack direction="row" spacing={0.25} sx={{ alignItems: "center" }}>
<Tooltip title="地图定位">
<IconButton
aria-label={`定位节点 ${row.node_id}`}
size="small"
onClick={() => locateRow(row)}
sx={{ width: 40, height: 40 }}
>
<LocateIcon fontSize="small" />
</IconButton>
</Tooltip>
{scheme.can_edit && (
<>
<Tooltip title="替换节点">
<IconButton
aria-label={`替换节点 ${row.node_id}`}
size="small"
onClick={() => activateMode("replace", row.node_id)}
sx={{ width: 40, height: 40 }}
>
<ReplaceIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="删除节点">
<span>
<IconButton
aria-label={`删除节点 ${row.node_id}`}
size="small"
onClick={() => handleDelete(row.node_id)}
disabled={rows.length <= 1}
sx={{ width: 40, height: 40 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</>
)}
</Stack>
),
},
],
[activateMode, handleDelete, locateRow, rows.length, scheme.can_edit],
);
const instruction =
mode === "add"
? "点击地图中的管网节点添加监测点"
: mode === "replace"
? replaceSourceId
? `已选择 ${replaceSourceId},请点击新的管网节点`
: "请先点击要替换的监测点"
: mode === "delete"
? "点击地图中的监测点删除"
: null;
return (
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }} noWrap>
{scheme.scheme_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{scheme.username} · {rows.length}
{dirty ? " · 未保存草稿" : ""}
</Typography>
</Box>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleExport}
disabled={exporting}
>
{exporting ? "导出中" : "Excel"}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<MapIcon />}
onClick={() => setDrawingOpen(true)}
>
</Button>
{scheme.can_edit && (
<Button
size="small"
variant="contained"
startIcon={<SaveIcon />}
disabled={!dirty}
onClick={() => setSaveDialogOpen(true)}
>
</Button>
)}
</Stack>
</Box>
{!scheme.can_edit && (
<Alert severity="info"></Alert>
)}
{scheme.can_edit && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
flexWrap: "wrap",
p: 1,
bgcolor: "#f3f6f8",
borderRadius: 2,
}}
>
<Button
size="small"
variant={mode === "add" ? "contained" : "text"}
startIcon={<AddIcon />}
onClick={() => activateMode("add")}
>
</Button>
<Button
size="small"
variant={mode === "replace" ? "contained" : "text"}
startIcon={<ReplaceIcon />}
onClick={() => activateMode("replace")}
>
</Button>
<Button
size="small"
color="error"
variant={mode === "delete" ? "contained" : "text"}
startIcon={<DeleteIcon />}
onClick={() => activateMode("delete")}
>
</Button>
<Box sx={{ flex: 1 }} />
<Tooltip title="撤销上一步">
<span>
<IconButton
aria-label="撤销上一步"
size="small"
disabled={!editor.history.length}
onClick={() => setEditor((current) => undoSchemeEdit(current))}
sx={{ width: 40, height: 40 }}
>
<UndoIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="重做下一步">
<span>
<IconButton
aria-label="重做下一步"
size="small"
disabled={!editor.future.length}
onClick={() => setEditor((current) => redoSchemeEdit(current))}
sx={{ width: 40, height: 40 }}
>
<RedoIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="重置为服务器版本">
<span>
<IconButton
aria-label="重置为服务器版本"
size="small"
disabled={!dirty}
onClick={() => setEditor((current) => resetSchemeEdit(current))}
sx={{ width: 40, height: 40 }}
>
<ResetIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
)}
{instruction && (
<Alert
severity="info"
action={
<Button
color="inherit"
size="small"
onClick={() => {
setMode("idle");
setReplaceSourceId(null);
}}
>
</Button>
}
>
{instruction}
</Alert>
)}
<Box sx={{ flex: 1, minHeight: 320 }}>
<DataGrid
rows={rows}
columns={columns}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
getRowId={(row) => row.node_id}
initialState={{ density: "compact" }}
disableRowSelectionOnClick={false}
rowSelectionModel={selectedNodeId ? [selectedNodeId] : []}
onRowSelectionModelChange={(selection: GridRowSelectionModel) =>
setSelectedNodeId(selection.length ? String(selection[0]) : null)
}
onRowDoubleClick={({ row }) => locateRow(row)}
slots={{ toolbar: GridToolbar }}
slotProps={{
toolbar: {
showQuickFilter: true,
printOptions: { disableToolbarButton: true },
csvOptions: { disableToolbarButton: true },
},
}}
sx={{
borderColor: "rgba(15, 23, 42, 0.10)",
"& .MuiDataGrid-columnHeaders": { bgcolor: "#edf3f7" },
"& .MuiDataGrid-cell": {
borderColor: "rgba(15, 23, 42, 0.06)",
fontVariantNumeric: "tabular-nums",
},
"& .MuiDataGrid-row.Mui-selected": {
bgcolor: "rgba(37, 125, 212, 0.10)",
},
}}
/>
</Box>
<Dialog
open={saveDialogOpen}
onClose={() => !saving && setSaveDialogOpen(false)}
aria-labelledby="overwrite-scheme-title"
>
<DialogTitle id="overwrite-scheme-title"></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ lineHeight: 1.8 }}>
{changes.added} {" "}
{changes.replaced} {changes.removed} {rows.length}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setSaveDialogOpen(false)} disabled={saving}>
</Button>
<Button variant="contained" onClick={handleSave} disabled={saving}>
{saving ? "保存中" : "确认覆盖"}
</Button>
</DialogActions>
</Dialog>
<SchemeDrawingDialog
open={drawingOpen}
onClose={() => setDrawingOpen(false)}
scheme={scheme}
rows={rows}
network={network}
map={map ?? null}
dirty={dirty}
/>
</Box>
);
};
export default SchemeEditor;
@@ -17,7 +17,7 @@ import {
} from "@mui/material";
import {
Info as InfoIcon,
LocationOn as LocationIcon,
EditLocationAlt as EditIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
@@ -37,16 +37,9 @@ import VectorSource from "ol/source/Vector";
import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf";
interface SchemeRecord {
id: number;
schemeName: string;
sensorNumber: number;
minDiameter: number;
user: string;
create_time: string;
sensorLocation?: string[];
}
import type { SchemeRecord } from "./types";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemaItem {
id: number;
@@ -61,7 +54,7 @@ interface SchemaItem {
interface SchemeQueryProps {
schemes?: SchemeRecord[];
onSchemesChange?: (schemes: SchemeRecord[]) => void;
onLocate?: (id: number) => void;
onEdit?: (id: number) => void;
network?: string;
state?: MonitoringSchemeQueryState;
onStateChange?: (state: MonitoringSchemeQueryState) => void;
@@ -71,6 +64,7 @@ export interface MonitoringSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
hasQueried: boolean;
}
export const createMonitoringSchemeQueryState =
@@ -78,12 +72,13 @@ export const createMonitoringSchemeQueryState =
queryAll: true,
queryDate: dayjs(new Date()),
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemes: externalSchemes,
onSchemesChange,
onLocate,
onEdit,
network = NETWORK_NAME,
state,
onStateChange,
@@ -93,9 +88,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
onStateChange,
createMonitoringSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const creatorName = useSchemeCreatorName();
const { open } = useNotification();
const map = useMap();
@@ -181,7 +177,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true);
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes?network=${network}`,
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
);
let filteredResults = response.data;
@@ -200,15 +196,16 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeName: item.scheme_name,
sensorNumber: item.sensor_number,
minDiameter: item.min_diameter,
user: item.username,
username: item.username,
create_time: item.create_time,
sensorLocation: item.sensor_location,
}));
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
open?.({
type: "error",
type: "success",
message: "查询结果",
description: queryAll
? "没有找到任何方案"
@@ -269,15 +266,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setQueryField("expandedId", expandedId === id ? null : id);
};
// 保存方案(示例功能)
const handleSaveScheme = (scheme: SchemeRecord) => {
open?.({
type: "success",
message: "保存成功",
description: `方案 "${scheme.schemeName}" 已保存`,
});
};
return (
<Box className="flex flex-col h-full">
{/* 查询条件 - 单行布局 */}
@@ -288,7 +276,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -301,9 +292,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -331,39 +325,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */}
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -399,8 +361,9 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="text-gray-500 block"
>
: {scheme.minDiameter} · : {scheme.user} ·
: {formatShortDate(scheme.create_time)}
: {scheme.minDiameter} · :{" "}
{creatorName(scheme.username)} · :{" "}
{formatShortDate(scheme.create_time)}
</Typography>
</Box>
{/* 操作按钮 */}
@@ -424,16 +387,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* <Tooltip title="">
<IconButton
size="small"
onClick={() => onLocate?.(scheme.id)}
color="primary"
className="p-1"
>
<LocationIcon fontSize="small" />
</IconButton>
</Tooltip> */}
</Box>
</Box>
@@ -464,7 +417,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="text-gray-600 min-w-[70px]"
>
:
:
</Typography>
<Typography
variant="caption"
@@ -490,7 +443,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
variant="caption"
className="font-medium text-gray-900"
>
{scheme.user}
{creatorName(scheme.username)}
</Typography>
</Box>
<Box className="flex items-center gap-2">
@@ -571,13 +524,14 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
fullWidth
size="small"
className="bg-blue-600 hover:bg-blue-700"
onClick={() => handleSaveScheme(scheme)}
startIcon={<EditIcon />}
onClick={() => onEdit?.(scheme.id)}
sx={{
textTransform: "none",
fontWeight: 500,
}}
>
</Button>
</Box>
</Box>
@@ -0,0 +1,330 @@
jest.mock("@components/olmap/core/tileFeatureIndex", () => ({
TileFeatureIndex: jest.fn(),
clipLineStringPartsToExtent: jest.fn(),
lineStringFromFlatCoordinates: jest.fn(),
}));
jest.mock("ol/View", () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
cancelAnimations: jest.fn(),
fit: jest.fn(),
getAnimating: () => false,
getProjection: () => "EPSG:3857",
getZoom: () => 12,
})),
}));
jest.mock("ol/layer/VectorTile", () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ source }) => {
let extent: number[] | undefined;
const properties = new Map<string, unknown>();
return {
get: (key: string) => properties.get(key),
getExtent: () => extent,
getSource: () => source,
set: (key: string, value: unknown) => properties.set(key, value),
setExtent: (value: number[]) => {
extent = value;
},
};
}),
}));
jest.mock("ol/layer/Group", () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock("ol/layer/Tile", () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ source }) => ({
getOpacity: () => 1,
getSource: () => source,
})),
}));
jest.mock("ol/Map", () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ layers, view }) => {
let size: number[] | undefined;
let renderComplete: (() => void) | undefined;
return {
getAllLayers: () => layers,
getLayers: () => ({ clear: jest.fn() }),
getSize: () => size,
getView: () => view,
once: (event: string, callback: () => void) => {
if (event === "rendercomplete") renderComplete = callback;
},
render: () => renderComplete?.(),
setSize: (value: number[]) => {
size = value;
},
setTarget: jest.fn(),
un: jest.fn(),
};
}),
}));
import { render, screen, waitFor } from "@testing-library/react";
import { createElement } from "react";
import {
getPaddedDrawingExtent,
renderEngineeringDrawing,
} from "./engineeringDrawing";
import SchemeDrawingDialog, {
DrawingPreview,
fitMapToFullNetwork,
} from "./SchemeDrawingDialog";
import type { SensorPlacementScheme, SensorPointRow } from "./types";
const createContext = () => ({
arc: jest.fn(),
beginPath: jest.fn(),
clip: jest.fn(),
closePath: jest.fn(),
drawImage: jest.fn(),
fill: jest.fn(),
fillRect: jest.fn(),
fillText: jest.fn(),
lineTo: jest.fn(),
moveTo: jest.fn(),
rect: jest.fn(),
restore: jest.fn(),
save: jest.fn(),
setTransform: jest.fn(),
stroke: jest.fn(),
strokeRect: jest.fn(),
strokeText: jest.fn(),
});
const point = (
node_id: string,
sequence: number,
map_x: number,
map_y: number,
): SensorPointRow => ({
node_id,
sequence,
map_x,
map_y,
project_x: map_x,
project_y: map_y,
longitude: 121,
latitude: 31,
elevation: 5,
adjustment_status: "current",
});
const rows = [point("A", 1, 400, 200), point("B", 2, 500, 300)];
const scheme: SensorPlacementScheme = {
id: 1,
scheme_name: "测试方案",
sensor_number: rows.length,
min_diameter: 300,
username: "tester",
create_time: "2026-07-30T08:00:00+08:00",
sensor_location: rows.map((row) => row.node_id),
sensor_points: rows,
can_edit: true,
};
describe("engineering drawing map framing", () => {
it("does not change the visible main-map view while opening the drawing", async () => {
const cancelAnimations = jest.fn();
const fit = jest.fn();
const setCenter = jest.fn();
const setResolution = jest.fn();
const setRotation = jest.fn();
const getAllLayers = jest.fn(() => [
{
get: (key: string) => (key === "value" ? "pipes" : undefined),
getExtent: () => [0, 0, 1000, 1000],
getSource: () => ({}),
},
]);
let renderComplete: (() => void) | undefined;
const map = {
getAllLayers,
getSize: () => [1200, 800],
getView: () => ({
cancelAnimations,
fit,
getAnimating: () => false,
getCenter: () => [500, 500],
getResolution: () => 10,
getRotation: () => 0,
getProjection: () => "EPSG:3857",
getZoom: () => 12,
setCenter,
setResolution,
setRotation,
}),
once: (event: string, callback: () => void) => {
if (event === "rendercomplete") renderComplete = callback;
},
un: jest.fn(),
render: jest.fn(() => renderComplete?.()),
};
render(
createElement(SchemeDrawingDialog, {
open: true,
onClose: jest.fn(),
scheme,
rows,
network: "test",
map: map as never,
dirty: false,
}),
);
await waitFor(() => expect(getAllLayers).toHaveBeenCalled());
expect(screen.getByText(/简化管网线稿/)).toBeInTheDocument();
expect(screen.queryByText("地图底图 + 管网")).not.toBeInTheDocument();
expect(screen.queryByTestId("HubOutlinedIcon")).not.toBeInTheDocument();
expect(screen.getByTestId("drawing-content-spacing")).toHaveStyle({
paddingTop: "12px",
});
expect(screen.getByTestId("drawing-format-summary")).toHaveStyle({
marginBottom: "12px",
});
expect(fit).not.toHaveBeenCalled();
expect(setCenter).not.toHaveBeenCalled();
expect(setResolution).not.toHaveBeenCalled();
expect(setRotation).not.toHaveBeenCalled();
});
it("does not use a native image fallback for the generated preview", () => {
render(
createElement(DrawingPreview, {
src: "blob:engineering-drawing-preview",
label: "测试方案压力监测点布置图预览",
}),
);
const preview = screen.getByRole("img", {
name: "测试方案压力监测点布置图预览",
});
expect(preview.tagName).toBe("DIV");
expect(preview).not.toHaveAttribute("src");
});
it("fits the OpenLayers map to the complete pipe layer extent", () => {
const extent = [13500000, 3600000, 13600000, 3700000];
const cancelAnimations = jest.fn();
const fit = jest.fn();
const map = {
getAllLayers: () => [
{
get: (key: string) => (key === "value" ? "pipes" : undefined),
getExtent: () => extent,
},
],
getSize: () => [1200, 800],
getView: () => ({ cancelAnimations, fit }),
};
expect(fitMapToFullNetwork(map as never)).toEqual(extent);
expect(cancelAnimations).toHaveBeenCalledTimes(1);
expect(fit).toHaveBeenCalledWith(
getPaddedDrawingExtent(extent, 1200 / 800),
{
padding: [0, 0, 0, 0],
size: [1200, 800],
},
);
expect(cancelAnimations.mock.invocationCallOrder[0]).toBeLessThan(
fit.mock.invocationCallOrder[0],
);
});
});
describe("engineering drawing", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("draws the complete network even when sensors occupy a local area", async () => {
const context = createContext();
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
await renderEngineeringDrawing({
scheme,
rows,
network: "test",
networkData: {
nodes: ["A:junction:8000:4000", "B:junction:9000:4500"],
links: ["P1:pipe:A:B"],
extent: [0, 0, 10000, 5000],
},
dirty: false,
width: 1600,
});
// The first path is the remote pipe. A sensor-derived extent would skip it,
// leaving the title-block divider as the first path near the sheet bottom.
const [pipeStartX, pipeStartY] = context.moveTo.mock.calls[0];
const [pipeEndX, pipeEndY] = context.lineTo.mock.calls[0];
expect(pipeStartX).toBeGreaterThan(1200);
expect(pipeStartY).toBeLessThan(400);
expect(pipeEndX).toBeGreaterThan(pipeStartX);
expect(pipeEndY).toBeLessThan(pipeStartY);
});
it("uses one scale for linework coordinates", async () => {
const context = createContext();
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
await renderEngineeringDrawing({
scheme,
rows,
network: "test",
networkData: {
nodes: ["A:junction:400:200", "B:junction:500:300"],
links: ["P1:pipe:A:B"],
extent: [0, 0, 1000, 1000],
},
dirty: false,
width: 1600,
});
const [startX, startY] = context.moveTo.mock.calls[0];
const [endX, endY] = context.lineTo.mock.calls[0];
expect(Math.abs(endX - startX)).toBeCloseTo(Math.abs(endY - startY), 6);
expect(context.drawImage).not.toHaveBeenCalled();
});
it("keeps visible space on the left and right of the complete network", async () => {
const context = createContext();
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
await renderEngineeringDrawing({
scheme,
rows,
network: "test",
networkData: {
nodes: ["A:junction:0:0", "B:junction:1631:1000"],
links: ["P1:pipe:A:B"],
extent: [0, 0, 1631, 1000],
},
dirty: false,
width: 1600,
});
const [startX] = context.moveTo.mock.calls[0];
const [endX] = context.lineTo.mock.calls[0];
expect(startX).toBeGreaterThan(100);
expect(endX).toBeLessThan(1500);
});
});
@@ -0,0 +1,369 @@
import type {
NetworkDrawingData,
SensorPlacementScheme,
SensorPointRow,
} from "./types";
export const A3_LANDSCAPE_WIDTH = 4961;
export const A3_LANDSCAPE_HEIGHT = 3508;
interface DrawingOptions {
scheme: SensorPlacementScheme;
rows: SensorPointRow[];
network: string;
networkData: NetworkDrawingData;
dirty: boolean;
width?: number;
}
interface ParsedNode {
id: string;
x: number;
y: number;
}
type DrawingExtent = [number, number, number, number];
export const DRAWING_CONTENT_PADDING_RATIO = 0.04;
const toDrawingExtent = (extent: number[]): DrawingExtent => {
if (
extent.length !== 4 ||
!extent.every(Number.isFinite) ||
extent[0] >= extent[2] ||
extent[1] >= extent[3]
) {
throw new Error("地图范围不可用");
}
return [extent[0], extent[1], extent[2], extent[3]];
};
const parseNode = (value: string): ParsedNode | null => {
const [id, , x, y] = value.split(":");
const parsedX = Number(x);
const parsedY = Number(y);
if (!id || !Number.isFinite(parsedX) || !Number.isFinite(parsedY))
return null;
return { id, x: parsedX, y: parsedY };
};
const fitExtentToAspectRatio = (
extent: DrawingExtent,
targetAspectRatio: number,
): DrawingExtent => {
const [minX, minY, maxX, maxY] = extent;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
let spanX = maxX - minX;
let spanY = maxY - minY;
if (spanX / spanY > targetAspectRatio) {
spanY = spanX / targetAspectRatio;
} else {
spanX = spanY * targetAspectRatio;
}
return [
centerX - spanX / 2,
centerY - spanY / 2,
centerX + spanX / 2,
centerY + spanY / 2,
];
};
export const getPaddedDrawingExtent = (
extent: number[],
targetAspectRatio: number,
): DrawingExtent => {
const fitted = fitExtentToAspectRatio(
toDrawingExtent(extent),
targetAspectRatio,
);
const [minX, minY, maxX, maxY] = fitted;
const horizontalPadding =
((maxX - minX) * DRAWING_CONTENT_PADDING_RATIO) /
(1 - DRAWING_CONTENT_PADDING_RATIO * 2);
const verticalPadding =
((maxY - minY) * DRAWING_CONTENT_PADDING_RATIO) /
(1 - DRAWING_CONTENT_PADDING_RATIO * 2);
return [
minX - horizontalPadding,
minY - verticalPadding,
maxX + horizontalPadding,
maxY + verticalPadding,
];
};
const drawText = (
context: CanvasRenderingContext2D,
value: string,
x: number,
y: number,
size: number,
weight = 400,
align: CanvasTextAlign = "left",
outlineColor?: string,
outlineWidth = 0,
) => {
context.font = `${weight} ${size}px -apple-system, "PingFang SC", "Noto Sans SC", sans-serif`;
context.textAlign = align;
context.textBaseline = "middle";
if (outlineColor && outlineWidth > 0) {
context.strokeStyle = outlineColor;
context.lineWidth = outlineWidth;
context.lineJoin = "round";
context.strokeText(value, x, y);
}
context.fillText(value, x, y);
};
export const renderEngineeringDrawing = async ({
scheme,
rows,
network,
networkData,
dirty,
width = A3_LANDSCAPE_WIDTH,
}: DrawingOptions): Promise<HTMLCanvasElement> => {
if (!rows.length) throw new Error("方案没有可出图的监测点");
const height = Math.round((width * A3_LANDSCAPE_HEIGHT) / A3_LANDSCAPE_WIDTH);
const scale = width / A3_LANDSCAPE_WIDTH;
const px = (value: number) => value * scale;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("无法创建工程图画布");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.strokeStyle = "#111827";
context.lineWidth = px(8);
context.strokeRect(px(70), px(70), width - px(140), height - px(140));
const mapBox = {
x: px(150),
y: px(150),
width: width - px(300),
height: height - px(650),
};
context.save();
context.beginPath();
context.rect(mapBox.x, mapBox.y, mapBox.width, mapBox.height);
context.clip();
const extent = getPaddedDrawingExtent(
networkData.extent,
mapBox.width / mapBox.height,
);
context.fillStyle = "#fbfcfd";
context.fillRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height);
const nodes = networkData.nodes
.map(parseNode)
.filter((node): node is ParsedNode => Boolean(node));
const nodesById = new globalThis.Map(
nodes.map((node) => [node.id, node] as const),
);
const [minX, minY, maxX, maxY] = extent;
const toCanvas = (x: number, y: number) => ({
x: mapBox.x + ((x - minX) / (maxX - minX)) * mapBox.width,
y:
mapBox.y + mapBox.height - ((y - minY) / (maxY - minY)) * mapBox.height,
});
context.strokeStyle = "#45677a";
context.lineWidth = px(5);
for (const link of networkData.links) {
const [, , startId, endId] = link.split(":");
const start = nodesById.get(startId);
const end = nodesById.get(endId);
if (!start || !end) continue;
if (
Math.max(start.x, end.x) < minX ||
Math.min(start.x, end.x) > maxX ||
Math.max(start.y, end.y) < minY ||
Math.min(start.y, end.y) > maxY
) {
continue;
}
const startPoint = toCanvas(start.x, start.y);
const endPoint = toCanvas(end.x, end.y);
context.beginPath();
context.moveTo(startPoint.x, startPoint.y);
context.lineTo(endPoint.x, endPoint.y);
context.stroke();
}
context.fillStyle = "#668697";
for (const node of nodes) {
if (node.x < minX || node.x > maxX || node.y < minY || node.y > maxY) {
continue;
}
const point = toCanvas(node.x, node.y);
context.beginPath();
context.arc(point.x, point.y, px(4), 0, Math.PI * 2);
context.fill();
}
rows.forEach((row) => {
const point = toCanvas(row.map_x, row.map_y);
context.fillStyle = "#ffffff";
context.beginPath();
context.arc(point.x, point.y, px(36), 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(255,255,255,0.96)";
context.lineWidth = px(14);
context.stroke();
context.strokeStyle = "#c9252d";
context.lineWidth = px(5);
context.stroke();
context.fillStyle = "#a51f28";
drawText(
context,
String(row.sequence),
point.x,
point.y,
px(32),
700,
"center",
);
context.fillStyle = "#273746";
drawText(
context,
row.node_id,
point.x,
point.y + px(62),
px(25),
650,
"center",
"rgba(255,255,255,0.98)",
px(9),
);
});
context.restore();
context.strokeStyle = "#111827";
context.lineWidth = px(4);
context.strokeRect(mapBox.x, mapBox.y, mapBox.width, mapBox.height);
for (let index = 0; index <= 4; index += 1) {
const ratio = index / 4;
const x = mapBox.x + ratio * mapBox.width;
const y = mapBox.y + mapBox.height - ratio * mapBox.height;
context.fillStyle = "#334155";
drawText(
context,
(minX + ratio * (maxX - minX)).toFixed(0),
x,
mapBox.y + mapBox.height + px(32),
px(20),
500,
"center",
);
drawText(
context,
(minY + ratio * (maxY - minY)).toFixed(0),
mapBox.x - px(18),
y,
px(20),
500,
"right",
);
}
const titleTop = height - px(420);
context.strokeStyle = "#111827";
context.lineWidth = px(4);
context.strokeRect(px(150), titleTop, width - px(300), px(270));
context.beginPath();
context.moveTo(width - px(1700), titleTop);
context.lineTo(width - px(1700), titleTop + px(270));
context.stroke();
context.fillStyle = "#111827";
drawText(
context,
`${scheme.scheme_name} 压力监测点布置图`,
px(220),
titleTop + px(70),
px(48),
700,
);
drawText(
context,
`项目:${network} 监测点:${rows.length} 个 地图:EPSG:3857 经纬度:WGS84`,
px(220),
titleTop + px(145),
px(26),
500,
);
drawText(
context,
dirty ? "状态:未保存草稿" : "状态:当前方案",
px(220),
titleTop + px(215),
px(26),
dirty ? 700 : 500,
);
const infoX = width - px(1620);
drawText(
context,
`创建人:${scheme.username}`,
infoX,
titleTop + px(55),
px(24),
500,
);
drawText(
context,
`创建时间:${new Date(scheme.create_time).toLocaleString("zh-CN")}`,
infoX,
titleTop + px(115),
px(24),
500,
);
drawText(
context,
`制图时间:${new Date().toLocaleString("zh-CN")}`,
infoX,
titleTop + px(175),
px(24),
500,
);
drawText(
context,
"图幅:A3 横向 300 DPI",
infoX,
titleTop + px(235),
px(24),
500,
);
context.strokeStyle = "#111827";
context.lineWidth = px(5);
const northX = width - px(260);
const northY = px(270);
context.beginPath();
context.moveTo(northX, northY - px(70));
context.lineTo(northX - px(32), northY + px(35));
context.lineTo(northX, northY + px(15));
context.lineTo(northX + px(32), northY + px(35));
context.closePath();
context.stroke();
context.fillStyle = "#111827";
drawText(context, "N", northX, northY - px(110), px(30), 700, "center");
return canvas;
};
export const canvasToPngBlob = (canvas: HTMLCanvasElement) =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("PNG 生成失败"));
}, "image/png");
});
@@ -0,0 +1,66 @@
import { api } from "@/lib/api";
import { config } from "@/config/config";
import type {
AdjustmentStatus,
SensorPlacementScheme,
} from "./types";
export interface OptimizeSchemeInput {
scheme_name: string;
sensor_type: "pressure";
method: "sensitivity" | "kmeans";
sensor_count: number;
min_diameter: number;
}
export const optimizeSensorPlacement = async (
input: OptimizeSchemeInput,
): Promise<SensorPlacementScheme> => {
const response = await api.post<SensorPlacementScheme>(
`${config.BACKEND_URL}/api/v1/sensor-placement-optimization-runs`,
input,
);
return response.data;
};
export const getSensorPlacementScheme = async (
schemeId: number,
): Promise<SensorPlacementScheme> => {
const response = await api.get<SensorPlacementScheme>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
);
return response.data;
};
export const overwriteSensorPlacementScheme = async (
schemeId: number,
expectedSensorLocation: string[],
sensorLocation: string[],
): Promise<SensorPlacementScheme> => {
const response = await api.put<SensorPlacementScheme>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
{
expected_sensor_location: expectedSensorLocation,
sensor_location: sensorLocation,
},
);
return response.data;
};
export const exportSensorPlacementExcel = async (
schemeId: number,
sensorLocation: string[],
adjustmentStatus: Record<string, AdjustmentStatus>,
) => {
const response = await api.post<Blob>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}/exports/excel`,
{
sensor_location: sensorLocation,
adjustment_status: adjustmentStatus,
},
{
responseType: "blob",
},
);
return response.data;
};
@@ -0,0 +1,104 @@
import {
addSensorPoint,
createSchemeEditorState,
deleteSensorPoint,
isSchemeDirty,
redoSchemeEdit,
replaceSensorPoint,
resetSchemeEdit,
summarizeChanges,
undoSchemeEdit,
} from "./schemeEditor";
import type { SensorPlacementScheme, SensorPoint } from "./types";
const point = (node_id: string): SensorPoint => ({
node_id,
project_x: Number(node_id.slice(1)) * 10,
project_y: Number(node_id.slice(1)) * 20,
map_x: 13500000 + Number(node_id.slice(1)) * 10,
map_y: 3600000 + Number(node_id.slice(1)) * 20,
longitude: 121,
latitude: 31,
elevation: 5,
});
const scheme: SensorPlacementScheme = {
id: 1,
scheme_name: "测试方案",
sensor_number: 2,
min_diameter: 300,
username: "alice",
create_time: "2026-07-30T08:00:00+08:00",
sensor_location: ["J1", "J2"],
sensor_points: [point("J1"), point("J2")],
can_edit: true,
};
describe("scheme editor", () => {
it("adds unique nodes and can undo", () => {
const initial = createSchemeEditorState(scheme);
const added = addSensorPoint(initial, point("J3"));
expect(added.points.map((item) => item.node_id)).toEqual(["J1", "J2", "J3"]);
expect(added.statuses.J3).toBe("added");
expect(isSchemeDirty(added)).toBe(true);
expect(undoSchemeEdit(added).points).toEqual(initial.points);
});
it("can redo an undone edit and clears redo history after a new edit", () => {
const initial = createSchemeEditorState(scheme);
const added = addSensorPoint(initial, point("J3"));
const undone = undoSchemeEdit(added);
const redone = redoSchemeEdit(undone);
expect(redone.points.map((item) => item.node_id)).toEqual([
"J1",
"J2",
"J3",
]);
expect(redone.history).toHaveLength(1);
expect(redone.future).toEqual([]);
const editedAfterUndo = replaceSensorPoint(undone, "J1", point("J4"));
expect(editedAfterUndo.future).toEqual([]);
});
it("replaces a node without changing row order", () => {
const initial = createSchemeEditorState(scheme);
const replaced = replaceSensorPoint(initial, "J1", point("J3"));
expect(replaced.points.map((item) => item.node_id)).toEqual(["J3", "J2"]);
expect(replaced.statuses.J3).toBe("replaced");
expect(summarizeChanges(replaced)).toEqual({
added: 0,
removed: 0,
replaced: 1,
});
});
it("keeps an added status when replacing a newly added node", () => {
const added = addSensorPoint(createSchemeEditorState(scheme), point("J3"));
const replaced = replaceSensorPoint(added, "J3", point("J4"));
expect(replaced.statuses.J3).toBeUndefined();
expect(replaced.statuses.J4).toBe("added");
});
it("rejects duplicate replacements and deleting the final row", () => {
const initial = createSchemeEditorState(scheme);
expect(replaceSensorPoint(initial, "J1", point("J2"))).toBe(initial);
const oneLeft = deleteSensorPoint(initial, "J1");
expect(deleteSensorPoint(oneLeft, "J2")).toBe(oneLeft);
});
it("resets to the loaded baseline", () => {
const edited = addSensorPoint(createSchemeEditorState(scheme), point("J3"));
const reset = resetSchemeEdit(edited);
expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]);
expect(isSchemeDirty(reset)).toBe(false);
expect(reset.history).toEqual([]);
expect(reset.future).toEqual([]);
});
});
@@ -0,0 +1,175 @@
import type {
AdjustmentStatus,
SensorPlacementScheme,
SensorPoint,
SensorPointRow,
} from "./types";
export interface SchemeEditorSnapshot {
points: SensorPoint[];
statuses: Record<string, AdjustmentStatus>;
}
export interface SchemeEditorState extends SchemeEditorSnapshot {
baseline: SensorPoint[];
history: SchemeEditorSnapshot[];
future: SchemeEditorSnapshot[];
}
const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point }));
const currentStatuses = (points: SensorPoint[]) =>
Object.fromEntries(points.map((point) => [point.node_id, "current"])) as Record<
string,
AdjustmentStatus
>;
export const createSchemeEditorState = (
scheme: SensorPlacementScheme,
): SchemeEditorState => ({
baseline: clonePoints(scheme.sensor_points),
points: clonePoints(scheme.sensor_points),
statuses: currentStatuses(scheme.sensor_points),
history: [],
future: [],
});
const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({
points: clonePoints(state.points),
statuses: { ...state.statuses },
});
const withHistory = (
state: SchemeEditorState,
points: SensorPoint[],
statuses: Record<string, AdjustmentStatus>,
): SchemeEditorState => ({
...state,
points,
statuses,
history: [...state.history, snapshot(state)],
future: [],
});
export const addSensorPoint = (
state: SchemeEditorState,
point: SensorPoint,
): SchemeEditorState => {
if (state.points.some((item) => item.node_id === point.node_id)) return state;
return withHistory(
state,
[...state.points, { ...point }],
{ ...state.statuses, [point.node_id]: "added" },
);
};
export const replaceSensorPoint = (
state: SchemeEditorState,
sourceNodeId: string,
point: SensorPoint,
): SchemeEditorState => {
const sourceIndex = state.points.findIndex(
(item) => item.node_id === sourceNodeId,
);
if (sourceIndex < 0) return state;
if (
sourceNodeId !== point.node_id &&
state.points.some((item) => item.node_id === point.node_id)
) {
return state;
}
const nextPoints = clonePoints(state.points);
nextPoints[sourceIndex] = { ...point };
const nextStatuses = { ...state.statuses };
const sourceStatus = nextStatuses[sourceNodeId] ?? "current";
delete nextStatuses[sourceNodeId];
const baselineIds = new Set(state.baseline.map((item) => item.node_id));
nextStatuses[point.node_id] =
sourceStatus === "added"
? "added"
: baselineIds.has(point.node_id)
? "original"
: "replaced";
return withHistory(state, nextPoints, nextStatuses);
};
export const deleteSensorPoint = (
state: SchemeEditorState,
nodeId: string,
): SchemeEditorState => {
if (state.points.length <= 1) return state;
if (!state.points.some((item) => item.node_id === nodeId)) return state;
const nextStatuses = { ...state.statuses };
delete nextStatuses[nodeId];
return withHistory(
state,
state.points.filter((item) => item.node_id !== nodeId),
nextStatuses,
);
};
export const undoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
const previous = state.history[state.history.length - 1];
if (!previous) return state;
return {
...state,
points: clonePoints(previous.points),
statuses: { ...previous.statuses },
history: state.history.slice(0, -1),
future: [...state.future, snapshot(state)],
};
};
export const redoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
const next = state.future[state.future.length - 1];
if (!next) return state;
return {
...state,
points: clonePoints(next.points),
statuses: { ...next.statuses },
history: [...state.history, snapshot(state)],
future: state.future.slice(0, -1),
};
};
export const resetSchemeEdit = (state: SchemeEditorState): SchemeEditorState => ({
...state,
points: clonePoints(state.baseline),
statuses: currentStatuses(state.baseline),
history: [],
future: [],
});
export const isSchemeDirty = (state: SchemeEditorState) => {
const baselineIds = state.baseline.map((point) => point.node_id);
const currentIds = state.points.map((point) => point.node_id);
return (
baselineIds.length !== currentIds.length ||
baselineIds.some((nodeId, index) => nodeId !== currentIds[index])
);
};
export const toSensorPointRows = (
state: SchemeEditorState,
): SensorPointRow[] =>
state.points.map((point, index) => ({
...point,
sequence: index + 1,
adjustment_status: state.statuses[point.node_id] ?? "current",
}));
export const summarizeChanges = (state: SchemeEditorState) => {
const baselineIds = new Set(state.baseline.map((point) => point.node_id));
const currentIds = new Set(state.points.map((point) => point.node_id));
const added = [...currentIds].filter((nodeId) => !baselineIds.has(nodeId)).length;
const removed = [...baselineIds].filter(
(nodeId) => !currentIds.has(nodeId),
).length;
const replaced = Math.min(added, removed);
return {
added: Math.max(0, added - replaced),
removed: Math.max(0, removed - replaced),
replaced,
};
};
@@ -0,0 +1,45 @@
export type AdjustmentStatus = "current" | "original" | "added" | "replaced";
export interface SensorPoint {
node_id: string;
project_x: number;
project_y: number;
map_x: number;
map_y: number;
longitude: number;
latitude: number;
elevation: number;
}
export interface SensorPlacementScheme {
id: number;
scheme_name: string;
sensor_number: number;
min_diameter: number;
username: string;
create_time: string;
sensor_location: string[];
sensor_points: SensorPoint[];
can_edit: boolean;
}
export interface SchemeRecord {
id: number;
schemeName: string;
sensorNumber: number;
minDiameter: number;
username: string;
create_time: string;
sensorLocation?: string[];
}
export interface SensorPointRow extends SensorPoint {
sequence: number;
adjustment_status: AdjustmentStatus;
}
export interface NetworkDrawingData {
nodes: string[];
links: string[];
extent: [number, number, number, number];
}
+25 -38
View File
@@ -40,6 +40,7 @@ import { useGetIdentity } from "@refinedev/core";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -104,11 +105,11 @@ const fetchFromBackend = async (
const start_time = dayjs(range.from).toISOString();
const end_time = dayjs(range.to).toISOString();
// 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
// 优先查询清洗数据和模拟数据
const [cleaningRes, simulationRes] = await Promise.all([
@@ -314,11 +315,11 @@ const emptyStateMessages: Record<
> = {
chart: {
title: "暂无时序数据",
subtitle: "请切换时间段来获取曲线",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
},
table: {
title: "暂无表格数据",
subtitle: "请切换时间段来获取记录",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
},
};
@@ -350,11 +351,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const end_time = dayjs(range.to).toISOString();
// 清洗数据接口
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 原始数据
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try {
const [cleanRes, rawRes, simRes] = await Promise.all([
apiFetch(cleaningDataUrl)
@@ -529,7 +530,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const response = await api.post(
`${
config.BACKEND_URL
}/api/v1/composite/clean-scada?device_ids=${deviceIds.join(
}/api/v1/timeseries/scada-cleaning-runs?device_ids=${deviceIds.join(
",",
)}&start_time=${startTime}&end_time=${endTime}`,
);
@@ -721,28 +722,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const renderEmpty = () => {
if (!hasDevices) {
return (
<PanelEmptyState
icon={<ShowChart />}
title="尚未选择设备"
description="请先在设备列表中选择需要查看的 SCADA 设备。"
/>
);
}
const message = emptyStateMessages[activeTab];
return (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 8,
color: "text.secondary",
height: "100%",
}}
>
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
{message.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{message.subtitle}
</Typography>
</Box>
<PanelEmptyState
icon={activeTab === "chart" ? <ShowChart /> : <TableChart />}
title={message.title}
description={message.subtitle}
/>
);
};
@@ -1254,15 +1250,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Stack>
</LocalizationProvider>
{!hasDevices && (
<Typography
variant="caption"
color="warning.main"
sx={{ mt: 1, display: "block" }}
>
</Typography>
)}
{error && (
<Typography
variant="caption"
+17 -15
View File
@@ -68,6 +68,7 @@ import dayjs, { Dayjs } from "dayjs";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
const STATUS_OPTIONS: {
value: "online" | "offline" | "warning" | "error";
@@ -623,7 +624,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
// 调用后端清洗接口
const response = await api.post(
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
`${config.BACKEND_URL}/api/v1/timeseries/scada-cleaning-runs?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
);
// 处理成功响应
@@ -1067,22 +1068,23 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
<CircularProgress />
</Box>
) : filteredDevices.length === 0 ? (
<Box
sx={{
p: 4,
textAlign: "center",
color: "text.secondary",
}}
>
<DeviceHub sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="body2">
{searchQuery ||
<PanelEmptyState
icon={<DeviceHub />}
title={
searchQuery ||
selectedType !== "all" ||
selectedStatus !== "all"
? "未找到匹配设备"
: "暂无 SCADA 设备"}
</Typography>
</Box>
? "未找到匹配设备"
: "暂无 SCADA 设备"
}
description={
searchQuery ||
selectedType !== "all" ||
selectedStatus !== "all"
? "请清除关键词,或调整设备类型和状态筛选条件。"
: "请确认设备已接入当前管网后刷新列表。"
}
/>
) : (
<FixedSizeList
height={listHeight}

Some files were not shown because too many files have changed in this diff Show More