From 45def5bba33cc05c7390b0a90bd0e8a3730c7d93 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 17 Aug 2026 18:54:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(analysis):=20=E7=BB=9F=E4=B8=80=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E5=90=8D=E4=B8=8E=E6=8E=A5=E5=8F=A3=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BurstDetection/AnalysisParameters.tsx | 25 +- .../BurstLocation/AnalysisParameters.tsx | 31 ++- .../olmap/BurstLocation/burstLocationError.ts | 20 +- .../BurstSimulation/AnalysisParameters.tsx | 24 +- .../AnalysisParameters.tsx | 24 +- .../DMALeakDetection/AnalysisParameters.tsx | 25 +- .../FlushingAnalysis/AnalysisParameters.tsx | 41 ++- .../OptimizationParameters.tsx | 31 ++- src/lib/apiError.test.ts | 209 ++++++++++++++ src/lib/apiError.ts | 258 ++++++++++++++++++ src/utils/schemeName.test.ts | 40 +++ src/utils/schemeName.ts | 47 ++++ 12 files changed, 720 insertions(+), 55 deletions(-) create mode 100644 src/lib/apiError.test.ts create mode 100644 src/lib/apiError.ts create mode 100644 src/utils/schemeName.test.ts create mode 100644 src/utils/schemeName.ts diff --git a/src/components/olmap/BurstDetection/AnalysisParameters.tsx b/src/components/olmap/BurstDetection/AnalysisParameters.tsx index 72bd7e0..d35ef9f 100644 --- a/src/components/olmap/BurstDetection/AnalysisParameters.tsx +++ b/src/components/olmap/BurstDetection/AnalysisParameters.tsx @@ -21,7 +21,15 @@ import { api } from "@/lib/api"; import { NETWORK_NAME } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; +import { getApiErrorMessage } from "@/lib/apiError"; import { BurstDetectionResult } from "./types"; +import { + createSchemeName, + isSchemeNameValid, + normalizeSchemeName, + SCHEME_NAME_MAX_LENGTH, + SCHEME_NAME_PREFIXES, +} from "@utils/schemeName"; interface Props { onResult: (result: BurstDetectionResult) => void; @@ -49,7 +57,7 @@ const currentQuarterHour = () => { export const createBurstDetectionAnalysisParametersState = (): BurstDetectionAnalysisParametersState => ({ - schemeName: `Burst_Detection_${Date.now()}`, + schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstDetection), detectionMode: "latest", targetTime: currentQuarterHour(), samplingIntervalMinutes: 15, @@ -94,7 +102,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => { export const buildBurstDetectionRequest = ( parameters: BurstDetectionAnalysisParametersState, ) => ({ - scheme_name: parameters.schemeName.trim(), + scheme_name: normalizeSchemeName(parameters.schemeName), sampling_interval_minutes: parameters.samplingIntervalMinutes, ...(parameters.detectionMode === "historical" && parameters.targetTime ? { target_time: parameters.targetTime.toISOString() } @@ -164,7 +172,7 @@ const AnalysisParameters: React.FC = ({ const isValid = useMemo( () => - schemeName.trim().length > 0 && + isSchemeNameValid(schemeName) && samplingIntervalValid && (detectionMode === "latest" || Boolean(targetTime?.isValid())), [detectionMode, samplingIntervalValid, schemeName, targetTime], @@ -203,12 +211,12 @@ const AnalysisParameters: React.FC = ({ ? "目标时刻存在异常信号,请优先复核相关测点。" : "目标时刻未发现爆管异常。", }); - } catch (error: any) { + } catch (error) { open?.({ key: "burst-detection-analysis-error", type: "error", message: "侦测失败", - description: error?.response?.data?.detail ?? error?.message ?? "请求失败", + description: getApiErrorMessage(error, "爆管侦测请求失败"), }); } finally { setRunning(false); @@ -226,6 +234,13 @@ const AnalysisParameters: React.FC = ({ value={schemeName} onChange={(event) => setFormField("schemeName", event.target.value)} placeholder="请输入方案名称" + error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH} + helperText={ + schemeName.trim().length > SCHEME_NAME_MAX_LENGTH + ? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符` + : undefined + } + inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }} fullWidth size="small" /> diff --git a/src/components/olmap/BurstLocation/AnalysisParameters.tsx b/src/components/olmap/BurstLocation/AnalysisParameters.tsx index 4409759..01c55aa 100644 --- a/src/components/olmap/BurstLocation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstLocation/AnalysisParameters.tsx @@ -27,9 +27,17 @@ import { api } from "@/lib/api"; import { NETWORK_NAME, config } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; +import { getApiErrorMessage } from "@/lib/apiError"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; import { BurstLocationResult } from "./types"; import { getBurstLocationErrorNotice } from "./burstLocationError"; +import { + createSchemeName, + isSchemeNameValid, + normalizeSchemeName, + SCHEME_NAME_MAX_LENGTH, + SCHEME_NAME_PREFIXES, +} from "@utils/schemeName"; interface Props { onResult: (result: BurstLocationResult) => void; @@ -67,7 +75,7 @@ export interface BurstLocationAnalysisParametersState { export const createBurstLocationAnalysisParametersState = (): BurstLocationAnalysisParametersState => ({ - schemeName: `Burst_Locate_${Date.now()}`, + schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstLocation), dataSource: "monitoring", schemes: [], selectedSchemeId: "", @@ -163,12 +171,14 @@ const AnalysisParameters: React.FC = ({ description: `当前可选爆管分析方案 ${burstSchemes.length} 个`, }); } - } catch (error: any) { + } catch (error) { open?.({ type: "error", message: "刷新方案失败", - description: - error?.response?.data?.detail ?? error?.message ?? "无法获取爆管分析方案列表", + description: getApiErrorMessage( + error, + "无法获取爆管分析方案列表", + ), }); } finally { setSchemeLoading(false); @@ -193,6 +203,7 @@ const AnalysisParameters: React.FC = ({ }; const isValid = useMemo(() => { + if (!isSchemeNameValid(schemeName)) return false; if (!Number.isFinite(burstLeakage) || burstLeakage <= 0) return false; if (!burstStartTime || !burstEndTime) { return false; @@ -204,6 +215,7 @@ const AnalysisParameters: React.FC = ({ return burstStartTime.isBefore(burstEndTime); }, [ burstLeakage, + schemeName, burstStartTime, burstEndTime, dataSource, @@ -234,7 +246,7 @@ const AnalysisParameters: React.FC = ({ `${config.BACKEND_URL}/api/v1/burst-locations`, { data_source: dataSource, - scheme_name: schemeName.trim() || undefined, + scheme_name: normalizeSchemeName(schemeName), burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT), min_dpressure: minDpressure, basic_pressure: basicPressure, @@ -270,7 +282,7 @@ const AnalysisParameters: React.FC = ({ message: "爆管定位成功", description: `定位到管段: ${(response.data as BurstLocationResult).located_pipe}`, }); - } catch (error: any) { + } catch (error) { const notice = getBurstLocationErrorNotice(error); open?.({ key: "burst-location-analysis-error", @@ -294,6 +306,13 @@ const AnalysisParameters: React.FC = ({ value={schemeName} onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="请输入方案名称" + error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH} + helperText={ + schemeName.trim().length > SCHEME_NAME_MAX_LENGTH + ? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符` + : undefined + } + inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }} fullWidth size="small" /> diff --git a/src/components/olmap/BurstLocation/burstLocationError.ts b/src/components/olmap/BurstLocation/burstLocationError.ts index cb8be8e..ec45dd4 100644 --- a/src/components/olmap/BurstLocation/burstLocationError.ts +++ b/src/components/olmap/BurstLocation/burstLocationError.ts @@ -1,3 +1,5 @@ +import { getApiErrorMessage } from "@/lib/apiError"; + export interface BurstLocationErrorNotice { message: string; description: string; @@ -6,26 +8,10 @@ export interface BurstLocationErrorNotice { 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 = ( error: unknown, ): BurstLocationErrorNotice => { - const detail = extractErrorDetail(error); + const detail = getApiErrorMessage(error); const match = detail.match(DATA_GAP_PATTERN); if (!match) { diff --git a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx index 537ef33..0efe97d 100644 --- a/src/components/olmap/BurstSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisParameters.tsx @@ -30,9 +30,17 @@ import { api } from "@/lib/api"; import { config, NETWORK_NAME } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; +import { getApiErrorMessage } from "@/lib/apiError"; import { along, lineString, length, toMercator } from "@turf/turf"; import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; +import { + createSchemeName, + isSchemeNameValid, + normalizeSchemeName, + SCHEME_NAME_MAX_LENGTH, + SCHEME_NAME_PREFIXES, +} from "@utils/schemeName"; export interface PipePoint { id: string; @@ -52,7 +60,7 @@ export const createBurstAnalysisParametersState = (): BurstAnalysisParametersSta pipePoints: [], startTime: dayjs(new Date()), duration: 3600, - schemeName: "FANGAN" + new Date().getTime(), + schemeName: createSchemeName(SCHEME_NAME_PREFIXES.burstAnalysis), network: NETWORK_NAME, }); @@ -104,7 +112,7 @@ const AnalysisParameters: React.FC = ({ pipePoints.length > 0 && startTime !== null && duration > 0 && - schemeName.trim() !== ""; + isSchemeNameValid(schemeName); // 地图点击选择要素事件处理函数 const handleMapClickSelectFeatures = useCallback( @@ -333,7 +341,7 @@ const AnalysisParameters: React.FC = ({ burst_id: burst_ID, burst_size: burst_size, modify_total_duration: modify_total_duration, - scheme_name: schemeName, + scheme_name: normalizeSchemeName(schemeName), }; try { @@ -358,8 +366,7 @@ const AnalysisParameters: React.FC = ({ key: "burst-analysis", type: "error", message: "提交分析失败", - description: - error instanceof Error ? error.message : "请检查网络连接或稍后重试", + description: getApiErrorMessage(error, "爆管模拟请求失败"), }); } finally { setAnalyzing(false); @@ -525,6 +532,13 @@ const AnalysisParameters: React.FC = ({ value={schemeName} onChange={(e) => setParameterField("schemeName", e.target.value)} placeholder="输入方案名称" + error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH} + helperText={ + schemeName.trim().length > SCHEME_NAME_MAX_LENGTH + ? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符` + : undefined + } + inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }} /> diff --git a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx index ad9f625..f1e26cd 100644 --- a/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx +++ b/src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx @@ -30,6 +30,14 @@ import { } from "@/utils/mapQueryService"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; 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 { schemeName: string; @@ -42,7 +50,7 @@ export interface ContaminantAnalysisParametersState { export const createContaminantAnalysisParametersState = (): ContaminantAnalysisParametersState => ({ - schemeName: "WQ_" + new Date().getTime(), + schemeName: createSchemeName(SCHEME_NAME_PREFIXES.contaminantAnalysis), startTime: dayjs(new Date()), sourceNode: "", concentration: 100, @@ -98,7 +106,7 @@ const AnalysisParameters: React.FC = ({ Boolean(sourceNode) && concentration > 0 && duration > 0 && - schemeName.trim() !== "" + isSchemeNameValid(schemeName) ); }, [network, startTime, sourceNode, concentration, duration, schemeName]); @@ -240,7 +248,7 @@ const AnalysisParameters: React.FC = ({ concentration, duration, pattern: pattern || undefined, - scheme_name: schemeName, + scheme_name: normalizeSchemeName(schemeName), }; await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, { @@ -259,8 +267,7 @@ const AnalysisParameters: React.FC = ({ key: "contaminant-analysis", type: "error", message: "提交分析失败", - description: - error instanceof Error ? error.message : "请检查网络连接或稍后重试", + description: getApiErrorMessage(error, "污染物模拟请求失败"), }); } finally { setSubmitting(false); @@ -391,6 +398,13 @@ const AnalysisParameters: React.FC = ({ value={schemeName} onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="输入方案名称" + error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH} + helperText={ + schemeName.trim().length > SCHEME_NAME_MAX_LENGTH + ? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符` + : undefined + } + inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }} /> diff --git a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx index 8a10424..e9874dd 100644 --- a/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx +++ b/src/components/olmap/DMALeakDetection/AnalysisParameters.tsx @@ -21,8 +21,16 @@ import { api } from "@/lib/api"; import { config } from "@config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; +import { getApiErrorMessage } from "@/lib/apiError"; import { LeakageResultDetail } from "./types"; import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units"; +import { + createSchemeName, + isSchemeNameValid, + normalizeSchemeName, + SCHEME_NAME_MAX_LENGTH, + SCHEME_NAME_PREFIXES, +} from "@utils/schemeName"; interface Props { onResult: (result: LeakageResultDetail) => void; @@ -43,7 +51,7 @@ export interface DMALeakAnalysisParametersState { export const createDMALeakAnalysisParametersState = (): DMALeakAnalysisParametersState => ({ - schemeName: `DMA_Leak_${Date.now()}`, + schemeName: createSchemeName(SCHEME_NAME_PREFIXES.dmaLeakIdentification), dmaCount: 5, startTime: dayjs().subtract(2, "hour"), endTime: dayjs(), @@ -93,7 +101,7 @@ const AnalysisParameters: React.FC = ({ qSumInput.trim() !== "" && Number.isFinite(parsedQSum) && parsedQSum >= 0; const isValid = useMemo(() => { - if (!schemeName.trim() || !startTime || !endTime) return false; + if (!isSchemeNameValid(schemeName) || !startTime || !endTime) return false; return startTime.isBefore(endTime) && qSumIsValid; }, [schemeName, startTime, endTime, qSumIsValid]); @@ -118,7 +126,7 @@ const AnalysisParameters: React.FC = ({ const response = await api.post( `${config.BACKEND_URL}/api/v1/leakage-identifications`, { - scheme_name: schemeName.trim(), + scheme_name: normalizeSchemeName(schemeName), dma_count: dmaCount, scada_start: startTime.toISOString(), scada_end: endTime.toISOString(), @@ -136,12 +144,12 @@ const AnalysisParameters: React.FC = ({ message: "方案分析成功", description: "DMA 漏损识别完成,请在方案查询中查看结果。", }); - } catch (error: any) { + } catch (error) { open?.({ key: "dma-leak-analysis-error", type: "error", message: "提交分析失败", - description: error?.response?.data?.detail ?? "请求失败", + description: getApiErrorMessage(error, "DMA 漏损识别请求失败"), }); } finally { setRunning(false); @@ -163,6 +171,13 @@ const AnalysisParameters: React.FC = ({ value={schemeName} onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="请输入方案名称" + error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH} + helperText={ + schemeName.trim().length > SCHEME_NAME_MAX_LENGTH + ? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符` + : undefined + } + inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }} fullWidth size="small" /> diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index c028a6a..57f8c8a 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -36,7 +36,15 @@ import { api } from "@/lib/api"; import { config } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; +import { getApiErrorMessage } from "@/lib/apiError"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; +import { + createSchemeName, + isSchemeNameValid, + normalizeSchemeName, + SCHEME_NAME_MAX_LENGTH, + SCHEME_NAME_PREFIXES, +} from "@utils/schemeName"; import { type LinkStatus, getValveSettingHelperText, @@ -65,7 +73,7 @@ export interface FlushingAnalysisParametersState { export const createFlushingAnalysisParametersState = (): FlushingAnalysisParametersState => ({ - schemeName: "Flushing_" + new Date().getTime(), + schemeName: createSchemeName(SCHEME_NAME_PREFIXES.flushingAnalysis), valves: [], drainageNode: null, startTime: dayjs(new Date()), @@ -334,6 +342,22 @@ const AnalysisParameters: React.FC = ({ open?.({ type: "error", 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 = ({ }; const handleAnalyze = async () => { - if (!startTime || !drainageNode || !schemeName.trim()) { + if (!startTime || !drainageNode || !isSchemeNameValid(schemeName)) { open?.({ type: "error", message: "请填写完整参数", @@ -442,7 +466,7 @@ const AnalysisParameters: React.FC = ({ const formattedTime = startTime.format("YYYY-MM-DDTHH:mm:00Z"); // ISO format with seconds set to 00 const params = { - scheme_name: schemeName, + scheme_name: normalizeSchemeName(schemeName), start_time: formattedTime, ...(valves.length > 0 && { valves: valves.map(v => v.id), @@ -480,7 +504,7 @@ const AnalysisParameters: React.FC = ({ open?.({ type: "error", message: "提交分析失败", - description: error instanceof Error ? error.message : "未知错误", + description: getApiErrorMessage(error, "管道冲洗分析请求失败"), }); } finally { setAnalyzing(false); @@ -668,6 +692,13 @@ const AnalysisParameters: React.FC = ({ value={schemeName} onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="请输入方案名称" + error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH} + helperText={ + schemeName.trim().length > SCHEME_NAME_MAX_LENGTH + ? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符` + : undefined + } + inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }} /> @@ -710,7 +741,7 @@ const AnalysisParameters: React.FC = ({ onClick={handleAnalyze} disabled={ analyzing || - !schemeName.trim() || + !isSchemeNameValid(schemeName) || !drainageNode || !startTime || // !flushFlow || diff --git a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx index 0b482f3..553f036 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx @@ -12,8 +12,16 @@ import { PlayArrow as PlayArrowIcon } from "@mui/icons-material"; import { useNotification } from "@refinedev/core"; import { NETWORK_NAME } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; +import { getApiErrorMessage } from "@/lib/apiError"; import { optimizeSensorPlacement } from "./schemeApi"; import type { SensorPlacementScheme } from "./types"; +import { + createSchemeName, + isSchemeNameValid, + normalizeSchemeName, + SCHEME_NAME_MAX_LENGTH, + SCHEME_NAME_PREFIXES, +} from "@utils/schemeName"; export interface OptimizationParametersState { method: string; @@ -27,7 +35,7 @@ export const createOptimizationParametersState = method: "kmeans", sensorCount: 5, minDiameter: 5, - schemeName: "Fangan" + new Date().getTime(), + schemeName: createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement), }); interface OptimizationParametersProps { @@ -62,7 +70,7 @@ const OptimizationParameters: React.FC = ({ // 创建方案 const handleCreateScheme = async () => { // 验证输入 - if (!schemeName.trim()) { + if (!isSchemeNameValid(schemeName)) { open?.({ type: "error", message: "请输入方案名称", @@ -90,7 +98,7 @@ const OptimizationParameters: React.FC = ({ try { const created = await optimizeSensorPlacement({ - scheme_name: schemeName, + scheme_name: normalizeSchemeName(schemeName), sensor_type: "pressure", method: method as "sensitivity" | "kmeans", sensor_count: sensorCount, @@ -102,14 +110,16 @@ const OptimizationParameters: React.FC = ({ description: `方案 "${schemeName}" 已完成优化分析`, }); onSchemeCreated?.(created); - setFormField("schemeName", "Fangan" + new Date().getTime()); - } catch (error: any) { + setFormField( + "schemeName", + createSchemeName(SCHEME_NAME_PREFIXES.sensorPlacement), + ); + } catch (error) { console.error("创建方案失败:", error); open?.({ type: "error", message: "创建方案失败", - description: - error.response?.data?.message || error.message || "未知错误", + description: getApiErrorMessage(error, "监测点优化请求失败"), }); } finally { setAnalyzing(false); @@ -255,6 +265,13 @@ const OptimizationParameters: React.FC = ({ value={schemeName} onChange={(e) => setFormField("schemeName", e.target.value)} placeholder="请输入方案名称" + error={schemeName.trim().length > SCHEME_NAME_MAX_LENGTH} + helperText={ + schemeName.trim().length > SCHEME_NAME_MAX_LENGTH + ? `方案名称不能超过 ${SCHEME_NAME_MAX_LENGTH} 个字符` + : undefined + } + inputProps={{ maxLength: SCHEME_NAME_MAX_LENGTH }} sx={{ "& .MuiOutlinedInput-root": { "&:hover fieldset": { diff --git a/src/lib/apiError.test.ts b/src/lib/apiError.test.ts new file mode 100644 index 0000000..d8996d3 --- /dev/null +++ b/src/lib/apiError.test.ts @@ -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: "Bad Gateway", + }, + }), + ).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)"); + }); +}); diff --git a/src/lib/apiError.ts b/src/lib/apiError.ts new file mode 100644 index 0000000..994f017 --- /dev/null +++ b/src/lib/apiError.ts @@ -0,0 +1,258 @@ +type UnknownRecord = Record; + +type ErrorResponse = { + status?: number; + data?: unknown; +}; + +const FIELD_LABELS: Record = { + 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 = { + 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 (/]+>/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((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; +}; diff --git a/src/utils/schemeName.test.ts b/src/utils/schemeName.test.ts new file mode 100644 index 0000000..8fda4cc --- /dev/null +++ b/src/utils/schemeName.test.ts @@ -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); + }); +}); diff --git a/src/utils/schemeName.ts b/src/utils/schemeName.ts new file mode 100644 index 0000000..fde88bb --- /dev/null +++ b/src/utils/schemeName.ts @@ -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}`; +};