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 { useMap } from "@components/olmap/core/MapComponent";
import { queryFeaturesByIds } from "@/utils/mapQueryService"; import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { BurstDetectionResult, BurstDetectionRow } from "./types"; import { BurstDetectionResult, BurstDetectionRow } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface BurstDetectionResultsState { export interface BurstDetectionResultsState {
selectedDay: number | null; selectedDay: number | null;
@@ -73,17 +74,11 @@ const formatDateTime = (value?: string) =>
value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-"; value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-";
const EmptyState = () => ( const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> <PanelEmptyState
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm"> icon={<ShowChartIcon />}
<ShowChartIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> title="尚未生成侦测结果"
</Box> description="请在“侦测参数”中运行分析,或在“方案查询”中打开历史结果。"
<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>
); );
const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => { const DetectionResults: React.FC<Props> = ({ result, state, onStateChange }) => {
@@ -24,6 +24,7 @@ import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config"; import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
import { import {
BurstDetectionResult, BurstDetectionResult,
BurstDetectionSchemeDetail, BurstDetectionSchemeDetail,
@@ -42,6 +43,7 @@ export interface BurstDetectionSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: number | null;
hasQueried: boolean;
} }
export const createBurstDetectionSchemeQueryState = export const createBurstDetectionSchemeQueryState =
@@ -49,6 +51,7 @@ export const createBurstDetectionSchemeQueryState =
queryAll: true, queryAll: true,
queryDate: dayjs(), queryDate: dayjs(),
expandedId: null, expandedId: null,
hasQueried: false,
}); });
const SchemeQuery: React.FC<Props> = ({ const SchemeQuery: React.FC<Props> = ({
@@ -64,7 +67,7 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange, onStateChange,
createBurstDetectionSchemeQueryState(), createBurstDetectionSchemeQueryState(),
); );
const { queryAll, queryDate, expandedId } = queryState; const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes; 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 response = await api.get("/api/v1/schemes", { params });
const nextSchemes = response.data as BurstDetectionSchemeRecord[]; const nextSchemes = response.data as BurstDetectionSchemeRecord[];
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
open?.({ open?.({
type: "success", type: "success",
message: "查询成功", message: "查询成功",
@@ -203,7 +207,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox <Checkbox
size="small" size="small"
checked={queryAll} checked={queryAll}
onChange={(event) => setQueryField("queryAll", event.target.checked)} onChange={(event) => {
setQueryField("queryAll", event.target.checked);
setQueryField("hasQueried", false);
}}
/> />
} }
label={<Typography variant="body2"></Typography>} label={<Typography variant="body2"></Typography>}
@@ -212,7 +219,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => setQueryField("queryDate", value)} onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll} disabled={queryAll}
format="YYYY-MM-DD" format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 180 } } }} slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
@@ -234,12 +244,7 @@ const SchemeQuery: React.FC<Props> = ({
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex h-full flex-col items-center justify-center text-center text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<Typography variant="body2"></Typography>
<Typography variant="caption" className="mt-1">
</Typography>
</Box>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="px-2 text-gray-500"> <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 { bbox, featureCollection } from "@turf/turf";
import { BurstCandidate, BurstLocationResult } from "./types"; import { BurstCandidate, BurstLocationResult } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface Props { interface Props {
result: BurstLocationResult | null; result: BurstLocationResult | null;
@@ -128,17 +129,11 @@ const MetricCard = ({ label, value, hint, tone }: MetricCardProps) => {
}; };
const EmptyState = () => ( const EmptyState = () => (
<Box className="flex h-full flex-col items-center justify-center bg-gray-50/50 p-6 text-center"> <PanelEmptyState
<Box className="mb-4 rounded-full bg-white p-6 shadow-sm"> icon={<MapIcon />}
<MapIcon sx={{ fontSize: 48, color: "#cbd5e1" }} /> title="尚未生成定位结果"
</Box> description="请在“定位参数”中运行分析,或在“方案查询”中打开历史结果。"
<Typography variant="h6" className="mb-1 font-bold text-gray-700"> />
</Typography>
<Typography variant="body2" className="max-w-xs text-gray-500">
</Typography>
</Box>
); );
const LocationResults: React.FC<Props> = ({ result }) => { const LocationResults: React.FC<Props> = ({ result }) => {
@@ -43,6 +43,7 @@ import {
} from "./types"; } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props { interface Props {
onViewResult: (result: BurstLocationResult) => void; onViewResult: (result: BurstLocationResult) => void;
@@ -57,6 +58,7 @@ export interface BurstLocationSchemeQueryState {
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: number | null;
simulationBurstIdsByName: Record<string, string[]>; simulationBurstIdsByName: Record<string, string[]>;
hasQueried: boolean;
} }
export const createBurstLocationSchemeQueryState = export const createBurstLocationSchemeQueryState =
@@ -65,6 +67,7 @@ export const createBurstLocationSchemeQueryState =
queryDate: dayjs(), queryDate: dayjs(),
expandedId: null, expandedId: null,
simulationBurstIdsByName: {}, simulationBurstIdsByName: {},
hasQueried: false,
}); });
const SchemeQuery: React.FC<Props> = ({ const SchemeQuery: React.FC<Props> = ({
@@ -83,7 +86,7 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange, onStateChange,
createBurstLocationSchemeQueryState(), createBurstLocationSchemeQueryState(),
); );
const { queryAll, queryDate, expandedId } = queryState; const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {}; const simulationBurstIdsByName = queryState.simulationBurstIdsByName ?? {};
const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<BurstSchemeRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -256,6 +259,7 @@ const SchemeQuery: React.FC<Props> = ({
), ),
), ),
); );
setQueryField("hasQueried", true);
open?.({ open?.({
type: "success", type: "success",
message: "查询成功", message: "查询成功",
@@ -320,7 +324,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox <Checkbox
size="small" size="small"
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
/> />
} }
label={<Typography variant="body2"></Typography>} label={<Typography variant="body2"></Typography>}
@@ -329,7 +336,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => setQueryField("queryDate", value)} onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll} disabled={queryAll}
format="YYYY-MM-DD" format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }} slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
@@ -350,39 +360,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<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>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
@@ -62,6 +62,24 @@ describe("AnalysisReport", () => {
document.body.classList.remove("burst-analysis-report-printing"); 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 () => { it("renders the selected scheme, pipe data, and matching valve result", async () => {
render( render(
<AnalysisReport <AnalysisReport
@@ -35,6 +35,7 @@ import {
type PipeDiameterMap, type PipeDiameterMap,
} from "./schemePipeDiameters"; } from "./schemePipeDiameters";
import { SchemeRecord, ValveIsolationResult } from "./types"; import { SchemeRecord, ValveIsolationResult } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface AnalysisReportProps { interface AnalysisReportProps {
scheme: SchemeRecord | null; scheme: SchemeRecord | null;
@@ -516,15 +517,11 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({
if (!scheme) { if (!scheme) {
return ( return (
<Box className="flex h-full flex-col items-center justify-center px-6 text-center"> <PanelEmptyState
<DescriptionOutlined sx={{ mb: 2, fontSize: 52, color: "#94a3b8" }} /> icon={<DescriptionOutlined />}
<Typography variant="h6" className="font-bold text-gray-700"> title="尚未选择分析方案"
description="请在“方案查询”中打开一个方案,再查看分析报告。"
</Typography> />
<Typography variant="body2" className="mt-2 text-gray-500">
</Typography>
</Box>
); );
} }
@@ -57,6 +57,7 @@ import {
type PipeDiameterMap, type PipeDiameterMap,
} from "./schemePipeDiameters"; } from "./schemePipeDiameters";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: SchemeRecord[]; schemes?: SchemeRecord[];
@@ -76,6 +77,7 @@ export interface BurstSchemeQueryState {
selectedDate: Date | undefined; selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined; timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null; expandedId: number | null;
hasQueried: boolean;
} }
export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
@@ -85,6 +87,7 @@ export const createBurstSchemeQueryState = (): BurstSchemeQueryState => ({
selectedDate: undefined, selectedDate: undefined,
timeRange: undefined, timeRange: undefined,
expandedId: null, expandedId: null,
hasQueried: false,
}); });
const SchemeQuery: React.FC<SchemeQueryProps> = ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -107,6 +110,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate, selectedDate,
timeRange, timeRange,
expandedId, expandedId,
hasQueried,
} = queryState; } = queryState;
const [highlightLayer, setHighlightLayer] = const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null); useState<VectorLayer<VectorSource> | null>(null);
@@ -185,6 +189,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeDetail: item.scheme_detail, schemeDetail: item.scheme_detail,
})); }));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
open?.({ open?.({
@@ -462,7 +467,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={ control={
<Checkbox <Checkbox
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small" size="small"
/> />
} }
@@ -475,9 +483,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
> >
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => onChange={(value) => {
value && dayjs.isDayjs(value) && setQueryField("queryDate", value) if (value && dayjs.isDayjs(value)) {
} setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD" format="YYYY-MM-DD"
disabled={queryAll} disabled={queryAll}
slotProps={{ slotProps={{
@@ -505,39 +516,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */} {/* 结果列表 */}
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{filteredSchemes.length === 0 ? ( {filteredSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<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>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-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 Timeline from "@components/olmap/core/Controls/Timeline";
import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types"; import { ContaminantSchemaItem, ContaminantSchemeRecord } from "./types";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: ContaminantSchemeRecord[]; schemes?: ContaminantSchemeRecord[];
@@ -60,6 +61,7 @@ export interface ContaminantSchemeQueryState {
selectedDate: Date | undefined; selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined; timeRange: { start: Date; end: Date } | undefined;
expandedId: number | null; expandedId: number | null;
hasQueried: boolean;
} }
export const createContaminantSchemeQueryState = export const createContaminantSchemeQueryState =
@@ -70,6 +72,7 @@ export const createContaminantSchemeQueryState =
selectedDate: undefined, selectedDate: undefined,
timeRange: undefined, timeRange: undefined,
expandedId: null, expandedId: null,
hasQueried: false,
}); });
const SchemeQuery: React.FC<SchemeQueryProps> = ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -92,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
selectedDate, selectedDate,
timeRange, timeRange,
expandedId, expandedId,
hasQueried,
} = queryState; } = queryState;
const [highlightLayer, setHighlightLayer] = const [highlightLayer, setHighlightLayer] =
useState<VectorLayer<VectorSource> | null>(null); useState<VectorLayer<VectorSource> | null>(null);
@@ -250,6 +254,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeDetail: item.scheme_detail, schemeDetail: item.scheme_detail,
})); }));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
open?.({ open?.({
@@ -351,7 +356,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={ control={
<Checkbox <Checkbox
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small" size="small"
/> />
} }
@@ -364,9 +372,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
> >
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => onChange={(value) => {
value && dayjs.isDayjs(value) && setQueryField("queryDate", value) if (value && dayjs.isDayjs(value)) {
} setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD" format="YYYY-MM-DD"
disabled={queryAll} disabled={queryAll}
slotProps={{ slotProps={{
@@ -393,39 +404,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{filteredSchemes.length === 0 ? ( {filteredSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<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>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
@@ -11,11 +11,15 @@ import {
TableHead, TableHead,
TableRow, TableRow,
} from "@mui/material"; } from "@mui/material";
import { FormatListBulleted } from "@mui/icons-material"; import {
FormatListBulleted,
TroubleshootOutlined,
} from "@mui/icons-material";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getAreaColor } from "./utils"; import { getAreaColor } from "./utils";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { LeakageResultDetail } from "./types"; import { LeakageResultDetail } from "./types";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface Props { interface Props {
result: LeakageResultDetail | null; result: LeakageResultDetail | null;
@@ -31,39 +35,11 @@ const RecognitionResults: React.FC<Props> = ({ result }) => {
if (!result || !sortedRows.length) { if (!result || !sortedRows.length) {
return ( return (
<Box className="flex flex-col items-center justify-center h-full text-gray-400 p-4"> <PanelEmptyState
<Box className="mb-4"> icon={<TroubleshootOutlined />}
<svg title="尚未生成识别结果"
width="80" description="请在“识别参数”中运行分析,或在“方案查询”中打开历史结果。"
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>
); );
} }
@@ -27,6 +27,7 @@ import { useControllableObjectState } from "@components/olmap/core/useControllab
import { LeakageResultDetail, LeakageSchemeRecord } from "./types"; import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface Props { interface Props {
onViewResult: (result: LeakageResultDetail) => void; onViewResult: (result: LeakageResultDetail) => void;
@@ -40,12 +41,14 @@ export interface DMALeakSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: number | null;
hasQueried: boolean;
} }
export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({ export const createDMALeakSchemeQueryState = (): DMALeakSchemeQueryState => ({
queryAll: true, queryAll: true,
queryDate: dayjs(), queryDate: dayjs(),
expandedId: null, expandedId: null,
hasQueried: false,
}); });
const SchemeQuery: React.FC<Props> = ({ const SchemeQuery: React.FC<Props> = ({
@@ -61,7 +64,7 @@ const SchemeQuery: React.FC<Props> = ({
onStateChange, onStateChange,
createDMALeakSchemeQueryState(), createDMALeakSchemeQueryState(),
); );
const { queryAll, queryDate, expandedId } = queryState; const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<LeakageSchemeRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const creatorName = useSchemeCreatorName(); const creatorName = useSchemeCreatorName();
@@ -93,6 +96,7 @@ const SchemeQuery: React.FC<Props> = ({
}); });
const nextSchemes = response.data as LeakageSchemeRecord[]; const nextSchemes = response.data as LeakageSchemeRecord[];
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (nextSchemes.length === 0) { if (nextSchemes.length === 0) {
open?.({ open?.({
type: "success", type: "success",
@@ -150,7 +154,10 @@ const SchemeQuery: React.FC<Props> = ({
<Checkbox <Checkbox
size="small" size="small"
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
/> />
} }
label={<Typography variant="body2"></Typography>} label={<Typography variant="body2"></Typography>}
@@ -159,7 +166,10 @@ const SchemeQuery: React.FC<Props> = ({
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => setQueryField("queryDate", value)} onChange={(value) => {
setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}}
disabled={queryAll} disabled={queryAll}
format="YYYY-MM-DD" format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 200 } } }} slotProps={{ textField: { size: "small", sx: { width: 200 } } }}
@@ -180,39 +190,7 @@ const SchemeQuery: React.FC<Props> = ({
</Box> </Box>
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<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>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-2"> <Typography variant="caption" className="text-gray-500 px-2">
@@ -11,7 +11,11 @@ import {
Alert, Alert,
Divider, Divider,
} from "@mui/material"; } 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 { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; 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 { api } from "@/lib/api";
import { config, NETWORK_NAME } from "@/config/config"; import { config, NETWORK_NAME } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface ValveItem { export interface ValveItem {
id: string; id: string;
@@ -383,9 +388,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
</Box> </Box>
))} ))}
{valves.length === 0 && ( {valves.length === 0 && (
<Typography variant="caption" className="text-gray-400 text-center py-20"> <PanelEmptyState
variant="compact"
</Typography> icon={<AdjustOutlined />}
title="尚未选择阀门"
description="点击“选择阀门”,然后在地图上添加。"
/>
)} )}
</Stack> </Stack>
</Box> </Box>
@@ -412,7 +420,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
💡 💡
</Box> </Box>
)} )}
<Stack spacing={1} className="h-12 overflow-auto"> <Stack spacing={1} className="min-h-16 overflow-auto">
{drainageNode && ( {drainageNode && (
<Box className="flex items-center gap-2 p-2 bg-gray-50 rounded"> <Box className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography> <Typography className="text-sm flex-1 pl-1">{drainageNode}</Typography>
@@ -428,9 +436,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
</Box> </Box>
)} )}
{!drainageNode && ( {!drainageNode && (
<Typography variant="caption" className="text-gray-400 text-center py-2"> <PanelEmptyState
variant="compact"
</Typography> icon={<WaterDropOutlined />}
title="尚未选择排水节点"
description="点击“选择节点”,然后在地图上指定排水点。"
/>
)} )}
</Stack> </Stack>
</Box> </Box>
@@ -43,6 +43,7 @@ import Timeline from "@components/olmap/core/Controls/Timeline";
import { SchemeRecord, SchemaItem } from "./types"; import { SchemeRecord, SchemaItem } from "./types";
import { FLOW_DISPLAY_UNIT } from "@utils/units"; import { FLOW_DISPLAY_UNIT } from "@utils/units";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeQueryProps { interface SchemeQueryProps {
schemes?: SchemeRecord[]; schemes?: SchemeRecord[];
@@ -61,6 +62,7 @@ export interface FlushingSchemeQueryState {
showTimeline: boolean; showTimeline: boolean;
selectedDate: Date | undefined; selectedDate: Date | undefined;
timeRange: { start: Date; end: Date } | undefined; timeRange: { start: Date; end: Date } | undefined;
hasQueried: boolean;
} }
export const createFlushingSchemeQueryState = export const createFlushingSchemeQueryState =
@@ -71,6 +73,7 @@ export const createFlushingSchemeQueryState =
showTimeline: false, showTimeline: false,
selectedDate: undefined, selectedDate: undefined,
timeRange: undefined, timeRange: undefined,
hasQueried: false,
}); });
const SchemeQuery: React.FC<SchemeQueryProps> = ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -92,6 +95,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
showTimeline, showTimeline,
selectedDate, selectedDate,
timeRange, timeRange,
hasQueried,
} = queryState; } = queryState;
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
@@ -298,6 +302,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
schemeDetail: item.scheme_detail, schemeDetail: item.scheme_detail,
})); }));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
open?.({ open?.({
@@ -371,7 +376,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={ control={
<Checkbox <Checkbox
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small" size="small"
/> />
} }
@@ -384,9 +392,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
> >
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => onChange={(value) => {
value && dayjs.isDayjs(value) && setQueryField("queryDate", value) if (value && dayjs.isDayjs(value)) {
} setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD" format="YYYY-MM-DD"
disabled={queryAll} disabled={queryAll}
slotProps={{ slotProps={{
@@ -414,39 +425,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* Results List */} {/* Results List */}
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<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>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-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 { useHealthRisk } from "./HealthRiskContext";
import { useProject } from "@/contexts/ProjectContext"; import { useProject } from "@/contexts/ProjectContext";
import HealthRiskReport from "./HealthRiskReport"; import HealthRiskReport from "./HealthRiskReport";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
const SIMPLE_LABELS = [ const SIMPLE_LABELS = [
"0.0 - 0.1", "0.0 - 0.1",
@@ -229,24 +230,11 @@ const HealthRiskStatistics: React.FC = () => {
}; };
const renderEmpty = () => ( const renderEmpty = () => (
<Box <PanelEmptyState
sx={{ icon={<BarChart />}
display: "flex", title="暂无风险预测结果"
flexDirection: "column", description="请先运行健康风险预测,完成后将在这里展示风险分布统计。"
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>
); );
return ( return (
@@ -8,6 +8,7 @@ import { ShowChart } from "@mui/icons-material";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import { useHealthRisk } from "./HealthRiskContext"; import { useHealthRisk } from "./HealthRiskContext";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
export interface PredictDataPanelProps { export interface PredictDataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */ /** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
@@ -58,26 +59,11 @@ const PredictDataPanel: React.FC<PredictDataPanelProps> = ({
}, [filteredResults]); }, [filteredResults]);
const renderEmpty = () => ( const renderEmpty = () => (
<Box <PanelEmptyState
sx={{ icon={<ShowChart />}
flex: 1, title="暂无可展示的管道预测"
display: "flex", description="请先在地图上选择已完成健康风险分析的管道。"
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>
); );
const renderChart = () => { const renderChart = () => {
@@ -38,6 +38,7 @@ import { Style, Icon, Circle, Fill, Stroke } from "ol/style";
import Feature, { FeatureLike } from "ol/Feature"; import Feature, { FeatureLike } from "ol/Feature";
import { bbox, featureCollection } from "@turf/turf"; import { bbox, featureCollection } from "@turf/turf";
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName"; import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
interface SchemeRecord { interface SchemeRecord {
id: number; id: number;
@@ -72,6 +73,7 @@ export interface MonitoringSchemeQueryState {
queryAll: boolean; queryAll: boolean;
queryDate: Dayjs | null; queryDate: Dayjs | null;
expandedId: number | null; expandedId: number | null;
hasQueried: boolean;
} }
export const createMonitoringSchemeQueryState = export const createMonitoringSchemeQueryState =
@@ -79,6 +81,7 @@ export const createMonitoringSchemeQueryState =
queryAll: true, queryAll: true,
queryDate: dayjs(new Date()), queryDate: dayjs(new Date()),
expandedId: null, expandedId: null,
hasQueried: false,
}); });
const SchemeQuery: React.FC<SchemeQueryProps> = ({ const SchemeQuery: React.FC<SchemeQueryProps> = ({
@@ -94,7 +97,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
onStateChange, onStateChange,
createMonitoringSchemeQueryState(), createMonitoringSchemeQueryState(),
); );
const { queryAll, queryDate, expandedId } = queryState; const { queryAll, queryDate, expandedId, hasQueried } = queryState;
const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]); const [internalSchemes, setInternalSchemes] = useState<SchemeRecord[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const creatorName = useSchemeCreatorName(); const creatorName = useSchemeCreatorName();
@@ -207,6 +210,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
sensorLocation: item.sensor_location, sensorLocation: item.sensor_location,
})); }));
setSchemes(nextSchemes); setSchemes(nextSchemes);
setQueryField("hasQueried", true);
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
open?.({ open?.({
@@ -290,7 +294,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
control={ control={
<Checkbox <Checkbox
checked={queryAll} checked={queryAll}
onChange={(e) => setQueryField("queryAll", e.target.checked)} onChange={(e) => {
setQueryField("queryAll", e.target.checked);
setQueryField("hasQueried", false);
}}
size="small" size="small"
/> />
} }
@@ -303,9 +310,12 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
> >
<DatePicker <DatePicker
value={queryDate} value={queryDate}
onChange={(value) => onChange={(value) => {
value && dayjs.isDayjs(value) && setQueryField("queryDate", value) if (value && dayjs.isDayjs(value)) {
} setQueryField("queryDate", value);
setQueryField("hasQueried", false);
}
}}
format="YYYY-MM-DD" format="YYYY-MM-DD"
disabled={queryAll} disabled={queryAll}
slotProps={{ slotProps={{
@@ -333,39 +343,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
{/* 结果列表 */} {/* 结果列表 */}
<Box className="flex-1 overflow-auto"> <Box className="flex-1 overflow-auto">
{sortedSchemes.length === 0 ? ( {sortedSchemes.length === 0 ? (
<Box className="flex flex-col items-center justify-center h-full text-gray-400"> <SchemeQueryEmptyState hasQueried={hasQueried} />
<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>
) : ( ) : (
<Box className="space-y-2 p-2"> <Box className="space-y-2 p-2">
<Typography variant="caption" className="text-gray-500 px-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 { useNotification } from "@refinedev/core";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@@ -314,11 +315,11 @@ const emptyStateMessages: Record<
> = { > = {
chart: { chart: {
title: "暂无时序数据", title: "暂无时序数据",
subtitle: "请切换时间段来获取曲线", subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
}, },
table: { table: {
title: "暂无表格数据", title: "暂无表格数据",
subtitle: "请切换时间段来获取记录", subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
}, },
}; };
@@ -721,28 +722,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
); );
const renderEmpty = () => { const renderEmpty = () => {
if (!hasDevices) {
return (
<PanelEmptyState
icon={<ShowChart />}
title="尚未选择设备"
description="请先在设备列表中选择需要查看的 SCADA 设备。"
/>
);
}
const message = emptyStateMessages[activeTab]; const message = emptyStateMessages[activeTab];
return ( return (
<Box <PanelEmptyState
sx={{ icon={activeTab === "chart" ? <ShowChart /> : <TableChart />}
flex: 1, title={message.title}
display: "flex", description={message.subtitle}
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>
); );
}; };
@@ -1254,15 +1250,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Stack> </Stack>
</LocalizationProvider> </LocalizationProvider>
{!hasDevices && (
<Typography
variant="caption"
color="warning.main"
sx={{ mt: 1, display: "block" }}
>
</Typography>
)}
{error && ( {error && (
<Typography <Typography
variant="caption" variant="caption"
+16 -14
View File
@@ -68,6 +68,7 @@ import dayjs, { Dayjs } from "dayjs";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
const STATUS_OPTIONS: { const STATUS_OPTIONS: {
value: "online" | "offline" | "warning" | "error"; value: "online" | "offline" | "warning" | "error";
@@ -1067,22 +1068,23 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : filteredDevices.length === 0 ? ( ) : filteredDevices.length === 0 ? (
<Box <PanelEmptyState
sx={{ icon={<DeviceHub />}
p: 4, title={
textAlign: "center", searchQuery ||
color: "text.secondary",
}}
>
<DeviceHub sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="body2">
{searchQuery ||
selectedType !== "all" || selectedType !== "all" ||
selectedStatus !== "all" selectedStatus !== "all"
? "未找到匹配设备" ? "未找到匹配设备"
: "暂无 SCADA 设备"} : "暂无 SCADA 设备"
</Typography> }
</Box> description={
searchQuery ||
selectedType !== "all" ||
selectedStatus !== "all"
? "请清除关键词,或调整设备类型和状态筛选条件。"
: "请确认设备已接入当前管网后刷新列表。"
}
/>
) : ( ) : (
<FixedSizeList <FixedSizeList
height={listHeight} 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 { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales";
import config from "@/config/config"; import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@@ -413,11 +414,11 @@ const emptyStateMessages: Record<
> = { > = {
chart: { chart: {
title: "暂无时序数据", title: "暂无时序数据",
subtitle: "请切换时间段来获取曲线", subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
}, },
table: { table: {
title: "暂无表格数据", title: "暂无表格数据",
subtitle: "请切换时间段来获取记录", subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
}, },
}; };
@@ -613,28 +614,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
); );
const renderEmpty = () => { const renderEmpty = () => {
if (!hasDevices) {
return (
<PanelEmptyState
icon={<ShowChart />}
title="尚未选择地图要素"
description="请在地图上选择一个要素以查看历史数据。"
/>
);
}
const message = emptyStateMessages[activeTab]; const message = emptyStateMessages[activeTab];
return ( return (
<Box <PanelEmptyState
sx={{ icon={activeTab === "chart" ? <ShowChart /> : <TableChart />}
flex: 1, title={message.title}
display: "flex", description={message.subtitle}
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>
); );
}; };
@@ -1048,15 +1044,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Stack> </Stack>
</LocalizationProvider> </LocalizationProvider>
{!hasDevices && (
<Typography
variant="caption"
color="warning.main"
sx={{ mt: 1, display: "block" }}
>
</Typography>
)}
{error && ( {error && (
<Typography <Typography
variant="caption" variant="caption"
@@ -2,7 +2,7 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import Draggable from "react-draggable"; import Draggable from "react-draggable";
import { Close } from "@mui/icons-material"; import { Close, InfoOutlined } from "@mui/icons-material";
import { import {
Button, Button,
CircularProgress, CircularProgress,
@@ -14,6 +14,7 @@ import {
Tooltip, Tooltip,
} from "@mui/material"; } from "@mui/material";
import type { SelectChangeEvent } from "@mui/material/Select"; import type { SelectChangeEvent } from "@mui/material/Select";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
interface BaseProperty { interface BaseProperty {
label: string; label: string;
@@ -162,23 +163,11 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
{/* 内容区域 */} {/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-4 py-3"> <div className="flex-1 overflow-y-auto px-4 py-3">
{!id ? ( {!id ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400"> <PanelEmptyState
<svg icon={<InfoOutlined />}
className="w-16 h-16 mb-3" title="尚未选择地图要素"
fill="none" description="请在地图上选择管道、节点或设备以查看属性。"
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>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{/* ID 属性 */} {/* ID 属性 */}