"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 = { 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 = ({ scheme, network, active = true, onSaved, }) => { const map = useMap(); const { open } = useNotification(); const [editor, setEditor] = useState(() => createSchemeEditorState(scheme)); const [mode, setMode] = useState("idle"); const [selectedNodeId, setSelectedNodeId] = useState(null); const [replaceSourceId, setReplaceSourceId] = useState(null); const [saving, setSaving] = useState(false); const [exporting, setExporting] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [drawingOpen, setDrawingOpen] = useState(false); const markerLayerRef = useRef | 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 => { 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[]>( () => [ { 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 ( ); }, }, { field: "actions", headerName: "操作", width: scheme.can_edit ? 138 : 54, sortable: false, filterable: false, renderCell: ({ row }) => ( locateRow(row)} sx={{ width: 40, height: 40 }} > {scheme.can_edit && ( <> activateMode("replace", row.node_id)} sx={{ width: 40, height: 40 }} > handleDelete(row.node_id)} disabled={rows.length <= 1} sx={{ width: 40, height: 40 }} > )} ), }, ], [activateMode, handleDelete, locateRow, rows.length, scheme.can_edit], ); const instruction = mode === "add" ? "点击地图中的管网节点添加监测点" : mode === "replace" ? replaceSourceId ? `已选择 ${replaceSourceId},请点击新的管网节点` : "请先点击要替换的监测点" : mode === "delete" ? "点击地图中的监测点删除" : null; return ( {scheme.scheme_name} {scheme.username} · {rows.length} 个监测点 {dirty ? " · 未保存草稿" : ""} {scheme.can_edit && ( )} {!scheme.can_edit && ( 当前为只读方案,创建人或管理员可以微调并保存。 )} {scheme.can_edit && ( setEditor((current) => undoSchemeEdit(current))} sx={{ width: 40, height: 40 }} > setEditor((current) => resetSchemeEdit(current))} sx={{ width: 40, height: 40 }} > )} {instruction && ( { setMode("idle"); setReplaceSourceId(null); }} > 取消 } > {instruction} )} 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)", }, }} /> !saving && setSaveDialogOpen(false)} aria-labelledby="overwrite-scheme-title" > 覆盖当前方案 保存后将覆盖原方案节点。此次调整包含:新增 {changes.added} 个,替换{" "} {changes.replaced} 个,删除 {changes.removed} 个,保存后共 {rows.length} 个。 setDrawingOpen(false)} scheme={scheme} rows={rows} network={network} map={map ?? null} dirty={dirty} /> ); }; export default SchemeEditor;