feat(ui): 统一面板空状态引导

This commit is contained in:
2026-07-30 14:34:43 +08:00
parent 8836549697
commit 04557b9363
21 changed files with 400 additions and 435 deletions
@@ -23,6 +23,7 @@ import { bbox, featureCollection } from "@turf/turf";
import { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { BurstDetectionResult, BurstDetectionRow } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface BurstDetectionResultsState {
selectedDay: number | null;
@@ -73,17 +74,11 @@ const formatDateTime = (value?: string) =>
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
<ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
</Box>
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
</Typography>
<Typography variant="body2" className="max-w-xs text-gray-500">
14
</Typography>
</Box>
<PanelEmptyState
icon={<ShowChartIcon />}
title="尚未生成侦测结果"
description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
);
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
@@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
import {
BurstDetectionResult,
BurstDetectionSchemeDetail,
@@ -42,6 +43,7 @@ export interface BurstDetectionSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
hasQueried: boolean;
}
export const createBurstDetectionSchemeQueryState =
@@ -49,6 +51,7 @@ export const createBurstDetectionSchemeQueryState =
queryAll: true,
queryDate: dayjs(),
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<Props> = ({
@@ -64,7 +67,7 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange,
createBurstDetectionSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
@@ -138,6 +141,7 @@ const SchemeQuery: React.FC<Props> = ({
const response = await api.get("/api/v1/schemes", { params });
const nextSchemes = response.data as BurstDetectionSchemeRecord[];
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
open?.({
type: "success",
message: "查询成功",
@@ -203,7 +207,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox
size="small"
checked={queryAll}
onChange={(event) => setQueryField("queryAll", event.target.checked)}
onChange={(event) => {
setQueryField("queryAll", event.target.checked);
setQueryField("hasQueried", false);
}}
/>
}
label={<Typography variant="body2"></Typography>}
@@ -212,7 +219,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={(value) => setQueryField("queryDate", value)}
onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
@@ -234,12 +244,7 @@ const SchemeQuery: React.FC<Props> = ({
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400">
<Typography variant="body2"></Typography>
<Typography variant="caption" className="mt-1">
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="px-2 text-gray-500">
@@ -31,6 +31,7 @@ import { Stroke, Style, Circle, Fill } from "ol/style";
import { bbox, featureCollection } from "@turf/turf";
import { BurstCandidate, BurstLocationResult } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface Props {
result: BurstLocationResult | null;
@@ -128,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
};
const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center">
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm">
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} />
</Box>
<Typography variant="h6" className="mb-1 font-bold text-gray-700">
</Typography>
<Typography variant="body2" className="max-w-xs text-gray-500">
</Typography>
</Box>
<PanelEmptyState
icon={<MapIcon />}
title="尚未生成定位结果"
description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
);
const LocationResults: React.FC<Props> = ({ result }) => {
@@ -43,6 +43,7 @@ import {
} from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props {
onViewResult: (result: BurstLocationResult) => void;
@@ -57,6 +58,7 @@ export interface BurstLocationSchemeQueryState {
queryDate: Dayjs | null;
expandedId: number | null;
simulationBurstIdsByName: Record<string, string[]>;
hasQueried: boolean;
}
export const createBurstLocationSchemeQueryState =
@@ -65,6 +67,7 @@ export const createBurstLocationSchemeQueryState =
queryDate: dayjs(),
expandedId: null,
simulationBurstIdsByName: {},
hasQueried: false,
});
const SchemeQuery: React.FC<Props> = ({
@@ -83,7 +86,7 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange,
createBurstLocationSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
@@ -256,6 +259,7 @@ const SchemeQuery: React.FC<Props> = ({
),
),
);
setQueryField("hasQueried", true);
open?.({
type: "success",
message: "查询成功",
@@ -320,7 +324,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox
size="small"
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
/>
}
label={<Typography variant="body2"></Typography>}
@@ -329,7 +336,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={(value) => setQueryField("queryDate", value)}
onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
@@ -350,39 +360,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box>
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -62,6 +62,24 @@ describe("AnalysisReport", () => {
document.body.classList.remove("burst-analysis-report-printing");
});
it("guides the user to choose a scheme when no report is active", () => {
render(
<AnalysisReport
scheme={null}
valveResult={null}
disabledValves={[]}
generatedAt={null}
/>,
);
expect(screen.getByText("尚未选择分析方案")).toBeInTheDocument();
expect(
screen.getByText(
"请在“方案查询”中打开一个方案,再查看分析报告。",
),
).toBeInTheDocument();
});
it("renders the selected scheme, pipe data, and matching valve result", async () => {
render(
<AnalysisReport
@@ -35,6 +35,7 @@ import {
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface AnalysisReportProps {
scheme: SchemeRecord | null;
@@ -516,15 +517,11 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({
if (!scheme) {
return (
<Box className="flex h-full flex-col items-center justify-center px-6 text-center">
<DescriptionOutlined sx={{ mb: 2, fontSize: 52, color: "#94a3b8" }} />
<Typography variant="h6" className="font-bold text-gray-700">
</Typography>
<Typography variant="body2" className="mt-2 text-gray-500">
</Typography>
</Box>
<PanelEmptyState
icon={<DescriptionOutlined />}
title="尚未选择分析方案"
description="请在“方案查询”中打开一个方案,再查看分析报告。"
/>
);
}
@@ -57,6 +57,7 @@ import {
type PipeDiameterMap,
} from "./schemePipeDiameters";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps {
schemes?: SchemeRecord[];
@@ -76,6 +77,7 @@ export interface BurstSchemeQueryState {
selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null;
hasQueried: boolean;
}
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
@@ -85,6 +87,7 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
selectedDate: undefined,
timeRange: undefined,
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -107,6 +110,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate,
timeRange,
expandedId,
hasQueried,
} = queryState;
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
@@ -185,6 +189,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
open?.({
@@ -462,7 +467,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -475,9 +483,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
}
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -505,39 +516,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */}
<Box className="flex-1 overflow-auto">
{filteredSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -41,6 +41,7 @@ import { bbox, featureCollection } from "@turf/turf";
import Timeline from "@components/olmap/core/Controls/Timeline";
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps {
schemes?: ContaminantSchemeRecord[];
@@ -60,6 +61,7 @@ export interface ContaminantSchemeQueryState {
selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null;
hasQueried: boolean;
}
export const createContaminantSchemeQueryState =
@@ -70,6 +72,7 @@ export const createContaminantSchemeQueryState =
selectedDate: undefined,
timeRange: undefined,
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -92,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate,
timeRange,
expandedId,
hasQueried,
} = queryState;
const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null);
@@ -250,6 +254,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
open?.({
@@ -351,7 +356,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -364,9 +372,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
}
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -393,39 +404,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<Box className="flex-1 overflow-auto">
{filteredSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -11,11 +11,15 @@ import {
TableHead,
TableRow,
} from "@mui/material";
import { FormatListBulleted } from "@mui/icons-material";
import {
FormatListBulleted,
TroubleshootOutlined,
} from "@mui/icons-material";
import dayjs from "dayjs";
import { getAreaColor } from "./utils";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { LeakageResultDetail } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface Props {
result: LeakageResultDetail | null;
@@ -31,39 +35,11 @@ const RecognitionResults: React.FC<Props> = ({ result }) => {
if (!result || !sortedRows.length) {
return (
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"></Typography>
<Typography variant="body2" className="mt-1">
</Typography>
</Box>
<PanelEmptyState
icon={<TroubleshootOutlined />}
title="尚未生成识别结果"
description="请在“识别参数”中运行分析,或在“方案查询”中打开历史结果。"
/>
);
}
@@ -27,6 +27,7 @@ import { useControllableObjectState } from "@components/olmap/core/useControllab
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props {
onViewResult: (result: LeakageResultDetail) => void;
@@ -40,12 +41,14 @@ export interface DMALeakSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
hasQueried: boolean;
}
export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({
queryAll: true,
queryDate: dayjs(),
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<Props> = ({
@@ -61,7 +64,7 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange,
createDMALeakSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const creatorName = useSchemeCreatorName();
@@ -93,6 +96,7 @@ const SchemeQuery: React.FC<Props> = ({
});
const nextSchemes = response.data as LeakageSchemeRecord[];
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (nextSchemes.length === 0) {
open?.({
type: "success",
@@ -150,7 +154,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox
size="small"
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
/>
}
label={<Typography variant="body2"></Typography>}
@@ -159,7 +166,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={(value) => setQueryField("queryDate", value)}
onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
@@ -180,39 +190,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box>
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -11,7 +11,11 @@ import {
Alert,
Divider,
} from "@mui/material";
import { Close as CloseIcon } from "@mui/icons-material";
import {
AdjustOutlined,
Close as CloseIcon,
WaterDropOutlined,
} from "@mui/icons-material";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
@@ -31,6 +35,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface ValveItem {
id: string;
@@ -383,9 +388,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
</Box>
))}
{valves.length === 0 && (
<Typography variant="caption" className="text-gray-400 text-center py-20">
</Typography>
<PanelEmptyState
variant="compact"
icon={<AdjustOutlined />}
title="尚未选择阀门"
description="点击“选择阀门”,然后在地图上添加。"
/>
)}
</Stack>
</Box>
@@ -412,7 +420,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
💡
</Box>
)}
<Stack spacing={1} className="h-12 overflow-auto">
<Stack spacing={1} className="min-h-16 overflow-auto">
{drainageNode && (
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography>
@@ -428,9 +436,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
</Box>
)}
{!drainageNode && (
<Typography variant="caption" className="text-gray-400 text-center py-2">
</Typography>
<PanelEmptyState
variant="compact"
icon={<WaterDropOutlined />}
title="尚未选择排水节点"
description="点击“选择节点”,然后在地图上指定排水点。"
/>
)}
</Stack>
</Box>
@@ -43,6 +43,7 @@ import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemeRecord, SchemaItem } from "./types";
import { FLOW_DISPLAY_UNIT } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps {
schemes?: SchemeRecord[];
@@ -61,6 +62,7 @@ export interface FlushingSchemeQueryState {
showTimeline: boolean;
selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined;
hasQueried: boolean;
}
export const createFlushingSchemeQueryState =
@@ -71,6 +73,7 @@ export const createFlushingSchemeQueryState =
showTimeline: false,
selectedDate: undefined,
timeRange: undefined,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -92,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
showTimeline,
selectedDate,
timeRange,
hasQueried,
} = queryState;
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -298,6 +302,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeDetail: item.scheme_detail,
}));
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
open?.({
@@ -371,7 +376,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -384,9 +392,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
}
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -414,39 +425,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* Results List */}
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
@@ -23,6 +23,7 @@ import { RAINBOW_COLORS, RISK_BREAKS, RISK_LABELS } from "./types";
import { useHealthRisk } from "./HealthRiskContext";
import { useProject } from "@/contexts/ProjectContext";
import HealthRiskReport from "./HealthRiskReport";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
const SIMPLE_LABELS = [
"0.0 - 0.1",
@@ -229,24 +230,11 @@ const HealthRiskStatistics: React.FC = () => {
};
const renderEmpty = () => (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "text.secondary",
}}
>
<BarChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<PanelEmptyState
icon={<BarChart />}
title="暂无风险预测结果"
description="请先运行健康风险预测,完成后将在这里展示风险分布统计。"
/>
);
return (
@@ -8,6 +8,7 @@ import { ShowChart } from "@mui/icons-material";
import ReactECharts from "echarts-for-react";
import "dayjs/locale/zh-cn";
import { useHealthRisk } from "./HealthRiskContext";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface PredictDataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
@@ -58,26 +59,11 @@ const PredictDataPanel: React.FC<PredictDataPanelProps> = ({
}, [filteredResults]);
const renderEmpty = () => (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 8,
color: "text.secondary",
height: "100%",
}}
>
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<PanelEmptyState
icon={<ShowChart />}
title="暂无可展示的管道预测"
description="请先在地图上选择已完成健康风险分析的管道。"
/>
);
const renderChart = () => {
@@ -38,6 +38,7 @@ import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeRecord {
id: number;
@@ -72,6 +73,7 @@ export interface MonitoringSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
hasQueried: boolean;
}
export const createMonitoringSchemeQueryState =
@@ -79,6 +81,7 @@ export const createMonitoringSchemeQueryState =
queryAll: true,
queryDate: dayjs(new Date()),
expandedId: null,
hasQueried: false,
});
const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -94,7 +97,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
onStateChange,
createMonitoringSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const creatorName = useSchemeCreatorName();
@@ -207,6 +210,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
sensorLocation: item.sensor_location,
}));
setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) {
open?.({
@@ -290,7 +294,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={
<Checkbox
checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)}
onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small"
/>
}
@@ -303,9 +310,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
>
<DatePicker
value={queryDate}
onChange={(value) =>
value && dayjs.isDayjs(value) && setQueryField("queryDate", value)
}
onChange={(value) => {
if (value && dayjs.isDayjs(value)) {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD"
disabled={queryAll}
slotProps={{
@@ -333,39 +343,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */}
<Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400">
<Box className="mb-4">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className="opacity-40"
>
<rect
x="10"
y="20"
width="60"
height="45"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<line
x1="10"
y1="30"
x2="70"
y2="30"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
</Box>
<Typography variant="body2"> 0 </Typography>
<Typography variant="body2" className="mt-1">
No data
</Typography>
</Box>
<SchemeQueryEmptyState hasQueried={hasQueried} />
) : (
<Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2">
+18 -31
View File
@@ -40,6 +40,7 @@ import { useGetIdentity } from "@refinedev/core";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -314,11 +315,11 @@ const emptyStateMessages: Record<
> = {
chart: {
title: "暂无时序数据",
subtitle: "请切换时间段来获取曲线",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
},
table: {
title: "暂无表格数据",
subtitle: "请切换时间段来获取记录",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
},
};
@@ -721,28 +722,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const renderEmpty = () => {
if (!hasDevices) {
return (
<PanelEmptyState
icon={<ShowChart />}
title="尚未选择设备"
description="请先在设备列表中选择需要查看的 SCADA 设备。"
/>
);
}
const message = emptyStateMessages[activeTab];
return (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 8,
color: "text.secondary",
height: "100%",
}}
>
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
{message.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{message.subtitle}
</Typography>
</Box>
<PanelEmptyState
icon={activeTab === "chart" ? <ShowChart /> : <TableChart />}
title={message.title}
description={message.subtitle}
/>
);
};
@@ -1254,15 +1250,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Stack>
</LocalizationProvider>
{!hasDevices && (
<Typography
variant="caption"
color="warning.main"
sx={{ mt: 1, display: "block" }}
>
</Typography>
)}
{error && (
<Typography
variant="caption"
+16 -14
View File
@@ -68,6 +68,7 @@ import dayjs, { Dayjs } from "dayjs";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
const STATUS_OPTIONS: {
value: "online" | "offline" | "warning" | "error";
@@ -1067,22 +1068,23 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
<CircularProgress />
</Box>
) : filteredDevices.length === 0 ? (
<Box
sx={{
p: 4,
textAlign: "center",
color: "text.secondary",
}}
>
<DeviceHub sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="body2">
{searchQuery ||
<PanelEmptyState
icon={<DeviceHub />}
title={
searchQuery ||
selectedType !== "all" ||
selectedStatus !== "all"
? "未找到匹配设备"
: "暂无 SCADA 设备"}
</Typography>
</Box>
? "未找到匹配设备"
: "暂无 SCADA 设备"
}
description={
searchQuery ||
selectedType !== "all" ||
selectedStatus !== "all"
? "请清除关键词,或调整设备类型和状态筛选条件。"
: "请确认设备已接入当前管网后刷新列表。"
}
/>
) : (
<FixedSizeList
height={listHeight}
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { DescriptionOutlined } from "@mui/icons-material";
import PanelEmptyState, { SchemeQueryEmptyState } from "./PanelEmptyState";
describe("PanelEmptyState", () => {
it("renders an accessible panel instruction", () => {
render(
<PanelEmptyState
icon={<DescriptionOutlined />}
title="尚未选择分析方案"
description="请在方案查询中打开一个方案。"
/>,
);
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("尚未选择分析方案")).toBeInTheDocument();
expect(screen.getByText("请在方案查询中打开一个方案。")).toBeInTheDocument();
});
it("distinguishes initial query guidance from an empty result", () => {
const { rerender } = render(
<SchemeQueryEmptyState hasQueried={false} />,
);
expect(screen.getByText("尚未查询历史方案")).toBeInTheDocument();
rerender(<SchemeQueryEmptyState hasQueried />);
expect(screen.getByText("未找到符合条件的方案")).toBeInTheDocument();
expect(
screen.getByText("请调整查询条件后重新查询,或先创建一个新方案。"),
).toBeInTheDocument();
});
it("supports compact selection guidance", () => {
render(
<PanelEmptyState
variant="compact"
title="尚未选择阀门"
description="点击“选择阀门”,然后在地图上添加。"
/>,
);
expect(screen.getByText("尚未选择阀门")).toBeInTheDocument();
});
});
@@ -0,0 +1,112 @@
"use client";
import type { ReactNode } from "react";
import { Box, Typography } from "@mui/material";
import { SearchOffOutlined } from "@mui/icons-material";
import { alpha } from "@mui/material/styles";
export interface PanelEmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
variant?: "panel" | "compact";
}
const PanelEmptyState = ({
icon,
title,
description,
variant = "panel",
}: PanelEmptyStateProps) => {
const compact = variant === "compact";
return (
<Box
role="status"
aria-live="polite"
lang="zh-CN"
sx={(theme) => ({
width: "100%",
height: compact ? "auto" : "100%",
minHeight: compact ? 48 : 220,
display: "flex",
flexDirection: compact ? "row" : { xs: "column", sm: "row" },
alignItems: "center",
justifyContent: "center",
gap: compact ? 1.5 : { xs: 1.75, sm: 2.5 },
px: compact ? 1.5 : { xs: 2, sm: 4 },
py: compact ? 1.25 : { xs: 4, sm: 5 },
color: "text.secondary",
textAlign: compact ? "left" : { xs: "center", sm: "left" },
boxSizing: "border-box",
"& .MuiTypography-root": {
textWrap: "pretty",
},
"& .MuiSvgIcon-root": {
fontSize: compact ? 22 : 32,
},
"& > [aria-hidden='true']": {
flex: "0 0 auto",
width: compact ? 36 : 56,
height: compact ? 36 : 56,
borderRadius: compact ? 2 : 3,
display: "grid",
placeItems: "center",
color: "primary.main",
backgroundColor: alpha(theme.palette.primary.main, 0.08),
boxShadow: `inset 0 0 0 1px ${alpha(
theme.palette.primary.main,
0.12,
)}`,
},
})}
>
{icon ? <Box aria-hidden="true">{icon}</Box> : null}
<Box sx={{ minWidth: 0, maxWidth: compact ? "none" : 380 }}>
<Typography
variant={compact ? "body2" : "subtitle1"}
sx={{
color: "text.primary",
fontWeight: 600,
lineHeight: compact ? 1.5 : 1.55,
}}
>
{title}
</Typography>
{description ? (
<Typography
variant={compact ? "caption" : "body2"}
sx={{
display: "block",
mt: compact ? 0.25 : 0.75,
color: "text.secondary",
lineHeight: compact ? 1.6 : 1.7,
}}
>
{description}
</Typography>
) : null}
</Box>
</Box>
);
};
export interface SchemeQueryEmptyStateProps {
hasQueried: boolean;
}
export const SchemeQueryEmptyState = ({
hasQueried,
}: SchemeQueryEmptyStateProps) => (
<PanelEmptyState
icon={<SearchOffOutlined />}
title={hasQueried ? "未找到符合条件的方案" : "尚未查询历史方案"}
description={
hasQueried
? "请调整查询条件后重新查询,或先创建一个新方案。"
: "设置查询条件后点击“查询”,这里将显示可查看的历史方案。"
}
/>
);
export default PanelEmptyState;
@@ -36,6 +36,7 @@ import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -413,11 +414,11 @@ const emptyStateMessages: Record<
> = {
chart: {
title: "暂无时序数据",
subtitle: "请切换时间段来获取曲线",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
},
table: {
title: "暂无表格数据",
subtitle: "请切换时间段来获取记录",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
},
};
@@ -613,28 +614,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const renderEmpty = () => {
if (!hasDevices) {
return (
<PanelEmptyState
icon={<ShowChart />}
title="尚未选择地图要素"
description="请在地图上选择一个要素以查看历史数据。"
/>
);
}
const message = emptyStateMessages[activeTab];
return (
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 8,
color: "text.secondary",
height: "100%",
}}
>
<ShowChart sx={{ fontSize: 64, mb: 2, opacity: 0.3 }} />
<Typography variant="h6" gutterBottom sx={{ fontWeight: 500 }}>
{message.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{message.subtitle}
</Typography>
</Box>
<PanelEmptyState
icon={activeTab === "chart" ? <ShowChart /> : <TableChart />}
title={message.title}
description={message.subtitle}
/>
);
};
@@ -1048,15 +1044,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Stack>
</LocalizationProvider>
{!hasDevices && (
<Typography
variant="caption"
color="warning.main"
sx={{ mt: 1, display: "block" }}
>
</Typography>
)}
{error && (
<Typography
variant="caption"
@@ -2,7 +2,7 @@
import React, { useRef, useState } from "react";
import Draggable from "react-draggable";
import { Close } from "@mui/icons-material";
import { Close, InfoOutlined } from "@mui/icons-material";
import {
Button,
CircularProgress,
@@ -14,6 +14,7 @@ import {
Tooltip,
} from "@mui/material";
import type { SelectChangeEvent } from "@mui/material/Select";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface BaseProperty {
label: string;
@@ -162,23 +163,11 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{!id ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg
className="w-16 h-16 mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
<PanelEmptyState
icon={<InfoOutlined />}
title="尚未选择地图要素"
description="请在地图上选择管道、节点或设备以查看属性。"
/>
) : (
<div className="space-y-2">
{/* ID 属性 */}