fix(map): use component-level loading skeletons
This commit is contained in:
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"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 MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
|
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 StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
import {
|
import {
|
||||||
RAINBOW_COLORS,
|
RAINBOW_COLORS,
|
||||||
@@ -12,6 +12,26 @@ import {
|
|||||||
} from "@components/olmap/HealthRiskAnalysis/types";
|
} from "@components/olmap/HealthRiskAnalysis/types";
|
||||||
import { Box } from "@mui/material";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<HealthRiskProvider>
|
<HealthRiskProvider>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <MapSkeleton />;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useState } from "react";
|
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 MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
const Timeline = dynamic(
|
||||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
() => 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() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
|
||||||
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
|
||||||
|
|
||||||
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
|
const SCADADeviceList = dynamic(
|
||||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
() => 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() {
|
export default function Home() {
|
||||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import React, { Suspense } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import authOptions from "@app/api/auth/[...nextauth]/options";
|
import authOptions from "@app/api/auth/[...nextauth]/options";
|
||||||
import { Header } from "@components/header";
|
import { Header } from "@components/header";
|
||||||
import { Title } from "@components/title";
|
import { Title } from "@components/title";
|
||||||
import { MapSkeleton } from "@components/loading/MapSkeleton";
|
|
||||||
import { AppSider } from "@components/sider/AppSider";
|
import { AppSider } from "@components/sider/AppSider";
|
||||||
import { ThemedLayout } from "@refinedev/mui";
|
import { ThemedLayout } from "@refinedev/mui";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { getServerSession } from "next-auth/next";
|
||||||
@@ -20,7 +19,7 @@ export const metadata: Metadata = META_DATA;
|
|||||||
export default async function MainLayout({
|
export default async function MainLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const theme = cookieStore.get("theme");
|
const theme = cookieStore.get("theme");
|
||||||
@@ -49,9 +48,7 @@ export default async function MainLayout({
|
|||||||
sx: { height: "100vh", overflow: "hidden" },
|
sx: { height: "100vh", overflow: "hidden" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<MapSkeleton />}>
|
{children}
|
||||||
{children}
|
|
||||||
</Suspense>
|
|
||||||
</ThemedLayout>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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']
|
hiddenButtons?: string[]; // 可选的隐藏按钮列表,例如 ['info', 'draw', 'style']
|
||||||
queryType?: string; // 可选的查询类型参数
|
queryType?: string; // 可选的查询类型参数
|
||||||
schemeType?: string; // 可选的方案类型参数
|
schemeType?: string; // 可选的方案类型参数
|
||||||
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
HistoryPanel?: React.ComponentType<any>; // 可选的自定义历史数据面板
|
||||||
enableCompare?: boolean;
|
enableCompare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type ToolbarHistoryPanelProps = {
|
|||||||
endTime?: string;
|
endTime?: string;
|
||||||
} | null;
|
} | null;
|
||||||
highlightFeatures: Feature[];
|
highlightFeatures: Feature[];
|
||||||
HistoryPanel?: React.FC<any>;
|
HistoryPanel?: React.ComponentType<any>;
|
||||||
schemeName?: string;
|
schemeName?: string;
|
||||||
queryType?: string;
|
queryType?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user