fix(map): use component-level loading skeletons

This commit is contained in:
2026-07-30 13:21:55 +08:00
parent c4246cf25f
commit 1d7e07174f
28 changed files with 1095 additions and 264 deletions
@@ -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>
);
}
@@ -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: ["分析参数", "方案查询"],
},
};
@@ -35,7 +35,7 @@ interface ToolbarProps {
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
queryType?: string; // 可选的查询类型参数
schemeType?: string; // 可选的方案类型参数
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
HistoryPanel?: React.ComponentType<any>; // 可选的自定义历史数据面板
enableCompare?: boolean;
}
@@ -16,7 +16,7 @@ type ToolbarHistoryPanelProps = {
endTime?: string;
} | null;
highlightFeatures: Feature[];
HistoryPanel?: React.FC<any>;
HistoryPanel?: React.ComponentType<any>;
schemeName?: string;
queryType?: string;
onClose: () => void;