feat(map): add valve setting editor

This commit is contained in:
2026-07-16 17:48:16 +08:00
parent ef2b045306
commit eb8950c89a
3 changed files with 654 additions and 8 deletions
@@ -1,9 +1,19 @@
"use client"; "use client";
import React, { useRef } from "react"; import React, { useRef, useState } from "react";
import Draggable from "react-draggable"; import Draggable from "react-draggable";
import { Close } from "@mui/icons-material"; 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 { interface BaseProperty {
label: string; label: string;
@@ -20,7 +30,29 @@ interface TableProperty {
rows: (string | number)[][]; // 每行的数据 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<void>;
}
interface TextProperty {
type: "text";
label: string;
value: string;
placeholder?: string;
disabled?: boolean;
saving?: boolean;
helperText?: string;
onSave: (value: string) => Promise<void>;
}
type PropertyItem = BaseProperty | TableProperty | SelectProperty | TextProperty;
interface PropertyPanelProps { interface PropertyPanelProps {
id?: string; id?: string;
@@ -36,6 +68,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
onClose, onClose,
}) => { }) => {
const draggableRef = useRef<HTMLDivElement>(null); const draggableRef = useRef<HTMLDivElement>(null);
const [draftValues, setDraftValues] = useState<Record<string, string>>({});
const headerActionSx = { const headerActionSx = {
color: "common.white", color: "common.white",
backgroundColor: "rgba(255,255,255,0.08)", backgroundColor: "rgba(255,255,255,0.08)",
@@ -56,6 +89,26 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"]; const isImportantKeys = ["ID", "类型", "Name", "面积", "长度"];
const handleSelectChange = (
key: string,
event: SelectChangeEvent,
) => {
setDraftValues((prev) => ({
...prev,
[key]: event.target.value,
}));
};
const handleTextChange = (
key: string,
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setDraftValues((prev) => ({
...prev,
[key]: event.target.value,
}));
};
// 统计属性数量(表格型按行数计入) // 统计属性数量(表格型按行数计入)
const totalProps = id const totalProps = id
? 2 + ? 2 +
@@ -200,6 +253,151 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
); );
} }
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 (
<div
key={`select-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
{selected.label}
</span>
<div className="flex items-center justify-end gap-2 flex-1">
<FormControl size="small" sx={{ minWidth: 118 }}>
<Select
value={draftValue}
displayEmpty
disabled={selected.disabled || selected.saving}
onChange={(event) =>
handleSelectChange(draftKey, event)
}
renderValue={(value) =>
value
? getOptionLabel(String(value))
: selected.placeholder || "-"
}
sx={{
height: 32,
fontSize: 13,
backgroundColor: "white",
}}
>
<MenuItem value="" disabled>
{selected.placeholder || "未设置"}
</MenuItem>
{selected.options.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
size="small"
variant="contained"
disabled={!canSave}
onClick={() => selected.onSave(draftValue)}
sx={{
minWidth: 58,
height: 32,
fontSize: 13,
boxShadow: "none",
}}
>
{selected.saving ? (
<CircularProgress size={16} color="inherit" />
) : (
"保存"
)}
</Button>
</div>
</div>
</div>
);
}
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 (
<div
key={`text-${index}`}
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
>
<div className="flex items-start justify-between gap-3">
<span className="font-medium text-xs uppercase tracking-wide text-gray-600 pt-2">
{editable.label}
</span>
<div className="flex flex-col items-end gap-1 flex-1">
<div className="flex items-center justify-end gap-2 w-full">
<TextField
size="small"
value={draftValue}
placeholder={editable.placeholder}
disabled={editable.disabled || editable.saving}
onChange={(event) =>
handleTextChange(draftKey, event)
}
sx={{
width: 150,
"& .MuiInputBase-input": {
height: 15,
fontSize: 13,
},
backgroundColor: "white",
}}
/>
<Button
size="small"
variant="contained"
disabled={!canSave}
onClick={() => editable.onSave(draftValue)}
sx={{
minWidth: 58,
height: 32,
fontSize: 13,
boxShadow: "none",
}}
>
{editable.saving ? (
<CircularProgress size={16} color="inherit" />
) : (
"保存"
)}
</Button>
</div>
{editable.helperText && (
<span className="text-xs text-gray-500 text-right">
{editable.helperText}
</span>
)}
</div>
</div>
</div>
);
}
// 普通属性 // 普通属性
const base = property as BaseProperty; const base = property as BaseProperty;
const isImportant = isImportantKeys.includes(base.label); const isImportant = isImportantKeys.includes(base.label);
+342 -3
View File
@@ -26,7 +26,8 @@ import {
import { useToolbarChatActions } from "./useToolbarChatActions"; import { useToolbarChatActions } from "./useToolbarChatActions";
import { useStyleEditor } from "./useStyleEditor"; 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"; import { apiFetch } from "@/lib/apiFetch";
// 添加接口定义隐藏按钮的props // 添加接口定义隐藏按钮的props
@@ -37,6 +38,52 @@ interface ToolbarProps {
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板 HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
enableCompare?: boolean; 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<ToolbarProps> = ({ const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons, hiddenButtons,
queryType, queryType,
@@ -46,6 +93,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
}) => { }) => {
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
const project = useProject();
const { open } = useNotification(); const { open } = useNotification();
const [activeTools, setActiveTools] = useState<string[]>([]); const [activeTools, setActiveTools] = useState<string[]>([]);
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]); const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
@@ -58,6 +106,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
const currentTime = data?.currentTime; const currentTime = data?.currentTime;
const selectedDate = data?.selectedDate; const selectedDate = data?.selectedDate;
const schemeName = data?.schemeName; const schemeName = data?.schemeName;
const networkName = project?.networkName || NETWORK_NAME;
const isCompareMode = data?.isCompareMode ?? false; const isCompareMode = data?.isCompareMode ?? false;
const toggleCompareMode = data?.toggleCompareMode; const toggleCompareMode = data?.toggleCompareMode;
const canToggleCompare = Boolean( const canToggleCompare = Boolean(
@@ -325,6 +374,261 @@ const Toolbar: React.FC<ToolbarProps> = ({
const [computedProperties, setComputedProperties] = useState< const [computedProperties, setComputedProperties] = useState<
Record<string, any> Record<string, any>
>({}); >({});
const [valveStatus, setValveStatus] = useState<LinkStatus | null>(null);
const [isValveStatusLoading, setIsValveStatusLoading] = useState(false);
const [isValveStatusSaving, setIsValveStatusSaving] = useState(false);
const [valveProperties, setValveProperties] = useState<ValveProperties>({
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 来查询计算属性
useEffect(() => { useEffect(() => {
if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) { if (highlightFeatures.length === 0 || !selectedDate || !showPropertyPanel) {
@@ -387,8 +691,43 @@ const Toolbar: React.FC<ToolbarProps> = ({
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]); }, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
const propertyPanelData = useMemo( 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) { if (!data) {
@@ -16,7 +16,33 @@ type ToolbarTableProperty = {
rows: (string | number)[][]; 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<void>;
};
type ToolbarTextProperty = {
type: "text";
label: string;
value: string;
placeholder?: string;
disabled?: boolean;
saving?: boolean;
helperText?: string;
onSave: (value: string) => Promise<void>;
};
export type ToolbarPropertyItem =
| ToolbarBaseProperty
| ToolbarTableProperty
| ToolbarSelectProperty
| ToolbarTextProperty;
export type ToolbarPropertyPanelData = { export type ToolbarPropertyPanelData = {
id?: string; id?: string;
@@ -24,6 +50,46 @@ export type ToolbarPropertyPanelData = {
properties?: ToolbarPropertyItem[]; properties?: ToolbarPropertyItem[];
}; };
export type ValveStatusPropertyOptions = {
value: string | null;
loading?: boolean;
saving?: boolean;
onSave: (value: string) => Promise<void>;
};
export type ValveSettingPropertyOptions = {
value: string | null;
vType: string | null;
loading?: boolean;
saving?: boolean;
status?: string | null;
onSave: (value: string) => Promise<void>;
};
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 getFeatureHistoryType = (feature: Feature): string | null => {
const layerId = feature.getId()?.toString().split(".")[0] || ""; const layerId = feature.getId()?.toString().split(".")[0] || "";
if (layerId.includes("pipe")) return "pipe"; if (layerId.includes("pipe")) return "pipe";
@@ -56,6 +122,8 @@ export const inferHistoryFeatureInfos = (
export const buildFeatureProperties = ( export const buildFeatureProperties = (
highlightFeature: Feature | undefined, highlightFeature: Feature | undefined,
computedProperties: Record<string, any>, computedProperties: Record<string, any>,
valveStatus?: ValveStatusPropertyOptions,
valveSetting?: ValveSettingPropertyOptions,
): ToolbarPropertyPanelData => { ): ToolbarPropertyPanelData => {
if (!highlightFeature) return {}; if (!highlightFeature) return {};
@@ -267,6 +335,12 @@ export const buildFeatureProperties = (
} }
if (layer === "geo_valves_mat" || layer === "geo_valves") { 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 { return {
id: properties.id, id: properties.id,
type: "阀门", type: "阀门",
@@ -280,12 +354,47 @@ export const buildFeatureProperties = (
}, },
{ {
label: "阀门类型", label: "阀门类型",
value: properties.v_type, value: valveType,
}, },
{ {
label: "局部损失", label: "局部损失",
value: properties.minor_loss?.toFixed?.(2), 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,
},
]
: []),
], ],
}; };
} }