合并 agent-mvp 到 master #1
@@ -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<Props> = ({
|
||||
|
||||
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<Props> = ({
|
||||
? "目标时刻存在异常信号,请优先复核相关测点。"
|
||||
: "目标时刻未发现爆管异常。",
|
||||
});
|
||||
} 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<Props> = ({
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
};
|
||||
|
||||
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<Props> = ({
|
||||
return burstStartTime.isBefore(burstEndTime);
|
||||
}, [
|
||||
burstLeakage,
|
||||
schemeName,
|
||||
burstStartTime,
|
||||
burstEndTime,
|
||||
dataSource,
|
||||
@@ -234,7 +246,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
`${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<Props> = ({
|
||||
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<Props> = ({
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<AnalysisParametersProps> = ({
|
||||
pipePoints.length > 0 &&
|
||||
startTime !== null &&
|
||||
duration > 0 &&
|
||||
schemeName.trim() !== "";
|
||||
isSchemeNameValid(schemeName);
|
||||
|
||||
// 地图点击选择要素事件处理函数
|
||||
const handleMapClickSelectFeatures = useCallback(
|
||||
@@ -333,7 +341,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
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<AnalysisParametersProps> = ({
|
||||
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<AnalysisParametersProps> = ({
|
||||
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 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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<AnalysisParametersProps> = ({
|
||||
Boolean(sourceNode) &&
|
||||
concentration > 0 &&
|
||||
duration > 0 &&
|
||||
schemeName.trim() !== ""
|
||||
isSchemeNameValid(schemeName)
|
||||
);
|
||||
}, [network, startTime, sourceNode, concentration, duration, schemeName]);
|
||||
|
||||
@@ -240,7 +248,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
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<AnalysisParametersProps> = ({
|
||||
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<AnalysisParametersProps> = ({
|
||||
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 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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<AnalysisParametersProps> = ({
|
||||
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<AnalysisParametersProps> = ({
|
||||
};
|
||||
|
||||
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<AnalysisParametersProps> = ({
|
||||
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<AnalysisParametersProps> = ({
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "提交分析失败",
|
||||
description: error instanceof Error ? error.message : "未知错误",
|
||||
description: getApiErrorMessage(error, "管道冲洗分析请求失败"),
|
||||
});
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
@@ -668,6 +692,13 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
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 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -710,7 +741,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
onClick={handleAnalyze}
|
||||
disabled={
|
||||
analyzing ||
|
||||
!schemeName.trim() ||
|
||||
!isSchemeNameValid(schemeName) ||
|
||||
!drainageNode ||
|
||||
!startTime ||
|
||||
// !flushFlow ||
|
||||
|
||||
@@ -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<OptimizationParametersProps> = ({
|
||||
// 创建方案
|
||||
const handleCreateScheme = async () => {
|
||||
// 验证输入
|
||||
if (!schemeName.trim()) {
|
||||
if (!isSchemeNameValid(schemeName)) {
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "请输入方案名称",
|
||||
@@ -90,7 +98,7 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
|
||||
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<OptimizationParametersProps> = ({
|
||||
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<OptimizationParametersProps> = ({
|
||||
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": {
|
||||
|
||||
@@ -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