合并 agent-mvp 到 master #1
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "9cd5b962e9556ec227c52d0dc7d4ef4af562dcea86e16877c923c37de0f4f704"
|
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2598,6 +2598,18 @@
|
|||||||
"title": "Map Y",
|
"title": "Map Y",
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"max_pipe_diameter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "节点关联管道的最大管径,单位:毫米",
|
||||||
|
"title": "Max Pipe Diameter"
|
||||||
|
},
|
||||||
"node_id": {
|
"node_id": {
|
||||||
"title": "Node Id",
|
"title": "Node Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2613,6 +2625,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"node_id",
|
"node_id",
|
||||||
|
"max_pipe_diameter",
|
||||||
"project_x",
|
"project_x",
|
||||||
"project_y",
|
"project_y",
|
||||||
"map_x",
|
"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": {
|
"/api/v1/sensor-placement-optimization-runs": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_sensor_placement_optimization_runs",
|
"operationId": "post_sensor_placement_optimization_runs",
|
||||||
|
|||||||
+46
-3
@@ -1,5 +1,6 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import MonitoringPlaceOptimizationPanel, {
|
import MonitoringPlaceOptimizationPanel, {
|
||||||
|
getMonitoringLayerVisibility,
|
||||||
getTabIndicatorSx,
|
getTabIndicatorSx,
|
||||||
getTabIndicatorTransform,
|
getTabIndicatorTransform,
|
||||||
} from "./MonitoringPlaceOptimizationPanel";
|
} from "./MonitoringPlaceOptimizationPanel";
|
||||||
@@ -7,6 +8,7 @@ import { getSensorPlacementScheme } from "./schemeApi";
|
|||||||
import type { SensorPlacementScheme } from "./types";
|
import type { SensorPlacementScheme } from "./types";
|
||||||
|
|
||||||
const mockSchemeEditorRender = jest.fn();
|
const mockSchemeEditorRender = jest.fn();
|
||||||
|
const mockSchemeQueryRender = jest.fn();
|
||||||
|
|
||||||
jest.mock("@refinedev/core", () => ({
|
jest.mock("@refinedev/core", () => ({
|
||||||
useNotification: () => ({ open: jest.fn() }),
|
useNotification: () => ({ open: jest.fn() }),
|
||||||
@@ -20,9 +22,16 @@ jest.mock("./OptimizationParameters", () => ({
|
|||||||
|
|
||||||
jest.mock("./SchemeQuery", () => ({
|
jest.mock("./SchemeQuery", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: ({ onEdit }: { onEdit: (schemeId: number) => void }) => (
|
default: ({
|
||||||
<button onClick={() => onEdit(7)}>打开测试方案</button>
|
onEdit,
|
||||||
),
|
active,
|
||||||
|
}: {
|
||||||
|
onEdit: (schemeId: number) => void;
|
||||||
|
active?: boolean;
|
||||||
|
}) => {
|
||||||
|
mockSchemeQueryRender(active);
|
||||||
|
return <button onClick={() => onEdit(7)}>打开测试方案</button>;
|
||||||
|
},
|
||||||
createMonitoringSchemeQueryState: () => ({}),
|
createMonitoringSchemeQueryState: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -66,6 +75,7 @@ const scheme: SensorPlacementScheme = {
|
|||||||
describe("MonitoringPlaceOptimizationPanel", () => {
|
describe("MonitoringPlaceOptimizationPanel", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSchemeEditorRender.mockClear();
|
mockSchemeEditorRender.mockClear();
|
||||||
|
mockSchemeQueryRender.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies equal-width positioning to the rendered tab indicator", () => {
|
it("applies equal-width positioning to the rendered tab indicator", () => {
|
||||||
@@ -90,6 +100,21 @@ describe("MonitoringPlaceOptimizationPanel", () => {
|
|||||||
expect(indicator).toHaveStyle({ transform: "translateX(200%)" });
|
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 () => {
|
it("prepares a queried scheme before switching to the result editor", async () => {
|
||||||
let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
|
let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
|
||||||
mockGetSensorPlacementScheme.mockReturnValue(
|
mockGetSensorPlacementScheme.mockReturnValue(
|
||||||
@@ -119,5 +144,23 @@ describe("MonitoringPlaceOptimizationPanel", () => {
|
|||||||
false,
|
false,
|
||||||
true,
|
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
|
interface PreparedSchemeEditorProps
|
||||||
extends React.ComponentProps<typeof SchemeEditor> {
|
extends React.ComponentProps<typeof SchemeEditor> {
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
@@ -126,6 +131,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const drawerWidth = currentTab === 1 ? 820 : 520;
|
const drawerWidth = currentTab === 1 ? 820 : 520;
|
||||||
|
const layerVisibility = getMonitoringLayerVisibility(currentTab);
|
||||||
|
|
||||||
const handleSchemeEditorReady = useCallback(() => {
|
const handleSchemeEditorReady = useCallback(() => {
|
||||||
setCurrentTab(1);
|
setCurrentTab(1);
|
||||||
@@ -326,7 +332,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
<PreparedSchemeEditor
|
<PreparedSchemeEditor
|
||||||
scheme={activeScheme}
|
scheme={activeScheme}
|
||||||
network={NETWORK_NAME}
|
network={NETWORK_NAME}
|
||||||
active={isOpen && currentTab === 1}
|
active={layerVisibility.editor}
|
||||||
onSaved={handleSchemeSaved}
|
onSaved={handleSchemeSaved}
|
||||||
onReady={
|
onReady={
|
||||||
pendingOpenSchemeId === activeScheme.id
|
pendingOpenSchemeId === activeScheme.id
|
||||||
@@ -346,6 +352,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
<TabPanel value={currentTab} index={2}>
|
<TabPanel value={currentTab} index={2}>
|
||||||
<SchemeQuery
|
<SchemeQuery
|
||||||
schemes={schemes}
|
schemes={schemes}
|
||||||
|
active={layerVisibility.query}
|
||||||
onSchemesChange={setSchemes}
|
onSchemesChange={setSchemes}
|
||||||
state={queryState}
|
state={queryState}
|
||||||
onStateChange={setQueryState}
|
onStateChange={setQueryState}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import SchemeEditor from "./SchemeEditor";
|
import SchemeEditor from "./SchemeEditor";
|
||||||
|
import { getSensorPlacementCandidate } from "./schemeApi";
|
||||||
import type { SensorPlacementScheme } from "./types";
|
import type { SensorPlacementScheme } from "./types";
|
||||||
|
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||||
|
|
||||||
const mockOpen = jest.fn();
|
const mockOpen = jest.fn();
|
||||||
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
||||||
|
let mockGridColumns: Array<{
|
||||||
|
field: string;
|
||||||
|
headerName?: string;
|
||||||
|
valueFormatter?: (value: unknown) => string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
const mockMap = {
|
const mockMap = {
|
||||||
addLayer: jest.fn(),
|
addLayer: jest.fn(),
|
||||||
@@ -32,19 +39,23 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
|
|||||||
|
|
||||||
jest.mock("@mui/x-data-grid", () => ({
|
jest.mock("@mui/x-data-grid", () => ({
|
||||||
DataGrid: (props: {
|
DataGrid: (props: {
|
||||||
|
columns: typeof mockGridColumns;
|
||||||
density?: string;
|
density?: string;
|
||||||
initialState?: { density?: string };
|
initialState?: { density?: string };
|
||||||
rowHeight?: number;
|
rowHeight?: number;
|
||||||
columnHeaderHeight?: number;
|
columnHeaderHeight?: number;
|
||||||
}) => (
|
}) => {
|
||||||
<div
|
mockGridColumns = props.columns;
|
||||||
data-testid="scheme-grid"
|
return (
|
||||||
data-density={props.density ?? "uncontrolled"}
|
<div
|
||||||
data-initial-density={props.initialState?.density ?? ""}
|
data-testid="scheme-grid"
|
||||||
data-row-height={props.rowHeight ?? "automatic"}
|
data-density={props.density ?? "uncontrolled"}
|
||||||
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
data-initial-density={props.initialState?.density ?? ""}
|
||||||
/>
|
data-row-height={props.rowHeight ?? "automatic"}
|
||||||
),
|
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
GridToolbar: () => null,
|
GridToolbar: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -119,11 +130,6 @@ jest.mock("ol/style", () => ({
|
|||||||
Text: class {},
|
Text: class {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("ol/proj", () => ({
|
|
||||||
fromLonLat: (coordinates: number[]) => coordinates,
|
|
||||||
toLonLat: (coordinates: number[]) => coordinates,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("@/utils/mapQueryService", () => ({
|
jest.mock("@/utils/mapQueryService", () => ({
|
||||||
handleMapClickSelectFeatures: jest.fn(),
|
handleMapClickSelectFeatures: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -135,6 +141,7 @@ jest.mock("./SchemeDrawingDialog", () => ({
|
|||||||
|
|
||||||
jest.mock("./schemeApi", () => ({
|
jest.mock("./schemeApi", () => ({
|
||||||
exportSensorPlacementExcel: jest.fn(),
|
exportSensorPlacementExcel: jest.fn(),
|
||||||
|
getSensorPlacementCandidate: jest.fn(),
|
||||||
overwriteSensorPlacementScheme: jest.fn(),
|
overwriteSensorPlacementScheme: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -149,6 +156,7 @@ const scheme: SensorPlacementScheme = {
|
|||||||
sensor_points: [
|
sensor_points: [
|
||||||
{
|
{
|
||||||
node_id: "J1",
|
node_id: "J1",
|
||||||
|
max_pipe_diameter: 400,
|
||||||
project_x: 10,
|
project_x: 10,
|
||||||
project_y: 20,
|
project_y: 20,
|
||||||
map_x: 13500010,
|
map_x: 13500010,
|
||||||
@@ -161,10 +169,18 @@ const scheme: SensorPlacementScheme = {
|
|||||||
can_edit: true,
|
can_edit: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockGetSensorPlacementCandidate = jest.mocked(
|
||||||
|
getSensorPlacementCandidate,
|
||||||
|
);
|
||||||
|
const mockHandleMapClickSelectFeatures = jest.mocked(
|
||||||
|
handleMapClickSelectFeatures,
|
||||||
|
);
|
||||||
|
|
||||||
describe("SchemeEditor notifications", () => {
|
describe("SchemeEditor notifications", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
mockSingleClickHandler = undefined;
|
mockSingleClickHandler = undefined;
|
||||||
|
mockGridColumns = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
|
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", () => {
|
it("lets the Data Grid density selector control row sizing", () => {
|
||||||
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||||
|
|
||||||
@@ -207,4 +255,22 @@ describe("SchemeEditor notifications", () => {
|
|||||||
"automatic",
|
"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 VectorLayer from "ol/layer/Vector";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import { Circle, Fill, Stroke, Style, Text } from "ol/style";
|
import { Circle, Fill, Stroke, Style, Text } from "ol/style";
|
||||||
import { fromLonLat, toLonLat } from "ol/proj";
|
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { config } from "@/config/config";
|
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +62,7 @@ import {
|
|||||||
} from "./schemeEditor";
|
} from "./schemeEditor";
|
||||||
import {
|
import {
|
||||||
exportSensorPlacementExcel,
|
exportSensorPlacementExcel,
|
||||||
|
getSensorPlacementCandidate,
|
||||||
overwriteSensorPlacementScheme,
|
overwriteSensorPlacementScheme,
|
||||||
} from "./schemeApi";
|
} from "./schemeApi";
|
||||||
import SchemeDrawingDialog from "./SchemeDrawingDialog";
|
import SchemeDrawingDialog from "./SchemeDrawingDialog";
|
||||||
@@ -147,6 +145,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
const [drawingOpen, setDrawingOpen] = useState(false);
|
const [drawingOpen, setDrawingOpen] = useState(false);
|
||||||
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
activeRef.current = active;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditor(createSchemeEditorState(scheme));
|
setEditor(createSchemeEditorState(scheme));
|
||||||
@@ -171,6 +171,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
},
|
},
|
||||||
zIndex: 120,
|
zIndex: 120,
|
||||||
});
|
});
|
||||||
|
layer.setVisible(activeRef.current);
|
||||||
markerLayerRef.current = layer;
|
markerLayerRef.current = layer;
|
||||||
map.addLayer(layer);
|
map.addLayer(layer);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -206,47 +207,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
const feature = await handleMapClickSelectFeatures(event, map);
|
const feature = await handleMapClickSelectFeatures(event, map);
|
||||||
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
|
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
|
||||||
if (!nodeId) return null;
|
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 {
|
try {
|
||||||
const response = await api.get<{
|
return await getSensorPlacementCandidate(nodeId);
|
||||||
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -436,6 +398,70 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
|
|
||||||
const columns = useMemo<GridColDef<SensorPointRow>[]>(
|
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",
|
field: "sequence",
|
||||||
headerName: "序号",
|
headerName: "序号",
|
||||||
@@ -445,6 +471,21 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
},
|
},
|
||||||
{ field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 },
|
{ 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",
|
field: "longitude",
|
||||||
headerName: "经度",
|
headerName: "经度",
|
||||||
@@ -507,70 +548,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
headerAlign: "right",
|
headerAlign: "right",
|
||||||
valueFormatter: (value) => Number(value).toFixed(3),
|
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],
|
[activateMode, handleDelete, locateRow, rows.length, scheme.can_edit],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -53,6 +53,7 @@ interface SchemaItem {
|
|||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: SchemeRecord[];
|
schemes?: SchemeRecord[];
|
||||||
|
active?: boolean;
|
||||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||||
onEdit?: (id: number) => void;
|
onEdit?: (id: number) => void;
|
||||||
network?: string;
|
network?: string;
|
||||||
@@ -77,6 +78,7 @@ export const createMonitoringSchemeQueryState =
|
|||||||
|
|
||||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||||
schemes: externalSchemes,
|
schemes: externalSchemes,
|
||||||
|
active = true,
|
||||||
onSchemesChange,
|
onSchemesChange,
|
||||||
onEdit,
|
onEdit,
|
||||||
network = NETWORK_NAME,
|
network = NETWORK_NAME,
|
||||||
@@ -98,6 +100,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
const [highlightLayer, setHighlightLayer] =
|
const [highlightLayer, setHighlightLayer] =
|
||||||
useState<VectorLayer<VectorSource> | null>(null);
|
useState<VectorLayer<VectorSource> | null>(null);
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
activeRef.current = active;
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
// 使用外部提供的 schemes 或内部状态
|
// 使用外部提供的 schemes 或内部状态
|
||||||
const schemes =
|
const schemes =
|
||||||
@@ -142,6 +146,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
queryable: false,
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
highlightLayer.setVisible(activeRef.current);
|
||||||
|
|
||||||
map.addLayer(highlightLayer);
|
map.addLayer(highlightLayer);
|
||||||
setHighlightLayer(highlightLayer);
|
setHighlightLayer(highlightLayer);
|
||||||
@@ -151,6 +156,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
};
|
};
|
||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
highlightLayer?.setVisible(active);
|
||||||
|
}, [active, highlightLayer]);
|
||||||
|
|
||||||
// 高亮要素的函数
|
// 高亮要素的函数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightLayer) {
|
if (!highlightLayer) {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const point = (
|
|||||||
map_y: number,
|
map_y: number,
|
||||||
): SensorPointRow => ({
|
): SensorPointRow => ({
|
||||||
node_id,
|
node_id,
|
||||||
|
max_pipe_diameter: 300,
|
||||||
sequence,
|
sequence,
|
||||||
map_x,
|
map_x,
|
||||||
map_y,
|
map_y,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { config } from "@/config/config";
|
|||||||
import type {
|
import type {
|
||||||
AdjustmentStatus,
|
AdjustmentStatus,
|
||||||
SensorPlacementScheme,
|
SensorPlacementScheme,
|
||||||
|
SensorPoint,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface OptimizeSchemeInput {
|
export interface OptimizeSchemeInput {
|
||||||
@@ -32,6 +33,15 @@ export const getSensorPlacementScheme = async (
|
|||||||
return response.data;
|
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 (
|
export const overwriteSensorPlacementScheme = async (
|
||||||
schemeId: number,
|
schemeId: number,
|
||||||
expectedSensorLocation: string[],
|
expectedSensorLocation: string[],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { SensorPlacementScheme, SensorPoint } from "./types";
|
|||||||
|
|
||||||
const point = (node_id: string): SensorPoint => ({
|
const point = (node_id: string): SensorPoint => ({
|
||||||
node_id,
|
node_id,
|
||||||
|
max_pipe_diameter: 300,
|
||||||
project_x: Number(node_id.slice(1)) * 10,
|
project_x: Number(node_id.slice(1)) * 10,
|
||||||
project_y: Number(node_id.slice(1)) * 20,
|
project_y: Number(node_id.slice(1)) * 20,
|
||||||
map_x: 13500000 + Number(node_id.slice(1)) * 10,
|
map_x: 13500000 + Number(node_id.slice(1)) * 10,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AdjustmentStatus = "current" | "original" | "added" | "replaced";
|
|||||||
|
|
||||||
export interface SensorPoint {
|
export interface SensorPoint {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
|
max_pipe_diameter: number | null;
|
||||||
project_x: number;
|
project_x: number;
|
||||||
project_y: number;
|
project_y: number;
|
||||||
map_x: number;
|
map_x: number;
|
||||||
|
|||||||
@@ -4833,6 +4833,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/sensor-placement-optimization-runs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8153,6 +8170,11 @@ export interface components {
|
|||||||
map_x: number;
|
map_x: number;
|
||||||
/** Map Y */
|
/** Map Y */
|
||||||
map_y: number;
|
map_y: number;
|
||||||
|
/**
|
||||||
|
* Max Pipe Diameter
|
||||||
|
* @description 节点关联管道的最大管径,单位:毫米
|
||||||
|
*/
|
||||||
|
max_pipe_diameter: number | null;
|
||||||
/** Node Id */
|
/** Node Id */
|
||||||
node_id: string;
|
node_id: string;
|
||||||
/** Project X */
|
/** 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: {
|
post_sensor_placement_optimization_runs: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Reference in New Issue
Block a user