diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index ea49f1b..b69ef3b 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -1,9 +1,19 @@ "use client"; -import React, { useRef } from "react"; +import React, { useRef, useState } from "react"; import Draggable from "react-draggable"; import { Close } from "@mui/icons-material"; -import { IconButton, Tooltip } from "@mui/material"; +import { + Button, + CircularProgress, + FormControl, + IconButton, + MenuItem, + Select, + TextField, + Tooltip, +} from "@mui/material"; +import type { SelectChangeEvent } from "@mui/material/Select"; interface BaseProperty { label: string; @@ -20,7 +30,29 @@ interface TableProperty { rows: (string | number)[][]; // 每行的数据 } -type PropertyItem = BaseProperty | TableProperty; +interface SelectProperty { + type: "select"; + label: string; + value: string; + options: { label: string; value: string }[]; + placeholder?: string; + disabled?: boolean; + saving?: boolean; + onSave: (value: string) => Promise; +} + +interface TextProperty { + type: "text"; + label: string; + value: string; + placeholder?: string; + disabled?: boolean; + saving?: boolean; + helperText?: string; + onSave: (value: string) => Promise; +} + +type PropertyItem = BaseProperty | TableProperty | SelectProperty | TextProperty; interface PropertyPanelProps { id?: string; @@ -36,6 +68,7 @@ const PropertyPanel: React.FC = ({ onClose, }) => { const draggableRef = useRef(null); + const [draftValues, setDraftValues] = useState>({}); const headerActionSx = { color: "common.white", backgroundColor: "rgba(255,255,255,0.08)", @@ -56,6 +89,26 @@ const PropertyPanel: React.FC = ({ const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"]; + const handleSelectChange = ( + key: string, + event: SelectChangeEvent, + ) => { + setDraftValues((prev) => ({ + ...prev, + [key]: event.target.value, + })); + }; + + const handleTextChange = ( + key: string, + event: React.ChangeEvent, + ) => { + setDraftValues((prev) => ({ + ...prev, + [key]: event.target.value, + })); + }; + // 统计属性数量(表格型按行数计入) const totalProps = id ? 2 + @@ -200,6 +253,151 @@ const PropertyPanel: React.FC = ({ ); } + if ("type" in property && property.type === "select") { + const selected = property as SelectProperty; + const draftKey = `${id ?? "unknown"}:${selected.label}`; + const draftValue = draftValues[draftKey] ?? selected.value ?? ""; + const hasChanged = draftValue !== selected.value; + const getOptionLabel = (value: string) => + selected.options.find((option) => option.value === value) + ?.label ?? value; + const canSave = + hasChanged && + draftValue !== "" && + !selected.disabled && + !selected.saving; + + return ( +
+
+ + {selected.label} + +
+ + + + +
+
+
+ ); + } + + if ("type" in property && property.type === "text") { + const editable = property as TextProperty; + const draftKey = `${id ?? "unknown"}:${editable.label}`; + const draftValue = draftValues[draftKey] ?? editable.value ?? ""; + const hasChanged = draftValue !== editable.value; + const canSave = + hasChanged && + !editable.disabled && + !editable.saving; + + return ( +
+
+ + {editable.label} + +
+
+ + handleTextChange(draftKey, event) + } + sx={{ + width: 150, + "& .MuiInputBase-input": { + height: 15, + fontSize: 13, + }, + backgroundColor: "white", + }} + /> + +
+ {editable.helperText && ( + + {editable.helperText} + + )} +
+
+
+ ); + } + // 普通属性 const base = property as BaseProperty; const isImportant = isImportantKeys.includes(base.label); diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 772123d..f6e6387 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -26,7 +26,8 @@ import { import { useToolbarChatActions } from "./useToolbarChatActions"; import { useStyleEditor } from "./useStyleEditor"; -import { config } from "@/config/config"; +import { config, NETWORK_NAME } from "@/config/config"; +import { useProject } from "@/contexts/ProjectContext"; import { apiFetch } from "@/lib/apiFetch"; // 添加接口定义隐藏按钮的props @@ -37,6 +38,52 @@ interface ToolbarProps { HistoryPanel?: React.FC; // 可选的自定义历史数据面板 enableCompare?: boolean; } + +type LinkStatus = "OPEN" | "CLOSED" | "ACTIVE"; +type ValveProperties = { + vType: string | null; + setting: string | null; +}; + +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, @@ -46,6 +93,7 @@ const Toolbar: React.FC = ({ }) => { const map = useMap(); const data = useData(); + const project = useProject(); const { open } = useNotification(); const [activeTools, setActiveTools] = useState([]); const [highlightFeatures, setHighlightFeatures] = useState([]); @@ -58,6 +106,7 @@ const Toolbar: React.FC = ({ const currentTime = data?.currentTime; const selectedDate = data?.selectedDate; const schemeName = data?.schemeName; + const networkName = project?.networkName || NETWORK_NAME; const isCompareMode = data?.isCompareMode ?? false; const toggleCompareMode = data?.toggleCompareMode; const canToggleCompare = Boolean( @@ -325,6 +374,261 @@ const Toolbar: React.FC = ({ const [computedProperties, setComputedProperties] = useState< Record >({}); + const [valveStatus, setValveStatus] = useState(null); + const [isValveStatusLoading, setIsValveStatusLoading] = useState(false); + const [isValveStatusSaving, setIsValveStatusSaving] = useState(false); + const [valveProperties, setValveProperties] = useState({ + vType: null, + setting: null, + }); + const [isValvePropertiesLoading, setIsValvePropertiesLoading] = + useState(false); + const [isValveSettingSaving, setIsValveSettingSaving] = useState(false); + + const selectedFeature = highlightFeatures[0]; + const selectedFeatureLayer = selectedFeature + ?.getId() + ?.toString() + .split(".")[0]; + const selectedFeatureId = selectedFeature?.getProperties?.().id; + const selectedFeatureValveType = selectedFeature?.getProperties?.().v_type; + const selectedValveType = + valveProperties.vType ?? + (selectedFeatureValveType ? String(selectedFeatureValveType) : null); + const selectedValveId = + showPropertyPanel && isValveLayer(selectedFeatureLayer) && selectedFeatureId + ? String(selectedFeatureId) + : null; + + useEffect(() => { + if (!selectedValveId) { + setValveStatus(null); + setIsValveStatusLoading(false); + return; + } + + let cancelled = false; + + const queryValveStatus = async () => { + setIsValveStatusLoading(true); + setValveStatus(null); + + try { + const params = new URLSearchParams({ + network: networkName, + link: selectedValveId, + }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/getstatus/?${params.toString()}`, + ); + + if (!response.ok) { + throw new Error(`getstatus failed: ${response.status}`); + } + + const data = await response.json(); + if (!cancelled) { + setValveStatus(isLinkStatus(data?.status) ? data.status : null); + } + } catch (error) { + console.error("Error querying valve status:", error); + if (!cancelled) { + setValveStatus(null); + open?.({ + type: "error", + message: "读取阀门开关状态失败。", + }); + } + } finally { + if (!cancelled) { + setIsValveStatusLoading(false); + } + } + }; + + queryValveStatus(); + + return () => { + cancelled = true; + }; + }, [networkName, open, selectedValveId]); + + useEffect(() => { + if (!selectedValveId) { + setValveProperties({ vType: null, setting: null }); + setIsValvePropertiesLoading(false); + return; + } + + let cancelled = false; + + const queryValveProperties = async () => { + setIsValvePropertiesLoading(true); + setValveProperties({ vType: null, setting: null }); + + try { + const params = new URLSearchParams({ + network: networkName, + valve: selectedValveId, + }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/getvalveproperties/?${params.toString()}`, + ); + + if (!response.ok) { + throw new Error(`getvalveproperties failed: ${response.status}`); + } + + const data = await response.json(); + if (!cancelled) { + setValveProperties({ + vType: data?.v_type ? String(data.v_type) : null, + setting: normalizeValveSetting(data?.setting), + }); + } + } catch (error) { + console.error("Error querying valve properties:", error); + if (!cancelled) { + setValveProperties({ vType: null, setting: null }); + open?.({ + type: "error", + message: "读取阀门设置值失败。", + }); + } + } finally { + if (!cancelled) { + setIsValvePropertiesLoading(false); + } + } + }; + + queryValveProperties(); + + return () => { + cancelled = true; + }; + }, [networkName, open, selectedValveId]); + + const handleValveStatusSave = useCallback( + async (nextStatus: string) => { + if (!selectedValveId || !isLinkStatus(nextStatus)) { + return; + } + + setIsValveStatusSaving(true); + try { + const params = new URLSearchParams({ + network: networkName, + link: selectedValveId, + }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/setstatus/?${params.toString()}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ status: nextStatus }), + }, + ); + + if (!response.ok) { + throw new Error(`setstatus failed: ${response.status}`); + } + + setValveStatus(nextStatus); + open?.({ + type: "success", + message: "阀门开关状态已更新。", + }); + } catch (error) { + console.error("Error updating valve status:", error); + open?.({ + type: "error", + message: "阀门开关状态更新失败。", + }); + } finally { + setIsValveStatusSaving(false); + } + }, + [networkName, open, selectedValveId], + ); + + const handleValveSettingSave = useCallback( + async (nextSetting: string) => { + if (!selectedValveId) { + return; + } + + if (valveStatus === "OPEN" || valveStatus === "CLOSED") { + open?.({ + type: "error", + message: "开启或关闭状态下不能编辑阀门设置值。", + }); + return; + } + + const validationMessage = validateValveSetting( + selectedValveType, + nextSetting, + ); + if (validationMessage) { + open?.({ + type: "error", + message: validationMessage, + }); + return; + } + + const trimmedSetting = nextSetting.trim(); + setIsValveSettingSaving(true); + try { + const params = new URLSearchParams({ + network: networkName, + valve: selectedValveId, + }); + const response = await apiFetch( + `${config.BACKEND_URL}/api/v1/setvalveproperties/?${params.toString()}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ setting: trimmedSetting }), + }, + ); + + if (!response.ok) { + throw new Error(`setvalveproperties failed: ${response.status}`); + } + + setValveProperties((prev) => ({ + ...prev, + setting: trimmedSetting, + })); + open?.({ + type: "success", + message: "阀门设置值已更新。", + }); + } catch (error) { + console.error("Error updating valve setting:", error); + open?.({ + type: "error", + message: "阀门设置值更新失败。", + }); + } finally { + setIsValveSettingSaving(false); + } + }, + [ + networkName, + open, + selectedValveId, + selectedValveType, + valveStatus, + ], + ); + // 添加 useEffect 来查询计算属性 useEffect(() => { if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) { @@ -387,8 +691,43 @@ const Toolbar: React.FC = ({ }, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]); const propertyPanelData = useMemo( - () => buildFeatureProperties(highlightFeatures[0], computedProperties), - [highlightFeatures, computedProperties], + () => + buildFeatureProperties( + selectedFeature, + computedProperties, + selectedValveId + ? { + value: valveStatus, + loading: isValveStatusLoading, + saving: isValveStatusSaving, + onSave: handleValveStatusSave, + } + : undefined, + selectedValveId + ? { + value: valveProperties.setting, + vType: selectedValveType, + loading: isValvePropertiesLoading, + saving: isValveSettingSaving, + status: valveStatus, + onSave: handleValveSettingSave, + } + : undefined, + ), + [ + selectedFeature, + computedProperties, + selectedValveId, + valveStatus, + valveProperties, + selectedValveType, + isValveStatusLoading, + isValveStatusSaving, + isValvePropertiesLoading, + isValveSettingSaving, + handleValveStatusSave, + handleValveSettingSave, + ], ); if (!data) { diff --git a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts index dc1019b..577f48f 100644 --- a/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts +++ b/src/components/olmap/core/Controls/toolbarFeatureHelpers.ts @@ -16,7 +16,33 @@ type ToolbarTableProperty = { rows: (string | number)[][]; }; -export type ToolbarPropertyItem = ToolbarBaseProperty | ToolbarTableProperty; +type ToolbarSelectProperty = { + type: "select"; + label: string; + value: string; + options: { label: string; value: string }[]; + placeholder?: string; + disabled?: boolean; + saving?: boolean; + onSave: (value: string) => Promise; +}; + +type ToolbarTextProperty = { + type: "text"; + label: string; + value: string; + placeholder?: string; + disabled?: boolean; + saving?: boolean; + helperText?: string; + onSave: (value: string) => Promise; +}; + +export type ToolbarPropertyItem = + | ToolbarBaseProperty + | ToolbarTableProperty + | ToolbarSelectProperty + | ToolbarTextProperty; export type ToolbarPropertyPanelData = { id?: string; @@ -24,6 +50,46 @@ export type ToolbarPropertyPanelData = { properties?: ToolbarPropertyItem[]; }; +export type ValveStatusPropertyOptions = { + value: string | null; + loading?: boolean; + saving?: boolean; + onSave: (value: string) => Promise; +}; + +export type ValveSettingPropertyOptions = { + value: string | null; + vType: string | null; + loading?: boolean; + saving?: boolean; + status?: string | null; + 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"; @@ -56,6 +122,8 @@ export const inferHistoryFeatureInfos = ( export const buildFeatureProperties = ( highlightFeature: Feature | undefined, computedProperties: Record, + valveStatus?: ValveStatusPropertyOptions, + valveSetting?: ValveSettingPropertyOptions, ): ToolbarPropertyPanelData => { if (!highlightFeature) return {}; @@ -267,6 +335,12 @@ export const buildFeatureProperties = ( } if (layer === "geo_valves_mat" || layer === "geo_valves") { + const valveType = valveSetting?.vType ?? properties.v_type; + const isValveSettingDisabled = + valveSetting?.loading || + valveSetting?.status === "OPEN" || + valveSetting?.status === "CLOSED"; + return { id: properties.id, type: "阀门", @@ -280,12 +354,47 @@ export const buildFeatureProperties = ( }, { label: "阀门类型", - value: properties.v_type, + value: valveType, }, { label: "局部损失", value: properties.minor_loss?.toFixed?.(2), }, + ...(valveStatus + ? [ + { + type: "select" as const, + label: "开关状态", + value: valveStatus.value ?? "", + options: [ + { label: "开启", value: "OPEN" }, + { label: "关闭", value: "CLOSED" }, + { label: "激活", value: "ACTIVE" }, + ], + placeholder: valveStatus.loading ? "加载中" : "未设置", + disabled: valveStatus.loading, + saving: valveStatus.saving, + onSave: valveStatus.onSave, + }, + ] + : []), + ...(valveSetting + ? [ + { + type: "text" as const, + label: "阀门设置值", + value: valveSetting.value ?? "", + placeholder: valveSetting.loading ? "加载中" : "未设置", + disabled: isValveSettingDisabled, + saving: valveSetting.saving, + helperText: getValveSettingHelperText( + valveType, + valveSetting.status, + ), + onSave: valveSetting.onSave, + }, + ] + : []), ], }; }