fix(analysis): 统一方案名与接口错误提示
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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("服务处理失败,请稍后重试(追踪 ID:trace-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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user