472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import React, { useMemo, useState, useCallback } from "react";
|
||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||
import {
|
||
Box,
|
||
Button,
|
||
CircularProgress,
|
||
Collapse,
|
||
FormControl,
|
||
MenuItem,
|
||
Select,
|
||
TextField,
|
||
Typography,
|
||
IconButton,
|
||
} from "@mui/material";
|
||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||
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";
|
||
import { useNotification } from "@refinedev/core";
|
||
import dayjs, { Dayjs } from "dayjs";
|
||
import "dayjs/locale/zh-cn";
|
||
import { api } from "@/lib/api";
|
||
import { NETWORK_NAME, config } from "@config/config";
|
||
import { BurstDetectionResult } from "./types";
|
||
|
||
interface Props {
|
||
onResult: (result: BurstDetectionResult) => void;
|
||
}
|
||
|
||
interface SchemeItem {
|
||
scheme_id: number;
|
||
scheme_name: string;
|
||
scheme_type: string;
|
||
create_time: string;
|
||
scheme_start_time: string;
|
||
scheme_detail?: {
|
||
modify_total_duration: number;
|
||
};
|
||
}
|
||
|
||
const AnalysisParameters: React.FC<Props> = ({ onResult }) => {
|
||
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 [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";
|
||
|
||
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
||
const start = dayjs(scheme.scheme_start_time);
|
||
const durationSeconds = scheme.scheme_detail?.modify_total_duration ?? 3600;
|
||
const end = start.add(durationSeconds, "second");
|
||
|
||
setScadaStart(start);
|
||
setScadaEnd(end);
|
||
}, []);
|
||
|
||
const fetchSchemes = useCallback(
|
||
async ({ force = false, notify = false }: { force?: boolean; notify?: boolean } = {}) => {
|
||
if (schemeLoading || (!force && schemes.length > 0)) return;
|
||
|
||
setSchemeLoading(true);
|
||
try {
|
||
const response = await api.get(`${config.BACKEND_URL}/api/v1/getallschemes/`, {
|
||
params: { network: NETWORK_NAME },
|
||
});
|
||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||
(scheme) => scheme.scheme_type === "burst_analysis",
|
||
);
|
||
|
||
setSchemes(burstSchemes);
|
||
|
||
if (selectedSchemeId) {
|
||
const matchedScheme = burstSchemes.find(
|
||
(scheme) => scheme.scheme_id === selectedSchemeId,
|
||
);
|
||
if (matchedScheme) {
|
||
applySchemeTimeRange(matchedScheme);
|
||
} else {
|
||
setSelectedSchemeId("");
|
||
}
|
||
}
|
||
|
||
if (notify) {
|
||
open?.({
|
||
type: "success",
|
||
message: "方案列表已刷新",
|
||
description: `当前可选爆管分析方案 ${burstSchemes.length} 个`,
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
open?.({
|
||
type: "error",
|
||
message: "刷新方案失败",
|
||
description:
|
||
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表",
|
||
});
|
||
} finally {
|
||
setSchemeLoading(false);
|
||
}
|
||
},
|
||
[applySchemeTimeRange, open, schemeLoading, schemes.length, selectedSchemeId],
|
||
);
|
||
|
||
const handleDataSourceChange = (value: "monitoring" | "simulation") => {
|
||
setDataSource(value);
|
||
if (value === "simulation") {
|
||
void fetchSchemes();
|
||
}
|
||
};
|
||
|
||
const handleSchemeSelect = (schemeId: number) => {
|
||
setSelectedSchemeId(schemeId);
|
||
const scheme = schemes.find((item) => item.scheme_id === schemeId);
|
||
if (scheme) {
|
||
applySchemeTimeRange(scheme);
|
||
}
|
||
};
|
||
|
||
const timeWindowValid = useMemo(() => {
|
||
if (!scadaStart || !scadaEnd) return false;
|
||
return scadaEnd.diff(scadaStart, "day", true) >= 2;
|
||
}, [scadaEnd, scadaStart]);
|
||
|
||
const contaminationValue = useMemo(() => {
|
||
const normalized = contaminationInput.trim().toLowerCase();
|
||
if (!normalized || normalized === "auto") {
|
||
return "auto" as const;
|
||
}
|
||
const parsed = Number(normalized);
|
||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 0.5) {
|
||
return null;
|
||
}
|
||
return parsed;
|
||
}, [contaminationInput]);
|
||
|
||
const isValid =
|
||
Boolean(scadaStart && scadaEnd) &&
|
||
timeWindowValid &&
|
||
Number.isFinite(mu) &&
|
||
mu > 0 &&
|
||
Number.isFinite(pointsPerDay) &&
|
||
pointsPerDay > 0 &&
|
||
Number.isFinite(nEstimators) &&
|
||
nEstimators > 0 &&
|
||
contaminationValue !== null &&
|
||
(dataSource !== "simulation" || Boolean(selectedSchemeId));
|
||
|
||
const handleRun = async () => {
|
||
if (!isValid || !scadaStart || !scadaEnd || contaminationValue === null) {
|
||
open?.({
|
||
type: "error",
|
||
message: "参数不完整",
|
||
description: "请检查时间范围(至少2天)和高级参数是否填写正确。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
setRunning(true);
|
||
open?.({
|
||
key: "burst-detection-analysis-progress",
|
||
type: "progress",
|
||
message: "正在执行爆管侦测",
|
||
description: "正在读取数据并计算异常分数。",
|
||
undoableTimeout: 3,
|
||
});
|
||
|
||
try {
|
||
const selectedScheme =
|
||
dataSource === "simulation"
|
||
? schemes.find((item) => item.scheme_id === selectedSchemeId)
|
||
: undefined;
|
||
|
||
const response = await api.post("/api/v1/burst-detection/detect/", {
|
||
network: NETWORK_NAME,
|
||
data_source: dataSource,
|
||
scheme_name: schemeName.trim() || undefined,
|
||
scada_start: scadaStart.toISOString(),
|
||
scada_end: scadaEnd.toISOString(),
|
||
mu,
|
||
points_per_day: pointsPerDay,
|
||
iforest_params: {
|
||
n_estimators: nEstimators,
|
||
contamination: contaminationValue,
|
||
},
|
||
simulation_scheme_name: selectedScheme?.scheme_name,
|
||
simulation_scheme_type: selectedScheme?.scheme_type,
|
||
});
|
||
|
||
onResult({
|
||
...(response.data as BurstDetectionResult),
|
||
scheme_name: schemeName.trim() || (response.data as BurstDetectionResult).scheme_name,
|
||
algorithm_params: {
|
||
mu,
|
||
points_per_day: pointsPerDay,
|
||
iforest_params: {
|
||
n_estimators: nEstimators,
|
||
contamination: contaminationValue,
|
||
},
|
||
},
|
||
});
|
||
|
||
open?.({
|
||
key: "burst-detection-analysis-success",
|
||
type: "success",
|
||
message: "爆管侦测完成",
|
||
description: `共识别 ${response.data.summary?.anomaly_day_count ?? 0} 个异常日。`,
|
||
});
|
||
} catch (error: any) {
|
||
open?.({
|
||
key: "burst-detection-analysis-error",
|
||
type: "error",
|
||
message: "侦测失败",
|
||
description: error?.response?.data?.detail ?? error?.message ?? "请求失败",
|
||
});
|
||
} finally {
|
||
setRunning(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Box className="flex flex-col flex-1 min-h-0">
|
||
<Box className="flex flex-col gap-3">
|
||
<Box>
|
||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||
方案名称
|
||
</Typography>
|
||
<TextField
|
||
value={schemeName}
|
||
onChange={(event) => setSchemeName(event.target.value)}
|
||
placeholder="请输入方案名称"
|
||
fullWidth
|
||
size="small"
|
||
/>
|
||
</Box>
|
||
|
||
<Box>
|
||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||
数据来源
|
||
</Typography>
|
||
<FormControl fullWidth size="small">
|
||
<Select
|
||
value={dataSource}
|
||
onChange={(e) => handleDataSourceChange(e.target.value as "monitoring" | "simulation")}
|
||
>
|
||
<MenuItem value="monitoring">监测数据</MenuItem>
|
||
<MenuItem value="simulation">模拟方案</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
</Box>
|
||
|
||
{isSimulationMode && (
|
||
<Box>
|
||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||
选择爆管分析方案
|
||
</Typography>
|
||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||
<FormControl fullWidth size="small">
|
||
<Select
|
||
value={selectedSchemeId}
|
||
onChange={(e) => handleSchemeSelect(Number(e.target.value))}
|
||
disabled={schemeLoading}
|
||
displayEmpty
|
||
>
|
||
<MenuItem value="" disabled>
|
||
请选择方案
|
||
</MenuItem>
|
||
{schemes.map((scheme) => (
|
||
<MenuItem key={scheme.scheme_id} value={scheme.scheme_id}>
|
||
{scheme.scheme_name}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
<IconButton
|
||
size="small"
|
||
color="primary"
|
||
onClick={() => void fetchSchemes({ force: true, notify: true })}
|
||
disabled={schemeLoading}
|
||
aria-label="刷新爆管分析方案"
|
||
sx={{
|
||
border: "1px solid",
|
||
borderColor: "divider",
|
||
borderRadius: 1,
|
||
}}
|
||
>
|
||
{schemeLoading ? (
|
||
<CircularProgress size={18} color="inherit" />
|
||
) : (
|
||
<RefreshIcon fontSize="small" />
|
||
)}
|
||
</IconButton>
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
|
||
<LocalizationProvider
|
||
dateAdapter={AdapterDayjs}
|
||
adapterLocale="zh-cn"
|
||
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
|
||
>
|
||
<Box className="grid grid-cols-2 gap-2">
|
||
<Box>
|
||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||
侦测开始时间
|
||
</Typography>
|
||
<DateTimePicker
|
||
value={scadaStart}
|
||
onChange={setScadaStart}
|
||
maxDateTime={scadaEnd ? scadaEnd.subtract(2, "day") : undefined}
|
||
disabled={isSimulationMode}
|
||
format="YYYY-MM-DD HH:mm"
|
||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||
/>
|
||
</Box>
|
||
<Box>
|
||
<Typography variant="subtitle2" className="mb-1 font-medium">
|
||
侦测结束时间
|
||
</Typography>
|
||
<DateTimePicker
|
||
value={scadaEnd}
|
||
onChange={setScadaEnd}
|
||
minDateTime={scadaStart ? scadaStart.add(2, "day") : undefined}
|
||
disabled={isSimulationMode}
|
||
format="YYYY-MM-DD HH:mm"
|
||
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
</LocalizationProvider>
|
||
|
||
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
|
||
当前页面为展示版:手动触发一次侦测,展示异常日、最新测点排名和结果表格,不做定时轮询。
|
||
</Box>
|
||
|
||
<Box
|
||
sx={{
|
||
border: "1px solid",
|
||
borderColor: "grey.200",
|
||
borderRadius: 1,
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<Box
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
setAdvancedOpen((prev) => !prev);
|
||
}
|
||
}}
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
px: 1.25,
|
||
py: 0.75,
|
||
cursor: "pointer",
|
||
backgroundColor: "transparent",
|
||
"&:hover": { backgroundColor: "action.hover" },
|
||
}}
|
||
>
|
||
<Typography variant="body2" color="text.secondary">
|
||
高级参数
|
||
</Typography>
|
||
<ExpandMoreIcon
|
||
sx={{
|
||
transform: advancedOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||
transition: "transform 0.2s ease",
|
||
}}
|
||
/>
|
||
</Box>
|
||
<Collapse in={advancedOpen} timeout="auto" unmountOnExit>
|
||
<Box
|
||
sx={{
|
||
px: 1.25,
|
||
pt: 1.25,
|
||
pb: 1.25,
|
||
backgroundColor: "transparent",
|
||
}}
|
||
>
|
||
<Box className="flex flex-col gap-3">
|
||
<TextField
|
||
type="number"
|
||
label="频域截断系数"
|
||
value={mu}
|
||
onChange={(event) => setMu(Number(event.target.value))}
|
||
size="small"
|
||
fullWidth
|
||
inputProps={{ min: 1 }}
|
||
/>
|
||
<TextField
|
||
type="number"
|
||
label="每日采样点数"
|
||
value={pointsPerDay}
|
||
onChange={(event) => setPointsPerDay(Number(event.target.value))}
|
||
size="small"
|
||
fullWidth
|
||
inputProps={{ min: 1 }}
|
||
/>
|
||
<TextField
|
||
type="number"
|
||
label="孤立森林树数量"
|
||
value={nEstimators}
|
||
onChange={(event) => setNEstimators(Number(event.target.value))}
|
||
size="small"
|
||
fullWidth
|
||
inputProps={{ min: 1 }}
|
||
/>
|
||
<TextField
|
||
label="异常比例"
|
||
value={contaminationInput}
|
||
onChange={(event) => setContaminationInput(event.target.value)}
|
||
size="small"
|
||
fullWidth
|
||
helperText="填写 auto 或 0~0.5 之间的小数。"
|
||
error={contaminationValue === null}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
</Collapse>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Box className="mt-auto pt-3 flex gap-2">
|
||
<Button
|
||
variant="outlined"
|
||
fullWidth
|
||
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");
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
<Button
|
||
variant="contained"
|
||
fullWidth
|
||
disabled={!isValid || running}
|
||
onClick={handleRun}
|
||
className="bg-blue-600 hover:bg-blue-700"
|
||
sx={{ textTransform: "none", fontWeight: 500 }}
|
||
>
|
||
{running ? <CircularProgress size={20} color="inherit" /> : "开始侦测"}
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export default AnalysisParameters;
|