816 lines
24 KiB
TypeScript
816 lines
24 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 RedoIcon,
|
|
RestartAlt 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,
|
|
redoSchemeEdit,
|
|
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/junctions/properties`, {
|
|
params: { 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],
|
|
);
|
|
|
|
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: "error", message: "请点击要删除的监测点" });
|
|
return;
|
|
}
|
|
setEditor((current) => deleteSensorPoint(current, markerNodeId));
|
|
setSelectedNodeId(null);
|
|
return;
|
|
}
|
|
|
|
if (mode === "replace" && !replaceSourceId) {
|
|
if (!markerNodeId) {
|
|
open?.({ type: "error", message: "请先点击要替换的监测点" });
|
|
return;
|
|
}
|
|
setReplaceSourceId(markerNodeId);
|
|
setSelectedNodeId(markerNodeId);
|
|
return;
|
|
}
|
|
|
|
const candidate = await resolveJunction(event);
|
|
if (!candidate) {
|
|
open?.({ type: "error", message: "请选择有效的管网节点" });
|
|
return;
|
|
}
|
|
|
|
if (mode === "add") {
|
|
const exists = editor.points.some(
|
|
(point) => point.node_id === candidate.node_id,
|
|
);
|
|
if (exists) {
|
|
open?.({ type: "error", 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: "error", 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(
|
|
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(
|
|
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={!editor.future.length}
|
|
onClick={() => setEditor((current) => redoSchemeEdit(current))}
|
|
sx={{ width: 40, height: 40 }}
|
|
>
|
|
<RedoIcon 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}
|
|
initialState={{ 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;
|