fix(analysis): preserve tab panel state

This commit is contained in:
2026-07-09 13:59:01 +08:00
parent 701c5a949d
commit 694f7629ee
24 changed files with 1119 additions and 281 deletions
@@ -24,13 +24,16 @@ import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/zh-cn";
import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { BurstDetectionResult } from "./types";
interface Props {
onResult: (result: BurstDetectionResult) => void;
state?: BurstDetectionAnalysisParametersState;
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void;
}
interface SchemeItem {
export interface SchemeItem {
scheme_id: number;
scheme_name: string;
scheme_type: string;
@@ -41,20 +44,60 @@ interface SchemeItem {
};
}
const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
export interface BurstDetectionAnalysisParametersState {
schemeName: string;
dataSource: "monitoring" | "simulation";
schemes: SchemeItem[];
selectedSchemeId: number | "";
scadaStart: Dayjs | null;
scadaEnd: Dayjs | null;
mu: number;
pointsPerDay: number;
nEstimators: number;
contaminationInput: string;
advancedOpen: boolean;
}
export const createBurstDetectionAnalysisParametersState =
(): BurstDetectionAnalysisParametersState => ({
schemeName: `Burst_Detection_${Date.now()}`,
dataSource: "monitoring",
schemes: [],
selectedSchemeId: "",
scadaStart: dayjs().subtract(3, "day"),
scadaEnd: dayjs(),
mu: 100,
pointsPerDay: 96,
nEstimators: 50,
contaminationInput: "auto",
advancedOpen: false,
});
const AnalysisParameters: React.FC<Props> = ({
onResult,
state,
onStateChange,
}) => {
const { open } = useNotification();
const [schemeName, setSchemeName] = useState(`Burst_Detection_${Date.now()}`);
const [dataSource, setDataSource] = useState<"monitoring" | "simulation">("monitoring");
const [schemes, setSchemes] = useState<SchemeItem[]>([]);
const [selectedSchemeId, setSelectedSchemeId] = useState<number | "">("");
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
state,
onStateChange,
createBurstDetectionAnalysisParametersState(),
);
const {
schemeName,
dataSource,
schemes,
selectedSchemeId,
scadaStart,
scadaEnd,
mu,
pointsPerDay,
nEstimators,
contaminationInput,
advancedOpen,
} = parametersState;
const [schemeLoading, setSchemeLoading] = useState(false);
const [scadaStart, setScadaStart] = useState<Dayjs | null>(dayjs().subtract(3, "day"));
const [scadaEnd, setScadaEnd] = useState<Dayjs | null>(dayjs());
const [mu, setMu] = useState<number>(100);
const [pointsPerDay, setPointsPerDay] = useState<number>(96);
const [nEstimators, setNEstimators] = useState<number>(50);
const [contaminationInput, setContaminationInput] = useState<string>("auto");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [running, setRunning] = useState(false);
const isSimulationMode = dataSource === "simulation";
@@ -63,9 +106,12 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
const end = start.add(durationSeconds, "second");
setScadaStart(start);
setScadaEnd(end);
}, []);
setParametersState((previous) => ({
...previous,
scadaStart: start,
scadaEnd: end,
}));
}, [setParametersState]);
const fetchSchemes = useCallback(
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
@@ -80,7 +126,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
(scheme) => scheme.scheme_type === "burst_analysis",
);
setSchemes(burstSchemes);
setFormField("schemes", burstSchemes);
if (selectedSchemeId) {
const matchedScheme = burstSchemes.find(
@@ -89,7 +135,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
if (matchedScheme) {
applySchemeTimeRange(matchedScheme);
} else {
setSelectedSchemeId("");
setFormField("selectedSchemeId", "");
}
}
@@ -111,18 +157,18 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
setSchemeLoading(false);
}
},
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId],
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId, setFormField],
);
const handleDataSourceChange = (value: "monitoring" | "simulation") => {
setDataSource(value);
setFormField("dataSource", value);
if (value === "simulation") {
void fetchSchemes();
}
};
const handleSchemeSelect = (schemeId: number) => {
setSelectedSchemeId(schemeId);
setFormField("selectedSchemeId", schemeId);
const scheme = schemes.find((item) => item.scheme_id === schemeId);
if (scheme) {
applySchemeTimeRange(scheme);
@@ -239,7 +285,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
</Typography>
<TextField
value={schemeName}
onChange={(event) => setSchemeName(event.target.value)}
onChange={(event) => setFormField("schemeName", event.target.value)}
placeholder="请输入方案名称"
fullWidth
size="small"
@@ -318,7 +364,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
</Typography>
<DateTimePicker
value={scadaStart}
onChange={setScadaStart}
onChange={(value) => setFormField("scadaStart", value)}
maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined}
disabled={isSimulationMode}
format="YYYY-MM-DD HH:mm"
@@ -331,7 +377,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
</Typography>
<DateTimePicker
value={scadaEnd}
onChange={setScadaEnd}
onChange={(value) => setFormField("scadaEnd", value)}
minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined}
disabled={isSimulationMode}
format="YYYY-MM-DD HH:mm"
@@ -356,10 +402,10 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
<Box
role="button"
tabIndex={0}
onClick={() => setAdvancedOpen((prev) => !prev)}
onClick={() => setFormField("advancedOpen", !advancedOpen)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
setAdvancedOpen((prev) => !prev);
setFormField("advancedOpen", !advancedOpen);
}
}}
sx={{
@@ -397,7 +443,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
type="number"
label="频域截断系数"
value={mu}
onChange={(event) => setMu(Number(event.target.value))}
onChange={(event) => setFormField("mu", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
@@ -406,7 +452,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
type="number"
label="每日采样点数"
value={pointsPerDay}
onChange={(event) => setPointsPerDay(Number(event.target.value))}
onChange={(event) => setFormField("pointsPerDay", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
@@ -415,7 +461,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
type="number"
label="孤立森林树数量"
value={nEstimators}
onChange={(event) => setNEstimators(Number(event.target.value))}
onChange={(event) => setFormField("nEstimators", Number(event.target.value))}
size="small"
fullWidth
inputProps={{ min: 1 }}
@@ -423,7 +469,7 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
<TextField
label="异常比例"
value={contaminationInput}
onChange={(event) => setContaminationInput(event.target.value)}
onChange={(event) => setFormField("contaminationInput", event.target.value)}
size="small"
fullWidth
helperText="填写 auto 或 0~0.5 之间的小数。"
@@ -442,13 +488,16 @@ const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
disabled={running}
sx={{ textTransform: "none", fontWeight: 500 }}
onClick={() => {
setSchemeName(`Burst_Detection_${Date.now()}`);
setScadaStart(dayjs().subtract(3, "day"));
setScadaEnd(dayjs());
setMu(100);
setPointsPerDay(96);
setNEstimators(50);
setContaminationInput("auto");
setParametersState((previous) => ({
...previous,
schemeName: `Burst_Detection_${Date.now()}`,
scadaStart: dayjs().subtract(3, "day"),
scadaEnd: dayjs(),
mu: 100,
pointsPerDay: 96,
nEstimators: 50,
contaminationInput: "auto",
}));
}}
>
@@ -9,9 +9,18 @@ import {
FormatListBulleted,
Search as SearchIcon,
} from "@mui/icons-material";
import AnalysisParameters from "./AnalysisParameters";
import DetectionResults from "./DetectionResults";
import SchemeQuery from "./SchemeQuery";
import AnalysisParameters, {
createBurstDetectionAnalysisParametersState,
type BurstDetectionAnalysisParametersState,
} from "./AnalysisParameters";
import DetectionResults, {
createBurstDetectionResultsState,
type BurstDetectionResultsState,
} from "./DetectionResults";
import SchemeQuery, {
createBurstDetectionSchemeQueryState,
type BurstDetectionSchemeQueryState,
} from "./SchemeQuery";
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
const TabPanel = ({
@@ -33,6 +42,18 @@ const BurstDetectionPanel: React.FC = () => {
const [tab, setTab] = useState(0);
const [result, setResult] = useState<BurstDetectionResult | null>(null);
const [schemes, setSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
const [analysisState, setAnalysisState] =
useState<BurstDetectionAnalysisParametersState>(
createBurstDetectionAnalysisParametersState,
);
const [queryState, setQueryState] =
useState<BurstDetectionSchemeQueryState>(
createBurstDetectionSchemeQueryState,
);
const [resultsState, setResultsState] =
useState<BurstDetectionResultsState>(
createBurstDetectionResultsState,
);
const drawerWidth = 450;
const panelTitle = "爆管侦测";
@@ -137,13 +158,27 @@ const BurstDetectionPanel: React.FC = () => {
</Box>
<TabPanel value={tab} index={0}>
<AnalysisParameters onResult={handleResult} />
<AnalysisParameters
onResult={handleResult}
state={analysisState}
onStateChange={setAnalysisState}
/>
</TabPanel>
<TabPanel value={tab} index={1}>
<SchemeQuery onViewResult={handleResult} schemes={schemes} onSchemesChange={setSchemes} />
<SchemeQuery
onViewResult={handleResult}
schemes={schemes}
onSchemesChange={setSchemes}
state={queryState}
onStateChange={setQueryState}
/>
</TabPanel>
<TabPanel value={tab} index={2}>
<DetectionResults result={result} />
<DetectionResults
result={result}
state={resultsState}
onStateChange={setResultsState}
/>
</TabPanel>
</Box>
</Drawer>
@@ -24,8 +24,19 @@ import { Circle, Fill, Stroke, Style } from "ol/style";
import { bbox, featureCollection } from "@turf/turf";
import { BurstDetectionResult, BurstDetectionRow } from "./types";
export interface BurstDetectionResultsState {
selectedDay: number | null;
}
export const createBurstDetectionResultsState =
(): BurstDetectionResultsState => ({
selectedDay: null,
});
interface Props {
result: BurstDetectionResult | null;
state?: BurstDetectionResultsState;
onStateChange?: (state: BurstDetectionResultsState) => void;
}
interface MetricCardProps {
@@ -106,11 +117,25 @@ const getScoreLevel = (score: number) => {
const formatDateTime = (value?: string) => (value ? dayjs(value).format("YYYY-MM-DD HH:mm") : "-");
const DetectionResults: React.FC<Props> = ({ result }) => {
const DetectionResults: React.FC<Props> = ({
result,
state,
onStateChange,
}) => {
const map = useMap();
const highlightLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
const [selectedDay, setSelectedDay] = useState<number | null>(null);
const [internalResultsState, setInternalResultsState] =
useState<BurstDetectionResultsState>(createBurstDetectionResultsState);
const resultsState = state ?? internalResultsState;
const selectedDay = resultsState.selectedDay;
const setSelectedDay = (value: number | null) => {
const nextState = { ...resultsState, selectedDay: value };
if (state === undefined) {
setInternalResultsState(nextState);
}
onStateChange?.(nextState);
};
useEffect(() => {
if (!map) return;
@@ -23,6 +23,7 @@ import "dayjs/locale/zh-cn";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { NETWORK_NAME } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import {
BurstDetectionResult,
BurstDetectionSchemeDetail,
@@ -33,15 +34,39 @@ interface Props {
onViewResult: (result: BurstDetectionResult) => void;
schemes?: BurstDetectionSchemeRecord[];
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
state?: BurstDetectionSchemeQueryState;
onStateChange?: (state: BurstDetectionSchemeQueryState) => void;
}
const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes, onSchemesChange }) => {
export interface BurstDetectionSchemeQueryState {
queryAll: boolean;
queryDate: Dayjs | null;
expandedId: number | null;
}
export const createBurstDetectionSchemeQueryState =
(): BurstDetectionSchemeQueryState => ({
queryAll: true,
queryDate: dayjs(),
expandedId: null,
});
const SchemeQuery: React.FC<Props> = ({
onViewResult,
schemes: externalSchemes,
onSchemesChange,
state,
onStateChange,
}) => {
const { open } = useNotification();
const [queryAll, setQueryAll] = useState(true);
const [queryDate, setQueryDate] = useState<Dayjs | null>(dayjs());
const [queryState, , setQueryField] = useControllableObjectState(
state,
onStateChange,
createBurstDetectionSchemeQueryState(),
);
const { queryAll, queryDate, expandedId } = queryState;
const [internalSchemes, setInternalSchemes] = useState<BurstDetectionSchemeRecord[]>([]);
const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const schemes = externalSchemes !== undefined ? externalSchemes : internalSchemes;
const setSchemes = onSchemesChange || setInternalSchemes;
@@ -159,7 +184,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
<Checkbox
size="small"
checked={queryAll}
onChange={(event) => setQueryAll(event.target.checked)}
onChange={(event) => setQueryField("queryAll", event.target.checked)}
/>
}
label={<Typography variant="body2"></Typography>}
@@ -168,7 +193,7 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-cn">
<DatePicker
value={queryDate}
onChange={setQueryDate}
onChange={(value) => setQueryField("queryDate", value)}
disabled={queryAll}
format="YYYY-MM-DD"
slotProps={{ textField: { size: "small", sx: { width: 180 } } }}
@@ -241,7 +266,10 @@ const SchemeQuery: React.FC<Props> = ({ onViewResult, schemes: externalSchemes,
<IconButton
size="small"
onClick={() =>
setExpandedId(expandedId === scheme.scheme_id ? null : scheme.scheme_id)
setQueryField(
"expandedId",
expandedId === scheme.scheme_id ? null : scheme.scheme_id,
)
}
color="primary"
className="p-1"