diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index cd08f0e..81b3eb5 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -11038,7 +11038,7 @@ }, "/api/v1/flushing-analyses": { "post": { - "description": "高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", + "description": "高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。", "operationId": "post_flushing_analyses", "parameters": [ { @@ -11053,31 +11053,92 @@ } }, { - "description": "要开启的阀门ID列表", + "description": "参与控制的阀门ID列表(可选)", "in": "query", "name": "valves", - "required": true, + "required": false, "schema": { - "description": "要开启的阀门ID列表", - "items": { - "type": "string" - }, - "title": "Valves", - "type": "array" + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "参与控制的阀门ID列表(可选)", + "title": "Valves" } }, { - "description": "对应各阀门的开度列表(0-1)", + "description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)", "in": "query", "name": "valves_k", - "required": true, + "required": false, "schema": { - "description": "对应各阀门的开度列表(0-1)", - "items": { - "type": "number" - }, - "title": "Valves K", - "type": "array" + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)", + "title": "Valves K" + } + }, + { + "description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)", + "in": "query", + "name": "valve_statuses", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "enum": [ + "OPEN", + "CLOSED", + "ACTIVE" + ], + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)", + "title": "Valve Statuses" + } + }, + { + "description": "对应各阀门的设置值列表(ACTIVE状态下必填)", + "in": "query", + "name": "valve_settings", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "对应各阀门的设置值列表(ACTIVE状态下必填)", + "title": "Valve Settings" } }, { diff --git a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx index b7601d4..c028a6a 100644 --- a/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx +++ b/src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Box, TextField, @@ -8,8 +8,8 @@ import { Typography, IconButton, Stack, - Alert, Divider, + MenuItem, } from "@mui/material"; import { AdjustOutlined, @@ -33,14 +33,25 @@ import { import Feature, { FeatureLike } from "ol/Feature"; import { useNotification } from "@refinedev/core"; import { api } from "@/lib/api"; -import { config, NETWORK_NAME } from "@/config/config"; +import { config } from "@/config/config"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; +import { + type LinkStatus, + getValveSettingHelperText, + isLinkStatus, + normalizeValveSetting, + VALVE_STATUS_OPTIONS, + validateValveSetting, +} from "@components/olmap/core/Controls/valveControl"; export interface ValveItem { id: string; - k: number; + setting?: string | null; + vType?: string | null; + status?: LinkStatus | null; + detailsLoaded?: boolean; } export interface FlushingAnalysisParametersState { @@ -92,6 +103,7 @@ const AnalysisParameters: React.FC = ({ const [selectionMode, setSelectionMode] = useState<'none' | 'valve' | 'drainage'>('none'); const [analyzing, setAnalyzing] = useState(false); + const valveSettingRequests = useRef(new Set()); const [highlightLayer, setHighlightLayer] = useState | null>(null); @@ -119,7 +131,7 @@ const AnalysisParameters: React.FC = ({ if (!feature) return; const layer = feature.getId()?.toString().split(".")[0]; - const featureId = feature.getProperties().id; + const featureId = String(feature.getProperties().id); if (selectionMode === 'valve') { if (layer !== 'geo_valves') { @@ -139,7 +151,16 @@ const AnalysisParameters: React.FC = ({ return prev; } setValveFeatures((features) => [...features, feature]); - return [...prev, { id: featureId, k: 1.0 }]; + return [ + ...prev, + { + id: featureId, + setting: undefined, + vType: undefined, + status: undefined, + detailsLoaded: false, + }, + ]; }); } else if (selectionMode === 'drainage') { @@ -253,6 +274,75 @@ const AnalysisParameters: React.FC = ({ }); }, [valveFeatures.length, valves]); + useEffect(() => { + valves.forEach((valve) => { + if (valve.detailsLoaded || valveSettingRequests.current.has(valve.id)) { + return; + } + + valveSettingRequests.current.add(valve.id); + void Promise.allSettled([ + api.get(`${config.BACKEND_URL}/api/v1/valves/properties`, { + params: { valve: valve.id }, + }), + api.get(`${config.BACKEND_URL}/api/v1/status`, { + params: { link: valve.id }, + }), + ]) + .then(([propertiesResult, statusResult]) => { + const properties = + propertiesResult.status === "fulfilled" + ? propertiesResult.value.data + : null; + const rawSetting = properties?.setting; + const rawStatus = + statusResult.status === "fulfilled" + ? statusResult.value.data?.status + : null; + const status = isLinkStatus(rawStatus) ? rawStatus : null; + + setValves((previous) => + previous.map((item) => + item.id === valve.id + ? { + ...item, + setting: normalizeValveSetting(rawSetting), + vType: properties?.v_type + ? String(properties.v_type) + : null, + status, + detailsLoaded: true, + } + : item, + ), + ); + + if ( + propertiesResult.status === "rejected" || + statusResult.status === "rejected" + ) { + console.error("读取阀门属性失败", { + propertiesError: + propertiesResult.status === "rejected" + ? propertiesResult.reason + : undefined, + statusError: + statusResult.status === "rejected" + ? statusResult.reason + : undefined, + }); + open?.({ + type: "error", + message: `阀门 ${valve.id} 的部分属性读取失败`, + }); + } + }) + .finally(() => { + valveSettingRequests.current.delete(valve.id); + }); + }); + }, [open, setValves, valves]); + useEffect(() => { if (!drainageNode) { setDrainageFeature(null); @@ -292,9 +382,21 @@ const AnalysisParameters: React.FC = ({ ); }; - const handleValveKChange = (id: string, k: string) => { - const numK = parseFloat(k); - setValves(prev => prev.map(v => v.id === id ? { ...v, k: isNaN(numK) ? 0 : numK } : v)); + const handleValveStatusChange = (id: string, status: string) => { + if (!isLinkStatus(status)) return; + setValves((previous) => + previous.map((valve) => + valve.id === id ? { ...valve, status } : valve, + ), + ); + }; + + const handleValveSettingChange = (id: string, setting: string) => { + setValves((previous) => + previous.map((valve) => + valve.id === id ? { ...valve, setting } : valve, + ), + ); }; const handleAnalyze = async () => { @@ -307,6 +409,33 @@ const AnalysisParameters: React.FC = ({ return; } + if (valves.some((valve) => !isLinkStatus(valve.status))) { + open?.({ + type: "error", + message: "阀门开关状态未设置", + description: "请为所有参与阀门选择开启、关闭或激活状态", + }); + return; + } + + const invalidActiveValve = valves.find( + (valve) => + valve.status === "ACTIVE" && + validateValveSetting(valve.vType, valve.setting ?? ""), + ); + if (invalidActiveValve) { + open?.({ + type: "error", + message: `阀门 ${invalidActiveValve.id} 的设置值无效`, + description: + validateValveSetting( + invalidActiveValve.vType, + invalidActiveValve.setting ?? "", + ) ?? undefined, + }); + return; + } + setAnalyzing(true); try { @@ -315,8 +444,13 @@ const AnalysisParameters: React.FC = ({ const params = { scheme_name: schemeName, start_time: formattedTime, - valves: valves.map(v => v.id), - valves_k: valves.map(v => v.k), + ...(valves.length > 0 && { + valves: valves.map(v => v.id), + valve_statuses: valves.map((v) => v.status), + valve_settings: valves.map((v) => + v.status === "ACTIVE" ? (v.setting ?? "").trim() : "", + ), + }), drainage_node_id: drainageNode, flush_flow: flushFlow, duration: duration @@ -355,62 +489,11 @@ const AnalysisParameters: React.FC = ({ return ( - {/* 1. Valve Selection */} + {/* 1. Drainage Node Selection */} - 参与阀门 - - - - {selectionMode === 'valve' && ( - - 💡 点击地图上的阀门进行添加 - - )} - - {valves.map((valve) => ( - - {valve.id} - handleValveKChange(valve.id, e.target.value)} - className="w-20" - slotProps={{ htmlInput: { step: 0.1, min: 0, max: 1 } }} - /> - handleRemoveValve(valve.id)}> - - - - ))} - {valves.length === 0 && ( - } - title="尚未选择阀门" - description="点击“选择阀门”,然后在地图上添加。" - /> - )} - - - - - - {/* 2. Drainage Node Selection */} - - - - 排水节点 + 排水节点(必选) + + {selectionMode === 'valve' && ( + + 💡 点击地图上的阀门进行添加 + + )} + + {valves.map((valve) => { + const settingValidation = + valve.status === "ACTIVE" + ? validateValveSetting(valve.vType, valve.setting ?? "") + : null; + const isSettingDisabled = + !valve.detailsLoaded || + valve.status === "OPEN" || + valve.status === "CLOSED"; + + return ( + + + + {valve.id} + + handleRemoveValve(valve.id)} + > + + + + + + handleValveStatusChange(valve.id, event.target.value) + } + > + {VALVE_STATUS_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + handleValveSettingChange(valve.id, event.target.value) + } + /> + + + ); + })} + {valves.length === 0 && ( + } + title="未选择参与阀门" + description="无需调整阀门时可跳过,也可点击“选择阀门”添加。" + /> + )} + + + + + {/* 3. Parameters */} diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index cadd22e..b6f9a0f 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -25,6 +25,12 @@ import { } from "./toolbarFeatureHelpers"; import { useToolbarChatActions } from "./useToolbarChatActions"; import { useStyleEditor } from "./useStyleEditor"; +import { + type LinkStatus, + isLinkStatus, + normalizeValveSetting, + validateValveSetting, +} from "./valveControl"; import { config, NETWORK_NAME } from "@/config/config"; import { useProject } from "@/contexts/ProjectContext"; @@ -41,7 +47,6 @@ interface ToolbarProps { enableCompare?: boolean; } -type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE"; type ValveProperties = { vType: string | null; setting: string | null; @@ -50,42 +55,6 @@ type ValveProperties = { const isValveLayer = (layerId: string | undefined) => layerId === "geo_valves_mat" || layerId === "geo_valves"; -const isLinkStatus = (value: unknown): value is LinkStatus => - value === "OPEN" || value === "CLOSED" || value === "ACTIVE"; - -const normalizeValveSetting = (value: unknown): string | null => { - if (value === undefined || value === null) return null; - return String(value); -}; - -const validateValveSetting = ( - valveType: string | null, - value: string, -): string | null => { - const normalizedType = valveType?.toUpperCase(); - const trimmedValue = value.trim(); - const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]); - - if (normalizedType === "GPV") { - return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。"; - } - - if (numericTypes.has(normalizedType ?? "")) { - if (!trimmedValue) { - return "阀门设置值必须是 0 或正数。"; - } - - const numericValue = Number(trimmedValue); - if (!Number.isFinite(numericValue) || numericValue < 0) { - return "阀门设置值必须是有限数字,且大于或等于 0。"; - } - - return null; - } - - return trimmedValue ? null : "阀门设置值不能为空。"; -}; - const Toolbar: React.FC = ({ hiddenButtons, queryType, diff --git a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts index 577f48f..6c88018 100644 --- a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts +++ b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts @@ -1,6 +1,10 @@ import Feature from "ol/Feature"; import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units"; +import { + getValveSettingHelperText, + VALVE_STATUS_OPTIONS, +} from "./valveControl"; type ToolbarBaseProperty = { label: string; @@ -66,30 +70,6 @@ export type ValveSettingPropertyOptions = { onSave: (value: string) => Promise; }; -const getValveSettingHelperText = ( - vType: string | null, - status?: string | null, -) => { - if (status === "OPEN" || status === "CLOSED") { - return "开启/关闭状态下 EPANET 会忽略阀门设置值"; - } - - switch (vType?.toUpperCase()) { - case "PRV": - case "PSV": - case "PBV": - return "压力设置值,需为 0 或正数"; - case "FCV": - return "流量设置值,需为 0 或正数"; - case "TCV": - return "损失系数,需为 0 或正数"; - case "GPV": - return "水头损失曲线 ID"; - default: - return "阀门类型相关设置值"; - } -}; - const getFeatureHistoryType = (feature: Feature): string | null => { const layerId = feature.getId()?.toString().split(".")[0] || ""; if (layerId.includes("pipe")) return "pipe"; @@ -366,11 +346,7 @@ export const buildFeatureProperties = ( type: "select" as const, label: "开关状态", value: valveStatus.value ?? "", - options: [ - { label: "开启", value: "OPEN" }, - { label: "关闭", value: "CLOSED" }, - { label: "激活", value: "ACTIVE" }, - ], + options: VALVE_STATUS_OPTIONS, placeholder: valveStatus.loading ? "加载中" : "未设置", disabled: valveStatus.loading, saving: valveStatus.saving, diff --git a/src/components/olmap/core/Controls/valveControl.test.ts b/src/components/olmap/core/Controls/valveControl.test.ts new file mode 100644 index 0000000..5551871 --- /dev/null +++ b/src/components/olmap/core/Controls/valveControl.test.ts @@ -0,0 +1,36 @@ +import { + getValveSettingHelperText, + isLinkStatus, + normalizeValveSetting, + VALVE_STATUS_OPTIONS, + validateValveSetting, +} from "./valveControl"; + +describe("valveControl", () => { + it("provides the status translations shared by valve editors", () => { + expect(VALVE_STATUS_OPTIONS).toEqual([ + { label: "开启", value: "OPEN" }, + { label: "关闭", value: "CLOSED" }, + { label: "激活", value: "ACTIVE" }, + ]); + expect(isLinkStatus("ACTIVE")).toBe(true); + expect(isLinkStatus("UNKNOWN")).toBe(false); + }); + + it("normalizes setting values without dropping zero", () => { + expect(normalizeValveSetting(0)).toBe("0"); + expect(normalizeValveSetting(null)).toBeNull(); + }); + + it("validates numeric and curve settings by valve type", () => { + expect(validateValveSetting("PRV", "2.5")).toBeNull(); + expect(validateValveSetting("PRV", "-1")).toContain("大于或等于 0"); + expect(validateValveSetting("GPV", "curve-1")).toBeNull(); + expect(validateValveSetting("GPV", " ")).toContain("曲线 ID"); + }); + + it("explains that OPEN and CLOSED ignore the setting", () => { + expect(getValveSettingHelperText("PRV", "OPEN")).toContain("忽略"); + expect(getValveSettingHelperText("FCV", "ACTIVE")).toContain("流量"); + }); +}); diff --git a/src/components/olmap/core/Controls/valveControl.ts b/src/components/olmap/core/Controls/valveControl.ts new file mode 100644 index 0000000..1503a36 --- /dev/null +++ b/src/components/olmap/core/Controls/valveControl.ts @@ -0,0 +1,70 @@ +export type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE"; + +export const VALVE_STATUS_OPTIONS: Array<{ + label: string; + value: LinkStatus; +}> = [ + { label: "开启", value: "OPEN" }, + { label: "关闭", value: "CLOSED" }, + { label: "激活", value: "ACTIVE" }, +]; + +export const isLinkStatus = (value: unknown): value is LinkStatus => + value === "OPEN" || value === "CLOSED" || value === "ACTIVE"; + +export const normalizeValveSetting = (value: unknown): string | null => { + if (value === undefined || value === null) return null; + return String(value); +}; + +export const validateValveSetting = ( + valveType: string | null | undefined, + value: string, +): string | null => { + const normalizedType = valveType?.toUpperCase(); + const trimmedValue = value.trim(); + const numericTypes = new Set(["PRV", "PSV", "PBV", "FCV", "TCV"]); + + if (normalizedType === "GPV") { + return trimmedValue ? null : "GPV 阀门设置值必须是非空曲线 ID。"; + } + + if (numericTypes.has(normalizedType ?? "")) { + if (!trimmedValue) { + return "阀门设置值必须是 0 或正数。"; + } + + const numericValue = Number(trimmedValue); + if (!Number.isFinite(numericValue) || numericValue < 0) { + return "阀门设置值必须是有限数字,且大于或等于 0。"; + } + + return null; + } + + return trimmedValue ? null : "阀门设置值不能为空。"; +}; + +export const getValveSettingHelperText = ( + valveType: string | null | undefined, + status?: LinkStatus | string | null, +) => { + if (status === "OPEN" || status === "CLOSED") { + return "开启/关闭状态下 EPANET 会忽略阀门设置值"; + } + + switch (valveType?.toUpperCase()) { + case "PRV": + case "PSV": + case "PBV": + return "压力设置值,需为 0 或正数"; + case "FCV": + return "流量设置值,需为 0 或正数"; + case "TCV": + return "损失系数,需为 0 或正数"; + case "GPV": + return "水头损失曲线 ID"; + default: + return "阀门类型相关设置值"; + } +}; diff --git a/src/generated/serverApi.ts b/src/generated/serverApi.ts index c0737cd..ea61165 100644 --- a/src/generated/serverApi.ts +++ b/src/generated/serverApi.ts @@ -1121,7 +1121,7 @@ export interface paths { put?: never; /** * 冲洗分析(高级) - * @description 高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。 + * @description 高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。 */ post: operations["post_flushing_analyses"]; delete?: never; @@ -14004,10 +14004,14 @@ export interface operations { query: { /** @description 冲洗开始时间(ISO 8601格式) */ start_time: string; - /** @description 要开启的阀门ID列表 */ - valves: string[]; - /** @description 对应各阀门的开度列表(0-1) */ - valves_k: number[]; + /** @description 参与控制的阀门ID列表(可选) */ + valves?: string[] | null; + /** @description 对应各阀门的开度列表(0-1,可选,与valves同时提供) */ + valves_k?: number[] | null; + /** @description 对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选) */ + valve_statuses?: ("OPEN" | "CLOSED" | "ACTIVE")[] | null; + /** @description 对应各阀门的设置值列表(ACTIVE状态下必填) */ + valve_settings?: string[] | null; /** @description 排污节点ID */ drainage_node_id: string; /** @description 冲洗流量(L/s),0表示自动计算 */