feat(sensor-placement): add scheme engineering editor
This commit is contained in:
@@ -0,0 +1,804 @@
|
||||
"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;
|
||||
Reference in New Issue
Block a user