fix(analysis): 统一方案名与接口错误提示
Generic Container CI/CD / test-build-publish (push) Successful in 1m2s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m2s

This commit is contained in:
2026-08-17 18:54:29 +08:00
parent 9e79d52dc8
commit 45def5bba3
12 changed files with 720 additions and 55 deletions
@@ -21,7 +21,15 @@ 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 { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { BurstDetectionResult } from "./types"; import { BurstDetectionResult } from "./types";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props { interface Props {
onResult: (result: BurstDetectionResult) => void; onResult: (result: BurstDetectionResult) => void;
@@ -49,7 +57,7 @@ const currentQuarterHour = () => {
export const createBurstDetectionAnalysisParametersState = export const createBurstDetectionAnalysisParametersState =
(): BurstDetectionAnalysisParametersState => ({ (): BurstDetectionAnalysisParametersState => ({
schemeName: `Burst_Detection_${Date.now()}`, schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstDetection),
detectionMode: "latest", detectionMode: "latest",
targetTime: currentQuarterHour(), targetTime: currentQuarterHour(),
samplingIntervalMinutes: 15, samplingIntervalMinutes: 15,
@@ -94,7 +102,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
export const buildBurstDetectionRequest = ( export const buildBurstDetectionRequest = (
parameters: BurstDetectionAnalysisParametersState, parameters: BurstDetectionAnalysisParametersState,
) => ({ ) => ({
scheme_name: parameters.schemeName.trim(), scheme_name: normalizeSchemeName(parameters.schemeName),
sampling_interval_minutes: parameters.samplingIntervalMinutes, sampling_interval_minutes: parameters.samplingIntervalMinutes,
...(parameters.detectionMode === "historical" && parameters.targetTime ...(parameters.detectionMode === "historical" && parameters.targetTime
? { target_time: parameters.targetTime.toISOString() } ? { target_time: parameters.targetTime.toISOString() }
@@ -164,7 +172,7 @@ const AnalysisParameters: React.FC<Props> = ({
const isValid = useMemo( const isValid = useMemo(
() => () =>
schemeName.trim().length > 0 && isSchemeNameValid(schemeName) &&
samplingIntervalValid && samplingIntervalValid &&
(detectionMode === "latest" || Boolean(targetTime?.isValid())), (detectionMode === "latest" || Boolean(targetTime?.isValid())),
[detectionMode, samplingIntervalValid, schemeName, targetTime], [detectionMode, samplingIntervalValid, schemeName, targetTime],
@@ -203,12 +211,12 @@ const AnalysisParameters: React.FC<Props> = ({
? "目标时刻存在异常信号,请优先复核相关测点。" ? "目标时刻存在异常信号,请优先复核相关测点。"
: "目标时刻未发现爆管异常。", : "目标时刻未发现爆管异常。",
}); });
} catch (error: any) { } catch (error) {
open?.({ open?.({
key: "burst-detection-analysis-error", key: "burst-detection-analysis-error",
type: "error", type: "error",
message: "侦测失败", message: "侦测失败",
description: error?.response?.data?.detail ?? error?.message ?? "请求失败", description: getApiErrorMessage(error, "爆管侦测请求失败"),
}); });
} finally { } finally {
setRunning(false); setRunning(false);
@@ -226,6 +234,13 @@ const AnalysisParameters: React.FC<Props> = ({
value={schemeName} value={schemeName}
onChange={(event) => setFormField("schemeName", event.target.value)} onChange={(event) => setFormField("schemeName", event.target.value)}
placeholder="请输入方案名称" 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 fullWidth
size="small" size="small"
/> />
@@ -27,9 +27,17 @@ import { api } from "@/lib/api";
import { NETWORK_NAME, config } from "@config/config"; import { NETWORK_NAME, config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import { BurstLocationResult } from "./types"; import { BurstLocationResult } from "./types";
import { getBurstLocationErrorNotice } from "./burstLocationError"; import { getBurstLocationErrorNotice } from "./burstLocationError";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props { interface Props {
onResult: (result: BurstLocationResult) => void; onResult: (result: BurstLocationResult) => void;
@@ -67,7 +75,7 @@ export interface BurstLocationAnalysisParametersState {
export const createBurstLocationAnalysisParametersState = export const createBurstLocationAnalysisParametersState =
(): BurstLocationAnalysisParametersState => ({ (): BurstLocationAnalysisParametersState => ({
schemeName: `Burst_Locate_${Date.now()}`, schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstLocation),
dataSource: "monitoring", dataSource: "monitoring",
schemes: [], schemes: [],
selectedSchemeId: "", selectedSchemeId: "",
@@ -163,12 +171,14 @@ const AnalysisParameters: React.FC<Props> = ({
description: `当前可选爆管分析方案 ${burstSchemes.length}`, description: `当前可选爆管分析方案 ${burstSchemes.length}`,
}); });
} }
} catch (error: any) { } catch (error) {
open?.({ open?.({
type: "error", type: "error",
message: "刷新方案失败", message: "刷新方案失败",
description: description: getApiErrorMessage(
error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表", error,
"无法获取爆管分析方案列表",
),
}); });
} finally { } finally {
setSchemeLoading(false); setSchemeLoading(false);
@@ -193,6 +203,7 @@ const AnalysisParameters: React.FC<Props> = ({
}; };
const isValid = useMemo(() => { const isValid = useMemo(() => {
if (!isSchemeNameValid(schemeName)) return false;
if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false; if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false;
if (!burstStartTime || !burstEndTime) { if (!burstStartTime || !burstEndTime) {
return false; return false;
@@ -204,6 +215,7 @@ const AnalysisParameters: React.FC<Props> = ({
return burstStartTime.isBefore(burstEndTime); return burstStartTime.isBefore(burstEndTime);
}, [ }, [
burstLeakage, burstLeakage,
schemeName,
burstStartTime, burstStartTime,
burstEndTime, burstEndTime,
dataSource, dataSource,
@@ -234,7 +246,7 @@ const AnalysisParameters: React.FC<Props> = ({
`${config.BACKEND_URL}/api/v1/burst-locations`, `${config.BACKEND_URL}/api/v1/burst-locations`,
{ {
data_source: dataSource, data_source: dataSource,
scheme_name: schemeName.trim() || undefined, scheme_name: normalizeSchemeName(schemeName),
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT), burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
min_dpressure: minDpressure, min_dpressure: minDpressure,
basic_pressure: basicPressure, basic_pressure: basicPressure,
@@ -270,7 +282,7 @@ const AnalysisParameters: React.FC<Props> = ({
message: "爆管定位成功", message: "爆管定位成功",
description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`, description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`,
}); });
} catch (error: any) { } catch (error) {
const notice = getBurstLocationErrorNotice(error); const notice = getBurstLocationErrorNotice(error);
open?.({ open?.({
key: "burst-location-analysis-error", key: "burst-location-analysis-error",
@@ -294,6 +306,13 @@ const AnalysisParameters: React.FC<Props> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称" 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 fullWidth
size="small" size="small"
/> />
@@ -1,3 +1,5 @@
import { getApiErrorMessage } from "@/lib/apiError";
export interface BurstLocationErrorNotice { export interface BurstLocationErrorNotice {
message: string; message: string;
description: string; description: string;
@@ -6,26 +8,10 @@ export interface BurstLocationErrorNotice {
const DATA_GAP_PATTERN = const DATA_GAP_PATTERN =
/^(爆管压力数据|正常压力数据|爆管流量数据|正常流量数据) 在时间窗内无有效模拟数据: (.+)$/; /^(爆管压力数据|正常压力数据|爆管流量数据|正常流量数据) 在时间窗内无有效模拟数据: (.+)$/;
const extractErrorDetail = (error: unknown): string => {
const candidate = error as {
message?: string;
response?: { data?: { detail?: unknown } };
};
const detail = candidate?.response?.data?.detail;
if (typeof detail === "string" && detail.trim()) {
return detail.trim();
}
if (typeof candidate?.message === "string" && candidate.message.trim()) {
return candidate.message.trim();
}
return "请求失败";
};
export const getBurstLocationErrorNotice = ( export const getBurstLocationErrorNotice = (
error: unknown, error: unknown,
): BurstLocationErrorNotice => { ): BurstLocationErrorNotice => {
const detail = extractErrorDetail(error); const detail = getApiErrorMessage(error);
const match = detail.match(DATA_GAP_PATTERN); const match = detail.match(DATA_GAP_PATTERN);
if (!match) { if (!match) {
@@ -30,9 +30,17 @@ 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 { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { along, lineString, length, toMercator } from "@turf/turf"; import { along, lineString, length, toMercator } from "@turf/turf";
import { Point } from "ol/geom"; import { Point } from "ol/geom";
import { toLonLat } from "ol/proj"; import { toLonLat } from "ol/proj";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
export interface PipePoint { export interface PipePoint {
id: string; id: string;
@@ -52,7 +60,7 @@ export const createBurstAnalysisParametersState = (): BurstAnalysisParametersSta
pipePoints: [], pipePoints: [],
startTime: dayjs(new Date()), startTime: dayjs(new Date()),
duration: 3600, duration: 3600,
schemeName: "FANGAN" + new Date().getTime(), schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis),
network: NETWORK_NAME, network: NETWORK_NAME,
}); });
@@ -104,7 +112,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
pipePoints.length > 0 && pipePoints.length > 0 &&
startTime !== null && startTime !== null &&
duration > 0 && duration > 0 &&
schemeName.trim() !== ""; isSchemeNameValid(schemeName);
// 地图点击选择要素事件处理函数 // 地图点击选择要素事件处理函数
const handleMapClickSelectFeatures = useCallback( const handleMapClickSelectFeatures = useCallback(
@@ -333,7 +341,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
burst_id: burst_ID, burst_id: burst_ID,
burst_size: burst_size, burst_size: burst_size,
modify_total_duration: modify_total_duration, modify_total_duration: modify_total_duration,
scheme_name: schemeName, scheme_name: normalizeSchemeName(schemeName),
}; };
try { try {
@@ -358,8 +366,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
key: "burst-analysis", key: "burst-analysis",
type: "error", type: "error",
message: "提交分析失败", message: "提交分析失败",
description: description: getApiErrorMessage(error, "爆管模拟请求失败"),
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
}); });
} finally { } finally {
setAnalyzing(false); setAnalyzing(false);
@@ -525,6 +532,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
value={schemeName} value={schemeName}
onChange={(e) => setParameterField("schemeName", e.target.value)} onChange={(e) => setParameterField("schemeName", e.target.value)}
placeholder="输入方案名称" 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 }}
/> />
</Box> </Box>
@@ -30,6 +30,14 @@ import {
} from "@/utils/mapQueryService"; } from "@/utils/mapQueryService";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
export interface ContaminantAnalysisParametersState { export interface ContaminantAnalysisParametersState {
schemeName: string; schemeName: string;
@@ -42,7 +50,7 @@ export interface ContaminantAnalysisParametersState {
export const createContaminantAnalysisParametersState = export const createContaminantAnalysisParametersState =
(): ContaminantAnalysisParametersState => ({ (): ContaminantAnalysisParametersState => ({
schemeName: "WQ_" + new Date().getTime(), schemeName: createSchemeName(SCHEME_NAME_PREFIXES.contaminantAnalysis),
startTime: dayjs(new Date()), startTime: dayjs(new Date()),
sourceNode: "", sourceNode: "",
concentration: 100, concentration: 100,
@@ -98,7 +106,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
Boolean(sourceNode) && Boolean(sourceNode) &&
concentration > 0 && concentration > 0 &&
duration > 0 && duration > 0 &&
schemeName.trim() !== "" isSchemeNameValid(schemeName)
); );
}, [network, startTime, sourceNode, concentration, duration, schemeName]); }, [network, startTime, sourceNode, concentration, duration, schemeName]);
@@ -240,7 +248,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
concentration, concentration,
duration, duration,
pattern: pattern || undefined, pattern: pattern || undefined,
scheme_name: schemeName, scheme_name: normalizeSchemeName(schemeName),
}; };
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, { await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
@@ -259,8 +267,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
key: "contaminant-analysis", key: "contaminant-analysis",
type: "error", type: "error",
message: "提交分析失败", message: "提交分析失败",
description: description: getApiErrorMessage(error, "污染物模拟请求失败"),
error instanceof Error ? error.message : "请检查网络连接或稍后重试",
}); });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -391,6 +398,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="输入方案名称" 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 }}
/> />
</Box> </Box>
@@ -21,8 +21,16 @@ import { api } from "@/lib/api";
import { config } from "@config/config"; import { config } from "@config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import { LeakageResultDetail } from "./types"; import { LeakageResultDetail } from "./types";
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
interface Props { interface Props {
onResult: (result: LeakageResultDetail) => void; onResult: (result: LeakageResultDetail) => void;
@@ -43,7 +51,7 @@ export interface DMALeakAnalysisParametersState {
export const createDMALeakAnalysisParametersState = export const createDMALeakAnalysisParametersState =
(): DMALeakAnalysisParametersState => ({ (): DMALeakAnalysisParametersState => ({
schemeName: `DMA_Leak_${Date.now()}`, schemeName: createSchemeName(SCHEME_NAME_PREFIXES.dmaLeakIdentification),
dmaCount: 5, dmaCount: 5,
startTime: dayjs().subtract(2, "hour"), startTime: dayjs().subtract(2, "hour"),
endTime: dayjs(), endTime: dayjs(),
@@ -93,7 +101,7 @@ const AnalysisParameters: React.FC<Props> = ({
qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0; qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0;
const isValid = useMemo(() => { const isValid = useMemo(() => {
if (!schemeName.trim() || !startTime || !endTime) return false; if (!isSchemeNameValid(schemeName) || !startTime || !endTime) return false;
return startTime.isBefore(endTime) && qSumIsValid; return startTime.isBefore(endTime) && qSumIsValid;
}, [schemeName, startTime, endTime, qSumIsValid]); }, [schemeName, startTime, endTime, qSumIsValid]);
@@ -118,7 +126,7 @@ const AnalysisParameters: React.FC<Props> = ({
const response = await api.post( const response = await api.post(
`${config.BACKEND_URL}/api/v1/leakage-identifications`, `${config.BACKEND_URL}/api/v1/leakage-identifications`,
{ {
scheme_name: schemeName.trim(), scheme_name: normalizeSchemeName(schemeName),
dma_count: dmaCount, dma_count: dmaCount,
scada_start: startTime.toISOString(), scada_start: startTime.toISOString(),
scada_end: endTime.toISOString(), scada_end: endTime.toISOString(),
@@ -136,12 +144,12 @@ const AnalysisParameters: React.FC<Props> = ({
message: "方案分析成功", message: "方案分析成功",
description: "DMA 漏损识别完成,请在方案查询中查看结果。", description: "DMA 漏损识别完成,请在方案查询中查看结果。",
}); });
} catch (error: any) { } catch (error) {
open?.({ open?.({
key: "dma-leak-analysis-error", key: "dma-leak-analysis-error",
type: "error", type: "error",
message: "提交分析失败", message: "提交分析失败",
description: error?.response?.data?.detail ?? "请求失败", description: getApiErrorMessage(error, "DMA 漏损识别请求失败"),
}); });
} finally { } finally {
setRunning(false); setRunning(false);
@@ -163,6 +171,13 @@ const AnalysisParameters: React.FC<Props> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称" 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 fullWidth
size="small" size="small"
/> />
@@ -36,7 +36,15 @@ import { api } from "@/lib/api";
import { config } from "@/config/config"; import { config } from "@/config/config";
import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useControllableObjectState } from "@components/olmap/core/useControllableState";
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
import { getApiErrorMessage } from "@/lib/apiError";
import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
import { import {
type LinkStatus, type LinkStatus,
getValveSettingHelperText, getValveSettingHelperText,
@@ -65,7 +73,7 @@ export interface FlushingAnalysisParametersState {
export const createFlushingAnalysisParametersState = export const createFlushingAnalysisParametersState =
(): FlushingAnalysisParametersState => ({ (): FlushingAnalysisParametersState => ({
schemeName: "Flushing_" + new Date().getTime(), schemeName: createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis),
valves: [], valves: [],
drainageNode: null, drainageNode: null,
startTime: dayjs(new Date()), startTime: dayjs(new Date()),
@@ -334,6 +342,22 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
open?.({ open?.({
type: "error", type: "error",
message: `阀门 ${valve.id} 的部分属性读取失败`, message: `阀门 ${valve.id} 的部分属性读取失败`,
description: [
propertiesResult.status === "rejected"
? `阀门属性:${getApiErrorMessage(
propertiesResult.reason,
"读取失败",
)}`
: null,
statusResult.status === "rejected"
? `开关状态:${getApiErrorMessage(
statusResult.reason,
"读取失败",
)}`
: null,
]
.filter(Boolean)
.join(""),
}); });
} }
}) })
@@ -400,7 +424,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
}; };
const handleAnalyze = async () => { const handleAnalyze = async () => {
if (!startTime || !drainageNode || !schemeName.trim()) { if (!startTime || !drainageNode || !isSchemeNameValid(schemeName)) {
open?.({ open?.({
type: "error", type: "error",
message: "请填写完整参数", message: "请填写完整参数",
@@ -442,7 +466,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00 const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00
const params = { const params = {
scheme_name: schemeName, scheme_name: normalizeSchemeName(schemeName),
start_time: formattedTime, start_time: formattedTime,
...(valves.length > 0 && { ...(valves.length > 0 && {
valves: valves.map(v => v.id), valves: valves.map(v => v.id),
@@ -480,7 +504,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
open?.({ open?.({
type: "error", type: "error",
message: "提交分析失败", message: "提交分析失败",
description: error instanceof Error ? error.message : "未知错误", description: getApiErrorMessage(error, "管道冲洗分析请求失败"),
}); });
} finally { } finally {
setAnalyzing(false); setAnalyzing(false);
@@ -668,6 +692,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称" 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 }}
/> />
</Box> </Box>
@@ -710,7 +741,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
onClick={handleAnalyze} onClick={handleAnalyze}
disabled={ disabled={
analyzing || analyzing ||
!schemeName.trim() || !isSchemeNameValid(schemeName) ||
!drainageNode || !drainageNode ||
!startTime || !startTime ||
// !flushFlow || // !flushFlow ||
@@ -12,8 +12,16 @@ import { PlayArrow as PlayArrowIcon } from "@mui/icons-material";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
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 { getApiErrorMessage } from "@/lib/apiError";
import { optimizeSensorPlacement } from "./schemeApi"; import { optimizeSensorPlacement } from "./schemeApi";
import type { SensorPlacementScheme } from "./types"; import type { SensorPlacementScheme } from "./types";
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_MAX_LENGTH,
SCHEME_NAME_PREFIXES,
} from "@utils/schemeName";
export interface OptimizationParametersState { export interface OptimizationParametersState {
method: string; method: string;
@@ -27,7 +35,7 @@ export const createOptimizationParametersState =
method: "kmeans", method: "kmeans",
sensorCount: 5, sensorCount: 5,
minDiameter: 5, minDiameter: 5,
schemeName: "Fangan" + new Date().getTime(), schemeName: createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement),
}); });
interface OptimizationParametersProps { interface OptimizationParametersProps {
@@ -62,7 +70,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
// 创建方案 // 创建方案
const handleCreateScheme = async () => { const handleCreateScheme = async () => {
// 验证输入 // 验证输入
if (!schemeName.trim()) { if (!isSchemeNameValid(schemeName)) {
open?.({ open?.({
type: "error", type: "error",
message: "请输入方案名称", message: "请输入方案名称",
@@ -90,7 +98,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
try { try {
const created = await optimizeSensorPlacement({ const created = await optimizeSensorPlacement({
scheme_name: schemeName, scheme_name: normalizeSchemeName(schemeName),
sensor_type: "pressure", sensor_type: "pressure",
method: method as "sensitivity" | "kmeans", method: method as "sensitivity" | "kmeans",
sensor_count: sensorCount, sensor_count: sensorCount,
@@ -102,14 +110,16 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
description: `方案 "${schemeName}" 已完成优化分析`, description: `方案 "${schemeName}" 已完成优化分析`,
}); });
onSchemeCreated?.(created); onSchemeCreated?.(created);
setFormField("schemeName", "Fangan" + new Date().getTime()); setFormField(
} catch (error: any) { "schemeName",
createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement),
);
} catch (error) {
console.error("创建方案失败:", error); console.error("创建方案失败:", error);
open?.({ open?.({
type: "error", type: "error",
message: "创建方案失败", message: "创建方案失败",
description: description: getApiErrorMessage(error, "监测点优化请求失败"),
error.response?.data?.message || error.message || "未知错误",
}); });
} finally { } finally {
setAnalyzing(false); setAnalyzing(false);
@@ -255,6 +265,13 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
value={schemeName} value={schemeName}
onChange={(e) => setFormField("schemeName", e.target.value)} onChange={(e) => setFormField("schemeName", e.target.value)}
placeholder="请输入方案名称" 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 }}
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
"&:hover fieldset": { "&:hover fieldset": {
+209
View File
@@ -0,0 +1,209 @@
import { getApiErrorMessage } from "./apiError";
describe("getApiErrorMessage", () => {
it("uses a business detail from Problem Details", () => {
expect(
getApiErrorMessage({
response: {
status: 422,
data: {
detail: "ACTIVE 状态的阀门 V1 必须提供设置值",
errors: [],
},
},
}),
).toBe("ACTIVE 状态的阀门 V1 必须提供设置值");
});
it("formats field-level validation errors in Chinese", () => {
expect(
getApiErrorMessage({
response: {
status: 422,
data: {
detail: "Request validation failed",
errors: [
{
type: "missing",
loc: ["query", "drainage_node_id"],
msg: "Field required",
},
{
type: "literal_error",
loc: ["query", "valve_statuses", 1],
msg: "Input should be 'OPEN', 'CLOSED' or 'ACTIVE'",
ctx: { expected: "'OPEN', 'CLOSED' or 'ACTIVE'" },
},
],
},
},
}),
).toBe(
"排水节点:不能为空;阀门开关状态[2]:可选值为 'OPEN', 'CLOSED' 或 'ACTIVE'",
);
});
it("supports legacy FastAPI validation details", () => {
expect(
getApiErrorMessage({
response: {
status: 422,
data: {
detail: [
{
type: "greater_than_equal",
loc: ["body", "sensor_count"],
msg: "Input should be greater than or equal to 1",
ctx: { ge: 1 },
},
],
},
},
}),
).toBe("监测点数量:必须大于或等于 1");
});
it("limits long validation responses while preserving the remaining count", () => {
expect(
getApiErrorMessage({
response: {
status: 422,
data: {
errors: Array.from({ length: 5 }, (_, index) => ({
type: "missing",
loc: ["body", `field_${index}`],
msg: "Field required",
})),
},
},
}),
).toBe(
"field_0:不能为空;field_1:不能为空;field_2:不能为空;另有 2 项参数错误",
);
});
it("bounds hostile validation payloads before formatting", () => {
const message = getApiErrorMessage({
response: {
status: 422,
data: {
errors: Array.from({ length: 100_000 }, () => ({
type: "custom_error",
loc: ["body", "x".repeat(1_000)],
msg: "y".repeat(10_000),
})),
},
},
});
expect(message.length).toBeLessThan(600);
expect(message).toContain("另有 99997 项参数错误");
});
it("bounds validation constraint values", () => {
const message = getApiErrorMessage({
response: {
status: 422,
data: {
errors: [
{
type: "greater_than",
loc: ["body", "duration"],
ctx: { gt: "9".repeat(10_000) },
},
],
},
},
});
expect(message.length).toBeLessThan(100);
});
it("distinguishes network and timeout failures", () => {
expect(getApiErrorMessage({ code: "ERR_NETWORK" })).toContain("无法连接服务");
expect(getApiErrorMessage({ code: "ECONNABORTED" })).toContain("请求超时");
});
it("uses a localized message and trace id for server failures", () => {
expect(
getApiErrorMessage({
response: {
status: 503,
data: {
detail: "仿真服务暂时不可用",
trace_id: "trace-123",
},
},
}),
).toBe("服务暂时不可用,请稍后重试(追踪 ID:trace-123");
});
it("does not expose authentication details from the response", () => {
expect(
getApiErrorMessage({
response: {
status: 401,
data: { detail: "Not authenticated" },
},
}),
).toBe("登录状态已失效,请重新登录");
});
it("preserves a clear Chinese permission detail", () => {
expect(
getApiErrorMessage({
response: {
status: 403,
data: { detail: "当前项目角色为只读,不能创建方案" },
},
}),
).toBe("当前项目角色为只读,不能创建方案");
});
it("localizes plain-text server failures", () => {
expect(
getApiErrorMessage({
response: {
status: 500,
data: "Internal Server Error",
},
}),
).toBe("服务处理失败,请稍后重试");
});
it("does not misreport server error arrays as validation failures", () => {
expect(
getApiErrorMessage({
response: {
status: 500,
data: {
errors: [{ loc: ["body", "scheme_name"], msg: "failed" }],
trace_id: "trace-500",
},
},
}),
).toBe("服务处理失败,请稍后重试(追踪 IDtrace-500");
});
it("does not display HTML gateway responses", () => {
expect(
getApiErrorMessage({
response: {
status: 502,
data: "<!doctype html><html><body>Bad Gateway</body></html>",
},
}),
).toBe("上游服务暂时不可用,请稍后重试");
});
it("uses a localized fallback for an unknown HTTP status", () => {
expect(
getApiErrorMessage({
response: {
status: 418,
},
message: "Request failed with status code 418",
}),
).toBe("请求失败(HTTP 418");
});
});
+258
View File
@@ -0,0 +1,258 @@
type UnknownRecord = Record<string, unknown>;
type ErrorResponse = {
status?: number;
data?: unknown;
};
const FIELD_LABELS: Record<string, string> = {
scheme_name: "方案名称",
start_time: "开始时间",
modify_pattern_start_time: "开始时间",
target_time: "目标时间",
sampling_interval_minutes: "采样间隔",
duration: "持续时长",
modify_total_duration: "持续时长",
valves: "参与阀门",
valve_statuses: "阀门开关状态",
valve_settings: "阀门设置值",
valves_k: "阀门开度",
drainage_node_id: "排水节点",
drainage_node_ID: "排水节点",
burst_id: "爆管点",
burst_ID: "爆管点",
burst_size: "爆管流量",
burst_leakage: "爆管流量",
min_dpressure: "最小压降",
basic_pressure: "基准压力",
data_source: "数据来源",
scada_burst_start: "爆管开始时间",
scada_burst_end: "爆管结束时间",
use_scada_flow: "使用流量监测数据",
simulation_scheme_name: "模拟方案名称",
simulation_scheme_type: "模拟方案类型",
source: "污染源节点",
concentration: "污染物浓度",
pattern: "污染物注入模式",
sensor_count: "监测点数量",
sensor_type: "监测点类型",
method: "优化方法",
min_diameter: "最小管径",
dma_count: "DMA 数量",
scada_start: "监测开始时间",
scada_end: "监测结束时间",
q_sum: "总漏损流量",
pop_size: "种群规模",
max_gen: "最大迭代次数",
flush_flow: "冲洗流量",
};
const STATUS_MESSAGES: Record<number, string> = {
400: "请求参数不正确",
401: "登录状态已失效,请重新登录",
403: "当前账号无权执行此操作",
404: "请求的数据不存在",
409: "当前数据已发生变化,请刷新后重试",
422: "请求参数校验失败",
429: "请求过于频繁,请稍后重试",
500: "服务处理失败,请稍后重试",
502: "上游服务暂时不可用,请稍后重试",
503: "服务暂时不可用,请稍后重试",
504: "服务响应超时,请稍后重试",
};
const asRecord = (value: unknown): UnknownRecord | null =>
value !== null && typeof value === "object"
? (value as UnknownRecord)
: null;
const nonEmptyString = (value: unknown): string | null =>
typeof value === "string" && value.trim() ? value.trim() : null;
const safeServerMessage = (value: unknown): string | null => {
const message = nonEmptyString(value);
if (!message || message.length > 300) return null;
if (/<!doctype|<html|<body|<script|<style|<[^>]+>/i.test(message)) {
return null;
}
return message;
};
const boundedText = (value: unknown, maxLength: number): string | null => {
const text = nonEmptyString(value);
if (!text) return null;
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text;
};
const boundedConstraint = (value: unknown, fallback: string) =>
value === null || value === undefined
? fallback
: (boundedText(String(value), 40) ?? fallback);
const getErrorResponse = (error: unknown): ErrorResponse | null => {
const candidate = asRecord(error);
const response = asRecord(candidate?.response);
if (!response) return null;
return {
status:
typeof response.status === "number" ? response.status : undefined,
data: response.data,
};
};
const formatValidationLocation = (location: unknown): string => {
if (!Array.isArray(location)) return "请求参数";
const segments = location.slice(0, 5).filter(
(segment, index) =>
!(
index === 0 &&
["body", "query", "path", "header"].includes(String(segment))
),
);
if (segments.length === 0) return "请求参数";
return segments.reduce<string>((result, segment, index) => {
if (typeof segment === "number") {
return `${result}[${segment + 1}]`;
}
const rawSegment = String(segment);
const label = FIELD_LABELS[rawSegment] ?? boundedText(rawSegment, 40) ?? "参数";
return index === 0 ? label : `${result}.${label}`;
}, "");
};
const translateValidationMessage = (error: UnknownRecord): string => {
const type = nonEmptyString(error.type);
const message = nonEmptyString(error.msg);
const context = asRecord(error.ctx);
switch (type) {
case "missing":
return "不能为空";
case "literal_error": {
const expected = boundedText(context?.expected, 100);
return expected
? `可选值为 ${expected.replace(/\s+or\s+/g, " 或 ")}`
: "取值不在允许范围内";
}
case "greater_than":
return `必须大于 ${boundedConstraint(context?.gt, "限定值")}`;
case "greater_than_equal":
return `必须大于或等于 ${boundedConstraint(context?.ge, "限定值")}`;
case "less_than":
return `必须小于 ${boundedConstraint(context?.lt, "限定值")}`;
case "less_than_equal":
return `必须小于或等于 ${boundedConstraint(context?.le, "限定值")}`;
case "int_parsing":
return "必须是整数";
case "float_parsing":
case "decimal_parsing":
return "必须是数字";
case "datetime_from_date_parsing":
case "datetime_parsing":
return "日期时间格式不正确";
case "string_too_short":
return `长度不能少于 ${boundedConstraint(context?.min_length, "要求的")} 个字符`;
case "string_too_long":
return `长度不能超过 ${boundedConstraint(context?.max_length, "允许的")} 个字符`;
default:
if (message === "Field required") return "不能为空";
if (message?.startsWith("Input should be ")) {
return boundedText(
message.replace("Input should be ", "可选值为 "),
120,
)!;
}
return boundedText(message, 120) ?? "参数无效";
}
};
const formatValidationErrors = (value: unknown): string | null => {
if (!Array.isArray(value) || value.length === 0) return null;
const messages = value
.slice(0, 3)
.map(asRecord)
.filter((item): item is UnknownRecord => item !== null)
.map(
(item) =>
`${formatValidationLocation(item.loc)}${translateValidationMessage(item)}`,
);
if (messages.length === 0) return null;
const visibleMessages = messages.join("");
const remainingCount = value.length - messages.length;
return remainingCount > 0
? `${visibleMessages};另有 ${remainingCount} 项参数错误`
: visibleMessages;
};
const appendTraceId = (message: string, traceId: string | null) =>
traceId ? `${message}(追踪 ID${traceId}` : message;
export const getApiErrorMessage = (
error: unknown,
fallback = "请求失败,请稍后重试",
): string => {
const candidate = asRecord(error);
const response = getErrorResponse(error);
const payload = asRecord(response?.data);
const status = response?.status;
const traceId = safeServerMessage(payload?.trace_id);
const isValidationResponse =
status === 400 ||
status === 422 ||
nonEmptyString(payload?.code) === "validation_error";
const validationErrors = isValidationResponse
? formatValidationErrors(
payload?.errors ??
(Array.isArray(payload?.detail) ? payload.detail : null),
)
: null;
if (validationErrors) {
return validationErrors;
}
const responseText = safeServerMessage(response?.data);
const detail = safeServerMessage(payload?.detail);
const responseMessage = safeServerMessage(payload?.message);
const statusMessage = status ? STATUS_MESSAGES[status] : null;
const unknownStatusMessage = status ? `请求失败(HTTP ${status}` : null;
if (status === 403) {
const permissionDetail = detail ?? responseMessage;
return permissionDetail && /[\u3400-\u9fff]/u.test(permissionDetail)
? permissionDetail
: STATUS_MESSAGES[403];
}
if (status && (status === 401 || status >= 500)) {
return appendTraceId(
statusMessage ?? unknownStatusMessage ?? fallback,
status >= 500 ? traceId : null,
);
}
const message =
detail ?? responseMessage ?? responseText ?? statusMessage ?? unknownStatusMessage;
if (message) {
return message;
}
const code = nonEmptyString(candidate?.code);
if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
return "请求超时,请稍后重试";
}
if (
code === "ERR_NETWORK" ||
(candidate?.isAxiosError === true && !response)
) {
return "无法连接服务,请检查网络连接或服务状态";
}
return nonEmptyString(candidate?.message) ?? fallback;
};
+40
View File
@@ -0,0 +1,40 @@
import {
createSchemeName,
isSchemeNameValid,
normalizeSchemeName,
SCHEME_NAME_PREFIXES,
} from "./schemeName";
describe("createSchemeName", () => {
it("combines the scheme type with a readable local timestamp", () => {
const date = new Date(2026, 7, 17, 15, 30, 45, 123);
expect(createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, date)).toBe(
"flush_260817_153045123",
);
});
it("pads single-digit date and time values", () => {
const date = new Date(2026, 0, 2, 3, 4, 5, 6);
expect(createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis, date)).toBe(
"burst_sim_260102_030405006",
);
});
it("keeps names distinct within the same second", () => {
const first = new Date(2026, 7, 17, 15, 30, 45, 123);
const second = new Date(2026, 7, 17, 15, 30, 45, 124);
expect(createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, first)).not.toBe(
createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis, second),
);
});
it("normalizes and validates manually entered names", () => {
expect(normalizeSchemeName(" cafe\u0301 ")).toBe("café");
expect(normalizeSchemeName("方案\u200bA\u2060")).toBe("方案A");
expect(isSchemeNameValid("x".repeat(32))).toBe(true);
expect(isSchemeNameValid("x".repeat(33))).toBe(false);
});
});
+47
View File
@@ -0,0 +1,47 @@
export const SCHEME_NAME_PREFIXES = {
dmaLeakIdentification: "dma_leak",
flushingAnalysis: "flush",
burstDetection: "burst_detect",
burstAnalysis: "burst_sim",
burstLocation: "burst_locate",
contaminantAnalysis: "water_quality",
sensorPlacement: "sensor_place",
} as const;
export type SchemeNamePrefix =
(typeof SCHEME_NAME_PREFIXES)[keyof typeof SCHEME_NAME_PREFIXES];
export const SCHEME_NAME_MAX_LENGTH = 32;
export const normalizeSchemeName = (value: string) =>
value
.normalize("NFC")
.replace(/\p{Default_Ignorable_Code_Point}/gu, "")
.trim();
export const isSchemeNameValid = (value: string) => {
const normalized = normalizeSchemeName(value);
return normalized.length > 0 && normalized.length <= SCHEME_NAME_MAX_LENGTH;
};
const padNumber = (value: number, length = 2) =>
String(value).padStart(length, "0");
export const createSchemeName = (
prefix: SchemeNamePrefix,
date = new Date(),
) => {
const timestamp = [
padNumber(date.getFullYear() % 100),
padNumber(date.getMonth() + 1),
padNumber(date.getDate()),
].join("");
const time = [
padNumber(date.getHours()),
padNumber(date.getMinutes()),
padNumber(date.getSeconds()),
padNumber(date.getMilliseconds(), 3),
].join("");
return `${prefix}_${timestamp}_${time}`;
};