Files
TJWaterFrontend_Refine/src/components/olmap/MonitoringPlaceOptimization/SchemeEditor.tsx
T

805 lines
23 KiB
TypeScript

"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import {
AddLocationAlt as AddIcon,
DeleteOutline as DeleteIcon,
Download as DownloadIcon,
EditLocationAlt as ReplaceIcon,
LocationOn as LocateIcon,
Map as MapIcon,
Redo as ResetIcon,
Save as SaveIcon,
Undo as UndoIcon,
} from "@mui/icons-material";
import {
DataGrid,
GridToolbar,
type GridColDef,
type GridRowSelectionModel,
} from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales";
import Feature, { type FeatureLike } from "ol/Feature";
import Point from "ol/geom/Point";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { Circle, Fill, Stroke, Style, Text } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { useNotification } from "@refinedev/core";
import { api } from "@/lib/api";
import { config } from "@/config/config";
import { useMap } from "@components/olmap/core/MapComponent";
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
import {
addSensorPoint,
createSchemeEditorState,
deleteSensorPoint,
isSchemeDirty,
replaceSensorPoint,
resetSchemeEdit,
summarizeChanges,
toSensorPointRows,
undoSchemeEdit,
} from "./schemeEditor";
import {
exportSensorPlacementExcel,
overwriteSensorPlacementScheme,
} from "./schemeApi";
import SchemeDrawingDialog from "./SchemeDrawingDialog";
import type {
AdjustmentStatus,
SensorPlacementScheme,
SensorPoint,
SensorPointRow,
} from "./types";
type EditMode = "idle" | "add" | "replace" | "delete";
interface SchemeEditorProps {
scheme: SensorPlacementScheme;
network: string;
active?: boolean;
onSaved?: (scheme: SensorPlacementScheme) => void;
}
const STATUS_LABELS: Record<AdjustmentStatus, string> = {
current: "当前方案",
original: "原方案",
added: "新增",
replaced: "替换",
};
const STATUS_COLORS: Record<
AdjustmentStatus,
"default" | "success" | "warning" | "info"
> = {
current: "default",
original: "info",
added: "success",
replaced: "warning",
};
const markerStyle = (feature: FeatureLike) => {
const status = feature.get("adjustment_status") as AdjustmentStatus;
const selected = Boolean(feature.get("selected"));
const color =
status === "added" ? "#16865b" : status === "replaced" ? "#d06b16" : "#c9252d";
return new Style({
image: new Circle({
radius: selected ? 11 : 9,
fill: new Fill({ color: "#ffffff" }),
stroke: new Stroke({ color, width: selected ? 4 : 3 }),
}),
text: new Text({
text: String(feature.get("sequence") ?? ""),
font: '700 11px -apple-system, "PingFang SC", sans-serif',
fill: new Fill({ color }),
offsetY: 0.5,
}),
zIndex: selected ? 20 : 10,
});
};
const errorDescription = (error: unknown) => {
const candidate = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return candidate.response?.data?.detail || candidate.message || "请求失败";
};
const SchemeEditor: React.FC<SchemeEditorProps> = ({
scheme,
network,
active = true,
onSaved,
}) => {
const map = useMap();
const { open } = useNotification();
const [editor, setEditor] = useState(() => createSchemeEditorState(scheme));
const [mode, setMode] = useState<EditMode>("idle");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [replaceSourceId, setReplaceSourceId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [drawingOpen, setDrawingOpen] = useState(false);
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
useEffect(() => {
setEditor(createSchemeEditorState(scheme));
setMode("idle");
setReplaceSourceId(null);
setSelectedNodeId(null);
}, [scheme]);
const rows = useMemo(() => toSensorPointRows(editor), [editor]);
const dirty = isSchemeDirty(editor);
const changes = summarizeChanges(editor);
useEffect(() => {
if (!map) return;
const layer = new VectorLayer({
source: new VectorSource(),
style: markerStyle,
properties: {
name: "监测点方案编辑",
value: "sensor_scheme_editor",
queryable: false,
},
zIndex: 120,
});
markerLayerRef.current = layer;
map.addLayer(layer);
return () => {
markerLayerRef.current = null;
map.removeLayer(layer);
};
}, [map]);
useEffect(() => {
markerLayerRef.current?.setVisible(active);
}, [active]);
useEffect(() => {
const source = markerLayerRef.current?.getSource();
if (!source) return;
source.clear();
rows.forEach((row) => {
const feature = new Feature({
geometry: new Point([row.map_x, row.map_y]),
node_id: row.node_id,
sequence: row.sequence,
adjustment_status: row.adjustment_status,
selected: row.node_id === selectedNodeId,
});
feature.setId(`sensor-scheme-${row.node_id}`);
source.addFeature(feature);
});
}, [rows, selectedNodeId]);
const resolveJunction = useCallback(
async (event: any): Promise<SensorPoint | null> => {
if (!map) return null;
const feature = await handleMapClickSelectFeatures(event, map);
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
if (!nodeId) return null;
const featureGeometry = feature?.getGeometry();
const featureCoordinate =
featureGeometry instanceof Point
? featureGeometry.getCoordinates()
: event.coordinate;
const projectedFeatureCoordinate =
Math.abs(featureCoordinate[0]) <= 180 &&
Math.abs(featureCoordinate[1]) <= 90
? fromLonLat(featureCoordinate)
: featureCoordinate;
const resolution = map.getView().getResolution() ?? 1;
const isAlignedWithMap =
Math.hypot(
projectedFeatureCoordinate[0] - event.coordinate[0],
projectedFeatureCoordinate[1] - event.coordinate[1],
) <=
resolution * 20;
const [mapX, mapY] = isAlignedWithMap
? projectedFeatureCoordinate
: event.coordinate;
try {
const response = await api.get<{
id: string;
x: number;
y: number;
elevation: number;
}>(`${config.BACKEND_URL}/api/v1/getjunctionproperties/`, {
params: { network, junction: nodeId },
});
if (!response.data?.id) return null;
const [longitude, latitude] = toLonLat([mapX, mapY]);
return {
node_id: String(response.data.id),
project_x: Number(response.data.x),
project_y: Number(response.data.y),
map_x: mapX,
map_y: mapY,
longitude,
latitude,
elevation: Number(response.data.elevation),
};
} catch {
return null;
}
},
[map, network],
);
useEffect(() => {
if (!active || !map || mode === "idle" || !scheme.can_edit) return;
let handling = false;
const processClick = async (event: any) => {
if (handling) return;
event.stopPropagation?.();
handling = true;
try {
const markerLayer = markerLayerRef.current;
const marker = markerLayer
? map.forEachFeatureAtPixel(
event.pixel,
(feature) => feature as Feature,
{
hitTolerance: 8,
layerFilter: (layer) => layer === markerLayer,
},
)
: undefined;
const markerNodeId = marker ? String(marker.get("node_id")) : null;
if (mode === "delete") {
if (!markerNodeId) {
open?.({ type: "progress", message: "请点击要删除的监测点" });
return;
}
setEditor((current) => deleteSensorPoint(current, markerNodeId));
setSelectedNodeId(null);
return;
}
if (mode === "replace" && !replaceSourceId) {
if (!markerNodeId) {
open?.({ type: "progress", message: "请先点击要替换的监测点" });
return;
}
setReplaceSourceId(markerNodeId);
setSelectedNodeId(markerNodeId);
return;
}
const candidate = await resolveJunction(event);
if (!candidate) {
open?.({ type: "progress", message: "请选择有效的管网节点" });
return;
}
if (mode === "add") {
const exists = editor.points.some(
(point) => point.node_id === candidate.node_id,
);
if (exists) {
open?.({ type: "progress", message: "该节点已在当前方案中" });
return;
}
setEditor((current) => addSensorPoint(current, candidate));
setSelectedNodeId(candidate.node_id);
} else if (mode === "replace" && replaceSourceId) {
const duplicate = editor.points.some(
(point) =>
point.node_id === candidate.node_id &&
point.node_id !== replaceSourceId,
);
if (duplicate) {
open?.({ type: "progress", message: "目标节点已在当前方案中" });
return;
}
setEditor((current) =>
replaceSensorPoint(current, replaceSourceId, candidate),
);
setSelectedNodeId(candidate.node_id);
setReplaceSourceId(null);
setMode("idle");
}
} finally {
handling = false;
}
};
const handleClick = (event: any) => {
void processClick(event);
};
map.on("singleclick", handleClick);
return () => {
map.un("singleclick", handleClick);
};
}, [
editor.points,
active,
map,
mode,
network,
open,
replaceSourceId,
resolveJunction,
scheme.can_edit,
]);
const activateMode = useCallback(
(nextMode: EditMode, sourceNodeId?: string) => {
setMode((current) =>
current === nextMode && !sourceNodeId ? "idle" : nextMode,
);
setReplaceSourceId(sourceNodeId ?? null);
if (sourceNodeId) setSelectedNodeId(sourceNodeId);
},
[],
);
const handleDelete = useCallback((nodeId: string) => {
setEditor((current) => deleteSensorPoint(current, nodeId));
if (selectedNodeId === nodeId) setSelectedNodeId(null);
}, [selectedNodeId]);
const locateRow = useCallback((row: SensorPointRow) => {
setSelectedNodeId(row.node_id);
if (!map) return;
map.getView().animate({
center: [row.map_x, row.map_y],
zoom: Math.max(map.getView().getZoom() ?? 16, 18),
duration: 300,
});
}, [map]);
const handleSave = async () => {
setSaving(true);
try {
const updated = await overwriteSensorPlacementScheme(
network,
scheme.id,
editor.baseline.map((point) => point.node_id),
editor.points.map((point) => point.node_id),
);
setEditor(createSchemeEditorState(updated));
onSaved?.(updated);
setSaveDialogOpen(false);
open?.({
type: "success",
message: "方案已覆盖保存",
description: `当前共 ${updated.sensor_number} 个监测点`,
});
} catch (error) {
open?.({
type: "error",
message:
(error as { response?: { status?: number } }).response?.status === 409
? "方案已发生变化"
: "方案保存失败",
description: errorDescription(error),
});
} finally {
setSaving(false);
}
};
const handleExport = async () => {
setExporting(true);
try {
const blob = await exportSensorPlacementExcel(
network,
scheme.id,
editor.points.map((point) => point.node_id),
editor.statuses,
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${scheme.scheme_name}${dirty ? "_未保存草稿" : ""}_监测点清单.xlsx`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
open?.({
type: "error",
message: "Excel 导出失败",
description: errorDescription(error),
});
} finally {
setExporting(false);
}
};
const columns = useMemo<GridColDef<SensorPointRow>[]>(
() => [
{
field: "sequence",
headerName: "序号",
width: 64,
align: "center",
headerAlign: "center",
sortable: false,
},
{ field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 },
{
field: "longitude",
headerName: "经度",
minWidth: 125,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(6),
},
{
field: "latitude",
headerName: "纬度",
minWidth: 125,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(6),
},
{
field: "project_x",
headerName: "工程 X",
minWidth: 130,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "project_y",
headerName: "工程 Y",
minWidth: 130,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "map_x",
headerName: "地图 X",
minWidth: 130,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "map_y",
headerName: "地图 Y",
minWidth: 130,
flex: 1,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "elevation",
headerName: "高程",
width: 92,
align: "right",
headerAlign: "right",
valueFormatter: (value) => Number(value).toFixed(3),
},
{
field: "adjustment_status",
headerName: "调整状态",
width: 108,
renderCell: ({ value }) => {
const status = value as AdjustmentStatus;
return (
<Chip
label={STATUS_LABELS[status]}
color={STATUS_COLORS[status]}
variant="outlined"
size="small"
/>
);
},
},
{
field: "actions",
headerName: "操作",
width: scheme.can_edit ? 138 : 54,
sortable: false,
filterable: false,
renderCell: ({ row }) => (
<Stack direction="row" spacing={0.25} sx={{ alignItems: "center" }}>
<Tooltip title="地图定位">
<IconButton
aria-label={`定位节点 ${row.node_id}`}
size="small"
onClick={() => locateRow(row)}
sx={{ width: 40, height: 40 }}
>
<LocateIcon fontSize="small" />
</IconButton>
</Tooltip>
{scheme.can_edit && (
<>
<Tooltip title="替换节点">
<IconButton
aria-label={`替换节点 ${row.node_id}`}
size="small"
onClick={() => activateMode("replace", row.node_id)}
sx={{ width: 40, height: 40 }}
>
<ReplaceIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="删除节点">
<span>
<IconButton
aria-label={`删除节点 ${row.node_id}`}
size="small"
onClick={() => handleDelete(row.node_id)}
disabled={rows.length <= 1}
sx={{ width: 40, height: 40 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</>
)}
</Stack>
),
},
],
[activateMode, handleDelete, locateRow, rows.length, scheme.can_edit],
);
const instruction =
mode === "add"
? "点击地图中的管网节点添加监测点"
: mode === "replace"
? replaceSourceId
? `已选择 ${replaceSourceId},请点击新的管网节点`
: "请先点击要替换的监测点"
: mode === "delete"
? "点击地图中的监测点删除"
: null;
return (
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }} noWrap>
{scheme.scheme_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{scheme.username} · {rows.length} 个监测点
{dirty ? " · 未保存草稿" : ""}
</Typography>
</Box>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleExport}
disabled={exporting}
>
{exporting ? "导出中" : "Excel"}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<MapIcon />}
onClick={() => setDrawingOpen(true)}
>
工程图
</Button>
{scheme.can_edit && (
<Button
size="small"
variant="contained"
startIcon={<SaveIcon />}
disabled={!dirty}
onClick={() => setSaveDialogOpen(true)}
>
覆盖保存
</Button>
)}
</Stack>
</Box>
{!scheme.can_edit && (
<Alert severity="info">当前为只读方案,创建人或管理员可以微调并保存。</Alert>
)}
{scheme.can_edit && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
flexWrap: "wrap",
p: 1,
bgcolor: "#f3f6f8",
borderRadius: 2,
}}
>
<Button
size="small"
variant={mode === "add" ? "contained" : "text"}
startIcon={<AddIcon />}
onClick={() => activateMode("add")}
>
添加
</Button>
<Button
size="small"
variant={mode === "replace" ? "contained" : "text"}
startIcon={<ReplaceIcon />}
onClick={() => activateMode("replace")}
>
替换
</Button>
<Button
size="small"
color="error"
variant={mode === "delete" ? "contained" : "text"}
startIcon={<DeleteIcon />}
onClick={() => activateMode("delete")}
>
删除
</Button>
<Box sx={{ flex: 1 }} />
<Tooltip title="撤销上一步">
<span>
<IconButton
aria-label="撤销上一步"
size="small"
disabled={!editor.history.length}
onClick={() => setEditor((current) => undoSchemeEdit(current))}
sx={{ width: 40, height: 40 }}
>
<UndoIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="重置为服务器版本">
<span>
<IconButton
aria-label="重置为服务器版本"
size="small"
disabled={!dirty}
onClick={() => setEditor((current) => resetSchemeEdit(current))}
sx={{ width: 40, height: 40 }}
>
<ResetIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
)}
{instruction && (
<Alert
severity="info"
action={
<Button
color="inherit"
size="small"
onClick={() => {
setMode("idle");
setReplaceSourceId(null);
}}
>
取消
</Button>
}
>
{instruction}
</Alert>
)}
<Box sx={{ flex: 1, minHeight: 320 }}>
<DataGrid
rows={rows}
columns={columns}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
getRowId={(row) => row.node_id}
rowHeight={48}
columnHeaderHeight={44}
density="compact"
disableRowSelectionOnClick={false}
rowSelectionModel={selectedNodeId ? [selectedNodeId] : []}
onRowSelectionModelChange={(selection: GridRowSelectionModel) =>
setSelectedNodeId(selection.length ? String(selection[0]) : null)
}
onRowDoubleClick={({ row }) => locateRow(row)}
slots={{ toolbar: GridToolbar }}
slotProps={{
toolbar: {
showQuickFilter: true,
printOptions: { disableToolbarButton: true },
csvOptions: { disableToolbarButton: true },
},
}}
sx={{
borderColor: "rgba(15, 23, 42, 0.10)",
"& .MuiDataGrid-columnHeaders": { bgcolor: "#edf3f7" },
"& .MuiDataGrid-cell": {
borderColor: "rgba(15, 23, 42, 0.06)",
fontVariantNumeric: "tabular-nums",
},
"& .MuiDataGrid-row.Mui-selected": {
bgcolor: "rgba(37, 125, 212, 0.10)",
},
}}
/>
</Box>
<Dialog
open={saveDialogOpen}
onClose={() => !saving && setSaveDialogOpen(false)}
aria-labelledby="overwrite-scheme-title"
>
<DialogTitle id="overwrite-scheme-title">覆盖当前方案</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ lineHeight: 1.8 }}>
保存后将覆盖原方案节点。此次调整包含:新增 {changes.added} 个,替换{" "}
{changes.replaced} 个,删除 {changes.removed} 个,保存后共 {rows.length} 个。
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setSaveDialogOpen(false)} disabled={saving}>
取消
</Button>
<Button variant="contained" onClick={handleSave} disabled={saving}>
{saving ? "保存中" : "确认覆盖"}
</Button>
</DialogActions>
</Dialog>
<SchemeDrawingDialog
open={drawingOpen}
onClose={() => setDrawingOpen(false)}
scheme={scheme}
rows={rows}
network={network}
map={map ?? null}
dirty={dirty}
/>
</Box>
);
};
export default SchemeEditor;