合并 agent-mvp 到 master #1
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "9cd5b962e9556ec227c52d0dc7d4ef4af562dcea86e16877c923c37de0f4f704"
|
||||
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2598,6 +2598,18 @@
|
||||
"title": "Map Y",
|
||||
"type": "number"
|
||||
},
|
||||
"max_pipe_diameter": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "节点关联管道的最大管径,单位:毫米",
|
||||
"title": "Max Pipe Diameter"
|
||||
},
|
||||
"node_id": {
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
@@ -2613,6 +2625,7 @@
|
||||
},
|
||||
"required": [
|
||||
"node_id",
|
||||
"max_pipe_diameter",
|
||||
"project_x",
|
||||
"project_y",
|
||||
"map_x",
|
||||
@@ -35546,6 +35559,114 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sensor-placement-candidates/{node_id}": {
|
||||
"get": {
|
||||
"operationId": "get_sensor_placement_candidates_node_id",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "node_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"maxLength": 32,
|
||||
"minLength": 1,
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Project-Id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "X-Project-Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SensorPointResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Insufficient permission"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resource conflict"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Dependency unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"OAuth2PasswordBearer": []
|
||||
}
|
||||
],
|
||||
"summary": "获取监测点候选节点详情",
|
||||
"tags": [
|
||||
"Sensor Placement"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/sensor-placement-optimization-runs": {
|
||||
"post": {
|
||||
"operationId": "post_sensor_placement_optimization_runs",
|
||||
|
||||
+46
-3
@@ -1,5 +1,6 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import MonitoringPlaceOptimizationPanel, {
|
||||
getMonitoringLayerVisibility,
|
||||
getTabIndicatorSx,
|
||||
getTabIndicatorTransform,
|
||||
} from "./MonitoringPlaceOptimizationPanel";
|
||||
@@ -7,6 +8,7 @@ import { getSensorPlacementScheme } from "./schemeApi";
|
||||
import type { SensorPlacementScheme } from "./types";
|
||||
|
||||
const mockSchemeEditorRender = jest.fn();
|
||||
const mockSchemeQueryRender = jest.fn();
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useNotification: () => ({ open: jest.fn() }),
|
||||
@@ -20,9 +22,16 @@ jest.mock("./OptimizationParameters", () => ({
|
||||
|
||||
jest.mock("./SchemeQuery", () => ({
|
||||
__esModule: true,
|
||||
default: ({ onEdit }: { onEdit: (schemeId: number) => void }) => (
|
||||
<button onClick={() => onEdit(7)}>打开测试方案</button>
|
||||
),
|
||||
default: ({
|
||||
onEdit,
|
||||
active,
|
||||
}: {
|
||||
onEdit: (schemeId: number) => void;
|
||||
active?: boolean;
|
||||
}) => {
|
||||
mockSchemeQueryRender(active);
|
||||
return <button onClick={() => onEdit(7)}>打开测试方案</button>;
|
||||
},
|
||||
createMonitoringSchemeQueryState: () => ({}),
|
||||
}));
|
||||
|
||||
@@ -66,6 +75,7 @@ const scheme: SensorPlacementScheme = {
|
||||
describe("MonitoringPlaceOptimizationPanel", () => {
|
||||
beforeEach(() => {
|
||||
mockSchemeEditorRender.mockClear();
|
||||
mockSchemeQueryRender.mockClear();
|
||||
});
|
||||
|
||||
it("applies equal-width positioning to the rendered tab indicator", () => {
|
||||
@@ -90,6 +100,21 @@ describe("MonitoringPlaceOptimizationPanel", () => {
|
||||
expect(indicator).toHaveStyle({ transform: "translateX(200%)" });
|
||||
});
|
||||
|
||||
it("keeps query and editor layers mutually exclusive by active tab", () => {
|
||||
expect(getMonitoringLayerVisibility(0)).toEqual({
|
||||
editor: false,
|
||||
query: false,
|
||||
});
|
||||
expect(getMonitoringLayerVisibility(1)).toEqual({
|
||||
editor: true,
|
||||
query: false,
|
||||
});
|
||||
expect(getMonitoringLayerVisibility(2)).toEqual({
|
||||
editor: false,
|
||||
query: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prepares a queried scheme before switching to the result editor", async () => {
|
||||
let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
|
||||
mockGetSensorPlacementScheme.mockReturnValue(
|
||||
@@ -119,5 +144,23 @@ describe("MonitoringPlaceOptimizationPanel", () => {
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
expect(mockSchemeQueryRender.mock.calls.at(-1)?.[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the active result layer visible when the panel is collapsed", async () => {
|
||||
mockGetSensorPlacementScheme.mockResolvedValue(scheme);
|
||||
render(<MonitoringPlaceOptimizationPanel />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "打开测试方案" }));
|
||||
await screen.findByTestId("scheme-editor");
|
||||
|
||||
const collapseButton = screen
|
||||
.getByTestId("ChevronRightIcon")
|
||||
.closest("button");
|
||||
expect(collapseButton).not.toBeNull();
|
||||
fireEvent.click(collapseButton!);
|
||||
|
||||
expect(mockSchemeEditorRender.mock.calls.at(-1)?.[0]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+8
-1
@@ -69,6 +69,11 @@ export const getTabIndicatorSx = (tabIndex: number) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const getMonitoringLayerVisibility = (tabIndex: number) => ({
|
||||
editor: tabIndex === 1,
|
||||
query: tabIndex === 2,
|
||||
});
|
||||
|
||||
interface PreparedSchemeEditorProps
|
||||
extends React.ComponentProps<typeof SchemeEditor> {
|
||||
onReady?: () => void;
|
||||
@@ -126,6 +131,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
||||
};
|
||||
|
||||
const drawerWidth = currentTab === 1 ? 820 : 520;
|
||||
const layerVisibility = getMonitoringLayerVisibility(currentTab);
|
||||
|
||||
const handleSchemeEditorReady = useCallback(() => {
|
||||
setCurrentTab(1);
|
||||
@@ -326,7 +332,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
||||
<PreparedSchemeEditor
|
||||
scheme={activeScheme}
|
||||
network={NETWORK_NAME}
|
||||
active={isOpen && currentTab === 1}
|
||||
active={layerVisibility.editor}
|
||||
onSaved={handleSchemeSaved}
|
||||
onReady={
|
||||
pendingOpenSchemeId === activeScheme.id
|
||||
@@ -346,6 +352,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
||||
<TabPanel value={currentTab} index={2}>
|
||||
<SchemeQuery
|
||||
schemes={schemes}
|
||||
active={layerVisibility.query}
|
||||
onSchemesChange={setSchemes}
|
||||
state={queryState}
|
||||
onStateChange={setQueryState}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import SchemeEditor from "./SchemeEditor";
|
||||
import { getSensorPlacementCandidate } from "./schemeApi";
|
||||
import type { SensorPlacementScheme } from "./types";
|
||||
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||
|
||||
const mockOpen = jest.fn();
|
||||
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
||||
let mockGridColumns: Array<{
|
||||
field: string;
|
||||
headerName?: string;
|
||||
valueFormatter?: (value: unknown) => string;
|
||||
}> = [];
|
||||
|
||||
const mockMap = {
|
||||
addLayer: jest.fn(),
|
||||
@@ -32,19 +39,23 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||
|
||||
jest.mock("@mui/x-data-grid", () => ({
|
||||
DataGrid: (props: {
|
||||
columns: typeof mockGridColumns;
|
||||
density?: string;
|
||||
initialState?: { density?: string };
|
||||
rowHeight?: number;
|
||||
columnHeaderHeight?: number;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="scheme-grid"
|
||||
data-density={props.density ?? "uncontrolled"}
|
||||
data-initial-density={props.initialState?.density ?? ""}
|
||||
data-row-height={props.rowHeight ?? "automatic"}
|
||||
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
||||
/>
|
||||
),
|
||||
}) => {
|
||||
mockGridColumns = props.columns;
|
||||
return (
|
||||
<div
|
||||
data-testid="scheme-grid"
|
||||
data-density={props.density ?? "uncontrolled"}
|
||||
data-initial-density={props.initialState?.density ?? ""}
|
||||
data-row-height={props.rowHeight ?? "automatic"}
|
||||
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
GridToolbar: () => null,
|
||||
}));
|
||||
|
||||
@@ -119,11 +130,6 @@ jest.mock("ol/style", () => ({
|
||||
Text: class {},
|
||||
}));
|
||||
|
||||
jest.mock("ol/proj", () => ({
|
||||
fromLonLat: (coordinates: number[]) => coordinates,
|
||||
toLonLat: (coordinates: number[]) => coordinates,
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/mapQueryService", () => ({
|
||||
handleMapClickSelectFeatures: jest.fn(),
|
||||
}));
|
||||
@@ -135,6 +141,7 @@ jest.mock("./SchemeDrawingDialog", () => ({
|
||||
|
||||
jest.mock("./schemeApi", () => ({
|
||||
exportSensorPlacementExcel: jest.fn(),
|
||||
getSensorPlacementCandidate: jest.fn(),
|
||||
overwriteSensorPlacementScheme: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -149,6 +156,7 @@ const scheme: SensorPlacementScheme = {
|
||||
sensor_points: [
|
||||
{
|
||||
node_id: "J1",
|
||||
max_pipe_diameter: 400,
|
||||
project_x: 10,
|
||||
project_y: 20,
|
||||
map_x: 13500010,
|
||||
@@ -161,10 +169,18 @@ const scheme: SensorPlacementScheme = {
|
||||
can_edit: true,
|
||||
};
|
||||
|
||||
const mockGetSensorPlacementCandidate = jest.mocked(
|
||||
getSensorPlacementCandidate,
|
||||
);
|
||||
const mockHandleMapClickSelectFeatures = jest.mocked(
|
||||
handleMapClickSelectFeatures,
|
||||
);
|
||||
|
||||
describe("SchemeEditor notifications", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSingleClickHandler = undefined;
|
||||
mockGridColumns = [];
|
||||
});
|
||||
|
||||
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
|
||||
@@ -187,6 +203,38 @@ describe("SchemeEditor notifications", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads authoritative point details when adding a map-selected node", async () => {
|
||||
mockHandleMapClickSelectFeatures.mockResolvedValue({
|
||||
get: (key: string) => (key === "id" ? "J2" : undefined),
|
||||
getId: () => "J2",
|
||||
} as never);
|
||||
mockGetSensorPlacementCandidate.mockResolvedValue({
|
||||
node_id: "J2",
|
||||
max_pipe_diameter: 500,
|
||||
project_x: 30,
|
||||
project_y: 40,
|
||||
map_x: 13500030,
|
||||
map_y: 3600040,
|
||||
longitude: 121.1,
|
||||
latitude: 31.1,
|
||||
elevation: 6,
|
||||
});
|
||||
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "添加" }));
|
||||
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
|
||||
await act(async () => {
|
||||
mockSingleClickHandler?.({
|
||||
pixel: [0, 0],
|
||||
coordinate: [13500030, 3600040],
|
||||
stopPropagation: jest.fn(),
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockGetSensorPlacementCandidate).toHaveBeenCalledWith("J2");
|
||||
});
|
||||
|
||||
it("lets the Data Grid density selector control row sizing", () => {
|
||||
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||
|
||||
@@ -207,4 +255,22 @@ describe("SchemeEditor notifications", () => {
|
||||
"automatic",
|
||||
);
|
||||
});
|
||||
|
||||
it("places actions and status first and displays the selected pipe diameter", () => {
|
||||
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||
|
||||
expect(mockGridColumns.slice(0, 5).map((column) => column.field)).toEqual([
|
||||
"actions",
|
||||
"adjustment_status",
|
||||
"sequence",
|
||||
"node_id",
|
||||
"max_pipe_diameter",
|
||||
]);
|
||||
const diameterColumn = mockGridColumns.find(
|
||||
(column) => column.field === "max_pipe_diameter",
|
||||
);
|
||||
expect(diameterColumn?.headerName).toBe("最大管径 (mm)");
|
||||
expect(diameterColumn?.valueFormatter?.(400)).toBe("400");
|
||||
expect(diameterColumn?.valueFormatter?.(null)).toBe("无");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,10 +45,7 @@ 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 {
|
||||
@@ -65,6 +62,7 @@ import {
|
||||
} from "./schemeEditor";
|
||||
import {
|
||||
exportSensorPlacementExcel,
|
||||
getSensorPlacementCandidate,
|
||||
overwriteSensorPlacementScheme,
|
||||
} from "./schemeApi";
|
||||
import SchemeDrawingDialog from "./SchemeDrawingDialog";
|
||||
@@ -147,6 +145,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [drawingOpen, setDrawingOpen] = useState(false);
|
||||
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const activeRef = useRef(active);
|
||||
activeRef.current = active;
|
||||
|
||||
useEffect(() => {
|
||||
setEditor(createSchemeEditorState(scheme));
|
||||
@@ -171,6 +171,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
},
|
||||
zIndex: 120,
|
||||
});
|
||||
layer.setVisible(activeRef.current);
|
||||
markerLayerRef.current = layer;
|
||||
map.addLayer(layer);
|
||||
return () => {
|
||||
@@ -206,47 +207,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
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),
|
||||
};
|
||||
return await getSensorPlacementCandidate(nodeId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -436,6 +398,70 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
|
||||
const columns = useMemo<GridColDef<SensorPointRow>[]>(
|
||||
() => [
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: "sequence",
|
||||
headerName: "序号",
|
||||
@@ -445,6 +471,21 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
sortable: false,
|
||||
},
|
||||
{ field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 },
|
||||
{
|
||||
field: "max_pipe_diameter",
|
||||
headerName: "最大管径 (mm)",
|
||||
description: "节点关联管道中的最大管径",
|
||||
minWidth: 132,
|
||||
flex: 0.75,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (value) => {
|
||||
if (value == null || !Number.isFinite(Number(value))) return "无";
|
||||
return Number(value).toLocaleString("zh-CN", {
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "longitude",
|
||||
headerName: "经度",
|
||||
@@ -507,70 +548,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -53,6 +53,7 @@ interface SchemaItem {
|
||||
|
||||
interface SchemeQueryProps {
|
||||
schemes?: SchemeRecord[];
|
||||
active?: boolean;
|
||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||
onEdit?: (id: number) => void;
|
||||
network?: string;
|
||||
@@ -77,6 +78,7 @@ export const createMonitoringSchemeQueryState =
|
||||
|
||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
schemes: externalSchemes,
|
||||
active = true,
|
||||
onSchemesChange,
|
||||
onEdit,
|
||||
network = NETWORK_NAME,
|
||||
@@ -98,6 +100,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
|
||||
const [highlightLayer, setHighlightLayer] =
|
||||
useState<VectorLayer<VectorSource> | null>(null);
|
||||
const activeRef = useRef(active);
|
||||
activeRef.current = active;
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
// 使用外部提供的 schemes 或内部状态
|
||||
const schemes =
|
||||
@@ -142,6 +146,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
queryable: false,
|
||||
},
|
||||
});
|
||||
highlightLayer.setVisible(activeRef.current);
|
||||
|
||||
map.addLayer(highlightLayer);
|
||||
setHighlightLayer(highlightLayer);
|
||||
@@ -151,6 +156,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
highlightLayer?.setVisible(active);
|
||||
}, [active, highlightLayer]);
|
||||
|
||||
// 高亮要素的函数
|
||||
useEffect(() => {
|
||||
if (!highlightLayer) {
|
||||
|
||||
@@ -107,6 +107,7 @@ const point = (
|
||||
map_y: number,
|
||||
): SensorPointRow => ({
|
||||
node_id,
|
||||
max_pipe_diameter: 300,
|
||||
sequence,
|
||||
map_x,
|
||||
map_y,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { config } from "@/config/config";
|
||||
import type {
|
||||
AdjustmentStatus,
|
||||
SensorPlacementScheme,
|
||||
SensorPoint,
|
||||
} from "./types";
|
||||
|
||||
export interface OptimizeSchemeInput {
|
||||
@@ -32,6 +33,15 @@ export const getSensorPlacementScheme = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getSensorPlacementCandidate = async (
|
||||
nodeId: string,
|
||||
): Promise<SensorPoint> => {
|
||||
const response = await api.get<SensorPoint>(
|
||||
`${config.BACKEND_URL}/api/v1/sensor-placement-candidates/${encodeURIComponent(nodeId)}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const overwriteSensorPlacementScheme = async (
|
||||
schemeId: number,
|
||||
expectedSensorLocation: string[],
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { SensorPlacementScheme, SensorPoint } from "./types";
|
||||
|
||||
const point = (node_id: string): SensorPoint => ({
|
||||
node_id,
|
||||
max_pipe_diameter: 300,
|
||||
project_x: Number(node_id.slice(1)) * 10,
|
||||
project_y: Number(node_id.slice(1)) * 20,
|
||||
map_x: 13500000 + Number(node_id.slice(1)) * 10,
|
||||
|
||||
@@ -2,6 +2,7 @@ export type AdjustmentStatus = "current" | "original" | "added" | "replaced";
|
||||
|
||||
export interface SensorPoint {
|
||||
node_id: string;
|
||||
max_pipe_diameter: number | null;
|
||||
project_x: number;
|
||||
project_y: number;
|
||||
map_x: number;
|
||||
|
||||
@@ -4833,6 +4833,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/sensor-placement-candidates/{node_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** 获取监测点候选节点详情 */
|
||||
get: operations["get_sensor_placement_candidates_node_id"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/sensor-placement-optimization-runs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8153,6 +8170,11 @@ export interface components {
|
||||
map_x: number;
|
||||
/** Map Y */
|
||||
map_y: number;
|
||||
/**
|
||||
* Max Pipe Diameter
|
||||
* @description 节点关联管道的最大管径,单位:毫米
|
||||
*/
|
||||
max_pipe_diameter: number | null;
|
||||
/** Node Id */
|
||||
node_id: string;
|
||||
/** Project X */
|
||||
@@ -31651,6 +31673,84 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_sensor_placement_candidates_node_id: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-Project-Id": string;
|
||||
};
|
||||
path: {
|
||||
node_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SensorPointResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Authentication required */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Insufficient permission */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Resource conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Validation error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Dependency unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProblemDetails"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post_sensor_placement_optimization_runs: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Reference in New Issue
Block a user