525 lines
12 KiB
TypeScript
525 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|