Files
TJWaterFrontend_Refine/src/components/olmap/BurstDetection/AnalysisParameters.tsx
T
jiang 29b8babd68 fix(api): align frontend with project-scoped backend
Use project-scoped endpoints and run IDs for current project, SCADA metadata, historical time series, migrated DMA results, and sensor placement runs.

The regressions recurred because tests mocked pre-interceptor and new-only payload shapes, while history queries relied on implicit global scheme state and unbounded request fan-out. Cover post-interceptor pages, migrated result_rows, explicit run_id routing, multi-element requests, request limits, and cancellation.
2026-09-01 14:37:51 +08:00

364 lines
12 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
FormControl,
MenuItem,
Select,
TextField,
Typography,
} 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 } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { BurstDetectionResult } from "./types";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props {
onResult: (result: BurstDetectionResult) => void;
state?: BurstDetectionAnalysisParametersState;
onStateChange?: (state: BurstDetectionAnalysisParametersState) => void;
}
export interface BurstDetectionAnalysisParametersState {
schemeName: string;
detectionMode: "latest" | "historical";
targetTime: Dayjs | null;
samplingIntervalMinutes: number;
samplingIntervalSource: "metadata" | "manual";
}
interface ScadaInfoItem {
device_type?: string;
transmission_frequency?: string | number | null;
}
interface ScadaInfoResponse {
data?: ScadaInfoItem[];
}
const currentQuarterHour = () => {
const now = dayjs().second(0).millisecond(0);
return now.minute(Math.floor(now.minute() / 15) * 15);
};
export const createBurstDetectionAnalysisParametersState =
(): BurstDetectionAnalysisParametersState => ({
schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstDetection),
detectionMode: "latest",
targetTime: currentQuarterHour(),
samplingIntervalMinutes: 15,
samplingIntervalSource: "metadata",
});
export const parseScadaFrequencyMinutes = (
value: string | number | null | undefined,
): number | null => {
if (typeof value === "number") {
return Number.isInteger(value) && value > 0 ? value : null;
}
if (!value) return null;
const normalized = value.trim();
const dayMatch = normalized.match(/^(\d+)\s+days?,\s*(.+)$/i);
const days = dayMatch ? Number(dayMatch[1]) : 0;
const timePart = dayMatch ? dayMatch[2] : normalized;
const parts = timePart.split(":").map(Number);
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
return null;
}
const minutes = days * 1440 + parts[0] * 60 + parts[1] + parts[2] / 60;
return Number.isInteger(minutes) && minutes > 0 ? minutes : null;
};
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
const counts = new Map<number, number>();
items
.filter((item) => item.device_type?.toLowerCase() === "pressure")
.forEach((item) => {
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
if (minutes && 1440 % minutes === 0) {
counts.set(minutes, (counts.get(minutes) ?? 0) + 1);
}
});
return [...counts.entries()].sort(
([minutesA, countA], [minutesB, countB]) =>
countB - countA || minutesA - minutesB,
)[0]?.[0] ?? 15;
};
export const fetchPressureSamplingInterval = async () => {
const response = await api.get<ScadaInfoResponse>(
"/api/v1/scada-info/database-view",
);
return resolvePressureSamplingInterval(
Array.isArray(response.data.data) ? response.data.data : [],
);
};
export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState,
) => ({
scheme_name: normalizeSchemeName(parameters.schemeName),
sampling_interval_minutes: parameters.samplingIntervalMinutes,
...(parameters.detectionMode === "historical" && parameters.targetTime
? { target_time: parameters.targetTime.toISOString() }
: {}),
});
const AnalysisParameters: React.FC<Props> = ({
onResult,
state,
onStateChange,
}) => {
const { open } = useNotification();
const [parametersState, setParametersState, setFormField] =
useControllableObjectState(
state,
onStateChange,
createBurstDetectionAnalysisParametersState(),
);
const {
schemeName,
detectionMode,
targetTime,
samplingIntervalMinutes,
samplingIntervalSource,
} = parametersState;
const [running, setRunning] = useState(false);
const [frequencyLoading, setFrequencyLoading] = useState(false);
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
setParametersState({
...draft,
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
}),
);
useEffect(() => {
if (samplingIntervalSource !== "metadata") return;
let active = true;
setFrequencyLoading(true);
fetchPressureSamplingInterval()
.then((interval) => {
if (!active) return;
setParametersState((previous) =>
previous.samplingIntervalSource === "metadata"
? { ...previous, samplingIntervalMinutes: interval }
: previous,
);
})
.catch(() => {
// Keep the 15-minute fallback when SCADA metadata is unavailable.
})
.finally(() => {
if (active) setFrequencyLoading(false);
});
return () => {
active = false;
};
}, [samplingIntervalSource, setParametersState]);
const samplingIntervalValid =
Number.isInteger(samplingIntervalMinutes) &&
samplingIntervalMinutes > 0 &&
1440 % samplingIntervalMinutes === 0;
const isValid = useMemo(
() =>
isSchemeNameValid(schemeName) &&
samplingIntervalValid &&
(detectionMode === "latest" || Boolean(targetTime?.isValid())),
[detectionMode, samplingIntervalValid, schemeName, targetTime],
);
const handleRun = async () => {
if (!isValid) {
open?.({
type: "error",
message: "参数不完整",
description: "请输入方案名称,并检查历史目标时间。",
});
return;
}
setRunning(true);
open?.({
key: "burst-detection-analysis-progress",
type: "progress",
message: "正在执行爆管侦测",
description: "正在读取目标时刻及前 14 天同刻基线。",
undoableTimeout: 3,
});
try {
const response = await api.post(
"/api/v1/burst-detections",
buildBurstDetectionRequest(parametersState),
);
onResult(response.data as BurstDetectionResult);
open?.({
key: "burst-detection-analysis-success",
type: "success",
message: "爆管侦测完成",
description: response.data.summary?.burst_detected
? "目标时刻存在异常信号,请优先复核相关测点。"
: "目标时刻未发现爆管异常。",
});
} catch (error) {
open?.({
key: "burst-detection-analysis-error",
type: "error",
message: "侦测失败",
description: getApiErrorMessage(error, "爆管侦测请求失败"),
});
} finally {
setRunning(false);
}
};
return (
<Box className="flex min-h-0 flex-1 flex-col">
<Box className="flex flex-col gap-3">
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
方案名称
</Typography>
<TextField
value={schemeName}
onChange={(event) => setFormField("schemeName", event.target.value)}
placeholder="请输入方案名称"
error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH}
helperText={
schemeName.trim().length > SCHEME_NAME_MAX_LENGTH
? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符`
: undefined
}
inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }}
fullWidth
size="small"
/>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
侦测方式
</Typography>
<FormControl fullWidth size="small">
<Select
value={detectionMode}
onChange={(event) =>
setFormField(
"detectionMode",
event.target.value as "latest" | "historical",
)
}
>
<MenuItem value="latest">检测最新数据</MenuItem>
<MenuItem value="historical">历史时刻回放</MenuItem>
</Select>
</FormControl>
</Box>
{detectionMode === "historical" ? (
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-cn"
localeText={pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText}
>
<Box>
<Typography variant="subtitle2" className="mb-1 font-medium">
目标时刻
</Typography>
<DateTimePicker
value={targetTime}
onChange={(value) => setFormField("targetTime", value)}
maxDateTime={dayjs()}
minutesStep={15}
format="YYYY-MM-DD HH:mm"
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Box>
</LocalizationProvider>
) : null}
<TextField
type="number"
label="采样间隔(分钟)"
value={samplingIntervalMinutes}
onChange={(event) => {
setParametersState((previous) => ({
...previous,
samplingIntervalMinutes: Number(event.target.value),
samplingIntervalSource: "manual",
}));
}}
size="small"
fullWidth
error={!samplingIntervalValid}
inputProps={{ min: 1, max: 1440, step: 1 }}
helperText={
samplingIntervalValid
? `${frequencyLoading ? "正在读取 SCADA 频率" : samplingIntervalSource === "metadata" ? "默认取自压力 SCADA 频率" : "已手动设置"},每天 ${1440 / samplingIntervalMinutes} 个采样点`
: "请输入能整除 1440 分钟的正整数,例如 1、5、10、15、30 或 60。"
}
/>
{detectionMode === "latest" ? (
<Box className="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-sm text-blue-900">
系统自动读取目标时刻及前 14 天同一时刻数据。每天使用截至该时刻的
24 小时压力序列提取扰动特征,仅判定目标时刻是否异常。
</Box>
) : null}
<Typography variant="caption" color="text.secondary">
当前口径:{samplingIntervalMinutes || "-"} 分钟采样、
{samplingIntervalValid ? 1440 / samplingIntervalMinutes : "-"} /天、14
个参考日;缺失数据的测点不会插值,将从本次分析中排除。
</Typography>
</Box>
<Box className="mt-auto flex gap-2 pt-3">
<Button
variant="outlined"
fullWidth
disabled={running}
onClick={() =>
setParametersState(createBurstDetectionAnalysisParametersState())
}
>
重置
</Button>
<Button
variant="contained"
fullWidth
disabled={!isValid || running}
onClick={() => void handleRun()}
>
{running
? "侦测中..."
: detectionMode === "latest"
? "侦测最新数据"
: "回放目标时刻"}
</Button>
</Box>
</Box>
);
};
export default AnalysisParameters;