fix(api): align frontend with REST contracts

This commit is contained in:
2026-07-30 20:38:52 +08:00
parent b57e58ff87
commit 782363cfb6
65 changed files with 99362 additions and 318 deletions
@@ -135,7 +135,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
const handleOpenScheme = async (schemeId: number) => {
setLoadingScheme(true);
try {
const scheme = await getSensorPlacementScheme(NETWORK_NAME, schemeId);
const scheme = await getSensorPlacementScheme(schemeId);
setActiveScheme(scheme);
setLoadingScheme(false);
setPendingOpenSchemeId(scheme.id);
@@ -90,7 +90,6 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
try {
const created = await optimizeSensorPlacement({
network,
scheme_name: schemeName,
sensor_type: "pressure",
method: method as "sensitivity" | "kmeans",
@@ -0,0 +1,176 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import SchemeEditor from "./SchemeEditor";
import type { SensorPlacementScheme } from "./types";
const mockOpen = jest.fn();
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
const mockMap = {
addLayer: jest.fn(),
removeLayer: jest.fn(),
on: jest.fn((eventName: string, handler: (event: unknown) => void) => {
if (eventName === "singleclick") {
mockSingleClickHandler = handler;
}
}),
un: jest.fn(),
forEachFeatureAtPixel: jest.fn(() => undefined),
getView: jest.fn(() => ({
animate: jest.fn(),
getZoom: jest.fn(() => 16),
getResolution: jest.fn(() => 1),
})),
};
jest.mock("@refinedev/core", () => ({
useNotification: () => ({ open: mockOpen }),
}));
jest.mock("@components/olmap/core/MapComponent", () => ({
useMap: () => mockMap,
}));
jest.mock("@mui/x-data-grid", () => ({
DataGrid: () => <div data-testid="scheme-grid" />,
GridToolbar: () => null,
}));
jest.mock("@mui/x-data-grid/locales", () => ({
zhCN: {
components: {
MuiDataGrid: {
defaultProps: {
localeText: {},
},
},
},
},
}));
jest.mock("ol/Feature", () => ({
__esModule: true,
default: class {
private values: Record<string, unknown>;
constructor(values: Record<string, unknown>) {
this.values = values;
}
get(key: string) {
return this.values[key];
}
setId() {}
},
}));
jest.mock("ol/geom/Point", () => ({
__esModule: true,
default: class {
constructor(_coordinates: number[]) {}
},
}));
jest.mock("ol/source/Vector", () => ({
__esModule: true,
default: class {
clear() {}
addFeature() {}
},
}));
jest.mock("ol/layer/Vector", () => ({
__esModule: true,
default: class {
private source: { clear: () => void; addFeature: () => void };
constructor(options: {
source: { clear: () => void; addFeature: () => void };
}) {
this.source = options.source;
}
getSource() {
return this.source;
}
setVisible() {}
},
}));
jest.mock("ol/style", () => ({
Circle: class {},
Fill: class {},
Stroke: class {},
Style: class {},
Text: class {},
}));
jest.mock("ol/proj", () => ({
fromLonLat: (coordinates: number[]) => coordinates,
toLonLat: (coordinates: number[]) => coordinates,
}));
jest.mock("@/utils/mapQueryService", () => ({
handleMapClickSelectFeatures: jest.fn(),
}));
jest.mock("./SchemeDrawingDialog", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("./schemeApi", () => ({
exportSensorPlacementExcel: jest.fn(),
overwriteSensorPlacementScheme: jest.fn(),
}));
const scheme: SensorPlacementScheme = {
id: 1,
scheme_name: "测试方案",
sensor_number: 1,
min_diameter: 300,
username: "operator",
create_time: "2026-07-30T08:00:00+08:00",
sensor_location: ["J1"],
sensor_points: [
{
node_id: "J1",
project_x: 10,
project_y: 20,
map_x: 13500010,
map_y: 3600020,
longitude: 121,
latitude: 31,
elevation: 5,
},
],
can_edit: true,
};
describe("SchemeEditor notifications", () => {
beforeEach(() => {
jest.clearAllMocks();
mockSingleClickHandler = undefined;
});
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
render(<SchemeEditor scheme={scheme} network="fengyang" />);
fireEvent.click(screen.getByRole("button", { name: "替换" }));
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
act(() => {
mockSingleClickHandler?.({
pixel: [0, 0],
coordinate: [13500000, 3600000],
stopPropagation: jest.fn(),
});
});
expect(mockOpen).toHaveBeenCalledWith({
type: "error",
message: "请先点击要替换的监测点",
});
});
});
@@ -28,7 +28,8 @@ import {
EditLocationAlt as ReplaceIcon,
LocationOn as LocateIcon,
Map as MapIcon,
Redo as ResetIcon,
Redo as RedoIcon,
RestartAlt as ResetIcon,
Save as SaveIcon,
Undo as UndoIcon,
} from "@mui/icons-material";
@@ -55,6 +56,7 @@ import {
createSchemeEditorState,
deleteSensorPoint,
isSchemeDirty,
redoSchemeEdit,
replaceSensorPoint,
resetSchemeEdit,
summarizeChanges,
@@ -230,8 +232,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
x: number;
y: number;
elevation: number;
}>(`${config.BACKEND_URL}/api/v1/getjunctionproperties/`, {
params: { network, junction: nodeId },
}>(`${config.BACKEND_URL}/api/v1/junctions/properties`, {
params: { junction: nodeId },
});
if (!response.data?.id) return null;
const [longitude, latitude] = toLonLat([mapX, mapY]);
@@ -249,7 +251,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
return null;
}
},
[map, network],
[map],
);
useEffect(() => {
@@ -275,7 +277,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
if (mode === "delete") {
if (!markerNodeId) {
open?.({ type: "progress", message: "请点击要删除的监测点" });
open?.({ type: "error", message: "请点击要删除的监测点" });
return;
}
setEditor((current) => deleteSensorPoint(current, markerNodeId));
@@ -285,7 +287,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
if (mode === "replace" && !replaceSourceId) {
if (!markerNodeId) {
open?.({ type: "progress", message: "请先点击要替换的监测点" });
open?.({ type: "error", message: "请先点击要替换的监测点" });
return;
}
setReplaceSourceId(markerNodeId);
@@ -295,7 +297,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
const candidate = await resolveJunction(event);
if (!candidate) {
open?.({ type: "progress", message: "请选择有效的管网节点" });
open?.({ type: "error", message: "请选择有效的管网节点" });
return;
}
@@ -304,7 +306,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
(point) => point.node_id === candidate.node_id,
);
if (exists) {
open?.({ type: "progress", message: "该节点已在当前方案中" });
open?.({ type: "error", message: "该节点已在当前方案中" });
return;
}
setEditor((current) => addSensorPoint(current, candidate));
@@ -316,7 +318,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
point.node_id !== replaceSourceId,
);
if (duplicate) {
open?.({ type: "progress", message: "目标节点已在当前方案中" });
open?.({ type: "error", message: "目标节点已在当前方案中" });
return;
}
setEditor((current) =>
@@ -379,7 +381,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
setSaving(true);
try {
const updated = await overwriteSensorPlacementScheme(
network,
scheme.id,
editor.baseline.map((point) => point.node_id),
editor.points.map((point) => point.node_id),
@@ -410,7 +411,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
setExporting(true);
try {
const blob = await exportSensorPlacementExcel(
network,
scheme.id,
editor.points.map((point) => point.node_id),
editor.statuses,
@@ -693,6 +693,19 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
</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
@@ -177,7 +177,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
setLoading(true);
try {
const response = await api.get(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes?network=${network}`,
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
);
let filteredResults = response.data;
@@ -6,7 +6,6 @@ import type {
} from "./types";
export interface OptimizeSchemeInput {
network: string;
scheme_name: string;
sensor_type: "pressure";
method: "sensitivity" | "kmeans";
@@ -18,25 +17,22 @@ export const optimizeSensorPlacement = async (
input: OptimizeSchemeInput,
): Promise<SensorPlacementScheme> => {
const response = await api.post<SensorPlacementScheme>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/optimize`,
`${config.BACKEND_URL}/api/v1/sensor-placement-optimization-runs`,
input,
);
return response.data;
};
export const getSensorPlacementScheme = async (
network: string,
schemeId: number,
): Promise<SensorPlacementScheme> => {
const response = await api.get<SensorPlacementScheme>(
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
{ params: { network } },
);
return response.data;
};
export const overwriteSensorPlacementScheme = async (
network: string,
schemeId: number,
expectedSensorLocation: string[],
sensorLocation: string[],
@@ -47,13 +43,11 @@ export const overwriteSensorPlacementScheme = async (
expected_sensor_location: expectedSensorLocation,
sensor_location: sensorLocation,
},
{ params: { network } },
);
return response.data;
};
export const exportSensorPlacementExcel = async (
network: string,
schemeId: number,
sensorLocation: string[],
adjustmentStatus: Record<string, AdjustmentStatus>,
@@ -65,7 +59,6 @@ export const exportSensorPlacementExcel = async (
adjustment_status: adjustmentStatus,
},
{
params: { network },
responseType: "blob",
},
);
@@ -3,6 +3,7 @@ import {
createSchemeEditorState,
deleteSensorPoint,
isSchemeDirty,
redoSchemeEdit,
replaceSensorPoint,
resetSchemeEdit,
summarizeChanges,
@@ -44,6 +45,24 @@ describe("scheme editor", () => {
expect(undoSchemeEdit(added).points).toEqual(initial.points);
});
it("can redo an undone edit and clears redo history after a new edit", () => {
const initial = createSchemeEditorState(scheme);
const added = addSensorPoint(initial, point("J3"));
const undone = undoSchemeEdit(added);
const redone = redoSchemeEdit(undone);
expect(redone.points.map((item) => item.node_id)).toEqual([
"J1",
"J2",
"J3",
]);
expect(redone.history).toHaveLength(1);
expect(redone.future).toEqual([]);
const editedAfterUndo = replaceSensorPoint(undone, "J1", point("J4"));
expect(editedAfterUndo.future).toEqual([]);
});
it("replaces a node without changing row order", () => {
const initial = createSchemeEditorState(scheme);
const replaced = replaceSensorPoint(initial, "J1", point("J3"));
@@ -80,5 +99,6 @@ describe("scheme editor", () => {
expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]);
expect(isSchemeDirty(reset)).toBe(false);
expect(reset.history).toEqual([]);
expect(reset.future).toEqual([]);
});
});
@@ -13,6 +13,7 @@ export interface SchemeEditorSnapshot {
export interface SchemeEditorState extends SchemeEditorSnapshot {
baseline: SensorPoint[];
history: SchemeEditorSnapshot[];
future: SchemeEditorSnapshot[];
}
const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point }));
@@ -30,6 +31,7 @@ export const createSchemeEditorState = (
points: clonePoints(scheme.sensor_points),
statuses: currentStatuses(scheme.sensor_points),
history: [],
future: [],
});
const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({
@@ -46,6 +48,7 @@ const withHistory = (
points,
statuses,
history: [...state.history, snapshot(state)],
future: [],
});
export const addSensorPoint = (
@@ -114,6 +117,19 @@ export const undoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
points: clonePoints(previous.points),
statuses: { ...previous.statuses },
history: state.history.slice(0, -1),
future: [...state.future, snapshot(state)],
};
};
export const redoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
const next = state.future[state.future.length - 1];
if (!next) return state;
return {
...state,
points: clonePoints(next.points),
statuses: { ...next.statuses },
history: [...state.history, snapshot(state)],
future: state.future.slice(0, -1),
};
};
@@ -122,6 +138,7 @@ export const resetSchemeEdit = (state: SchemeEditorState): SchemeEditorState =>
points: clonePoints(state.baseline),
statuses: currentStatuses(state.baseline),
history: [],
future: [],
});
export const isSchemeDirty = (state: SchemeEditorState) => {