fix(api): align frontend with REST contracts
This commit is contained in:
@@ -640,7 +640,7 @@ export const SystemAdminPanel = () => {
|
||||
|
||||
const loadInitialState = async () => {
|
||||
try {
|
||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
||||
const adminResponse = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/users/me`, {
|
||||
projectHeaderMode: "omit",
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
@@ -947,7 +947,7 @@ export const SystemAdminPanel = () => {
|
||||
const form = databaseForms[role];
|
||||
const body = form.dsn.trim() ? { dsn: form.dsn.trim() } : {};
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health`,
|
||||
`${config.BACKEND_URL}/api/v1/admin/projects/${projectId.trim()}/databases/${role}/health-checks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -107,7 +107,7 @@ const defaultFilters: AuditFilters = {
|
||||
end_time: "",
|
||||
};
|
||||
|
||||
const AUDIT_LOGS_PATH = "/api/v1/audit/logs";
|
||||
const AUDIT_LOGS_PATH = "/api/v1/audit-logs";
|
||||
|
||||
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
|
||||
sessionId: "session-1",
|
||||
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||
});
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
|
||||
isTitleManuallyEdited: true,
|
||||
});
|
||||
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
|
||||
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||
title: "新标题",
|
||||
|
||||
@@ -46,7 +46,7 @@ const toMillis = (value: string | number | undefined) =>
|
||||
const normalizeTitle = (value?: string) => value?.trim() || "新对话";
|
||||
|
||||
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
@@ -72,7 +72,7 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
|
||||
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
@@ -110,7 +110,7 @@ const updateBackendChatSessionTitle = async (
|
||||
isTitleManuallyEdited?: boolean,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -131,7 +131,7 @@ const updateBackendChatSessionTitle = async (
|
||||
|
||||
const deleteBackendChatSession = async (sessionId: string) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
projectHeaderMode: "include",
|
||||
|
||||
@@ -11,8 +11,7 @@ describe("burst detection request", () => {
|
||||
const state = createBurstDetectionAnalysisParametersState();
|
||||
state.schemeName = " latest-case ";
|
||||
|
||||
expect(buildBurstDetectionRequest(state, "fengyang")).toEqual({
|
||||
network: "fengyang",
|
||||
expect(buildBurstDetectionRequest(state)).toEqual({
|
||||
scheme_name: "latest-case",
|
||||
sampling_interval_minutes: 15,
|
||||
});
|
||||
@@ -33,10 +32,8 @@ describe("burst detection request", () => {
|
||||
samplingIntervalMinutes: 30,
|
||||
samplingIntervalSource: "manual",
|
||||
},
|
||||
"fengyang",
|
||||
),
|
||||
).toEqual({
|
||||
network: "fengyang",
|
||||
scheme_name: "history-case",
|
||||
sampling_interval_minutes: 30,
|
||||
target_time: targetTime.toISOString(),
|
||||
|
||||
@@ -92,9 +92,7 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
||||
|
||||
export const buildBurstDetectionRequest = (
|
||||
parameters: BurstDetectionAnalysisParametersState,
|
||||
network: string,
|
||||
) => ({
|
||||
network,
|
||||
scheme_name: parameters.schemeName.trim(),
|
||||
sampling_interval_minutes: parameters.samplingIntervalMinutes,
|
||||
...(parameters.detectionMode === "historical" && parameters.targetTime
|
||||
@@ -129,7 +127,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
let active = true;
|
||||
setFrequencyLoading(true);
|
||||
api
|
||||
.get("/api/v1/getallscadainfo/", { params: { network: NETWORK_NAME } })
|
||||
.get("/api/v1/scada-info")
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const interval = resolvePressureSamplingInterval(
|
||||
@@ -186,8 +184,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
|
||||
try {
|
||||
const response = await api.post(
|
||||
"/api/v1/burst-detection/detect/",
|
||||
buildBurstDetectionRequest(parametersState, NETWORK_NAME),
|
||||
"/api/v1/burst-detections",
|
||||
buildBurstDetectionRequest(parametersState),
|
||||
);
|
||||
onResult(response.data as BurstDetectionResult);
|
||||
open?.({
|
||||
|
||||
@@ -131,7 +131,6 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
network: NETWORK_NAME,
|
||||
scheme_type: "burst_detection",
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
@@ -162,7 +161,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { network: NETWORK_NAME, scheme_type: "burst_detection" } },
|
||||
{ params: { scheme_type: "burst_detection" } },
|
||||
);
|
||||
const schemeRecord = response.data as BurstDetectionSchemeRecord & {
|
||||
result_payload?: BurstDetectionResult;
|
||||
|
||||
@@ -125,7 +125,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
setSchemeLoading(true);
|
||||
try {
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||
params: { network: NETWORK_NAME, scheme_type: "burst_analysis" },
|
||||
params: { scheme_type: "burst_analysis" },
|
||||
});
|
||||
const burstSchemes = (response.data as SchemeItem[]).filter(
|
||||
(scheme) => scheme.scheme_type === "burst_analysis",
|
||||
@@ -222,9 +222,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
: undefined;
|
||||
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/burst-location/locate/`,
|
||||
`${config.BACKEND_URL}/api/v1/burst-locations`,
|
||||
{
|
||||
network: NETWORK_NAME,
|
||||
data_source: dataSource,
|
||||
scheme_name: schemeName.trim() || undefined,
|
||||
burst_leakage: toM3s(burstLeakage, FLOW_DISPLAY_UNIT),
|
||||
|
||||
@@ -228,7 +228,6 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
network: NETWORK_NAME,
|
||||
scheme_type: "burst_location",
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
@@ -238,7 +237,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
const [response, simulationResponse] = await Promise.all([
|
||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, { params }),
|
||||
api.get(`${config.BACKEND_URL}/api/v1/schemes`, {
|
||||
params: { network: NETWORK_NAME, scheme_type: "burst_analysis" },
|
||||
params: { scheme_type: "burst_analysis" },
|
||||
}),
|
||||
]);
|
||||
const nextSchemes = response.data as BurstSchemeRecord[];
|
||||
@@ -281,7 +280,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{ params: { network: NETWORK_NAME, scheme_type: "burst_location" } },
|
||||
{ params: { scheme_type: "burst_location" } },
|
||||
);
|
||||
const schemeRecord = response.data as BurstSchemeRecord & {
|
||||
result_payload?: BurstLocationResult;
|
||||
|
||||
@@ -322,16 +322,15 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
: "";
|
||||
const modify_total_duration = duration;
|
||||
const params = {
|
||||
network: network,
|
||||
modify_pattern_start_time: modify_pattern_start_time,
|
||||
burst_ID: burst_ID,
|
||||
burst_id: burst_ID,
|
||||
burst_size: burst_size,
|
||||
modify_total_duration: modify_total_duration,
|
||||
scheme_name: schemeName,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.get(`${config.BACKEND_URL}/api/v1/burst-analysis`, {
|
||||
await api.post(`${config.BACKEND_URL}/api/v1/burst-analyses`, undefined, {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null, // 移除数组索引,即由 burst_ID[] 变为 burst_ID
|
||||
|
||||
@@ -29,9 +29,12 @@ import {
|
||||
PrintOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import { NETWORK_NAME } from "@config/config";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import {
|
||||
clearPipeDiameterCache,
|
||||
getCachedPipeDiameters,
|
||||
getPipeDiameterDisplay,
|
||||
getPipeDiameterQueryKey,
|
||||
loadPipeDiameters,
|
||||
type PipeDiameterMap,
|
||||
} from "./schemePipeDiameters";
|
||||
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||
@@ -54,66 +57,7 @@ const REPORT_BLUE = "#0b4f87";
|
||||
const REPORT_INK = "#172435";
|
||||
const REPORT_MUTED = "#5f6f80";
|
||||
const REPORT_LINE = "#d8e0e8";
|
||||
const diameterCache = new Map<string, PipeDiameterMap>();
|
||||
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
|
||||
|
||||
export const clearAnalysisReportDiameterCache = () => {
|
||||
diameterCache.clear();
|
||||
diameterRequestCache.clear();
|
||||
};
|
||||
|
||||
const loadPipeDiameters = async (
|
||||
pipeIds: string[],
|
||||
queryKey: string,
|
||||
): Promise<PipeDiameterMap> => {
|
||||
const cachedDiameters = diameterCache.get(queryKey);
|
||||
if (cachedDiameters) {
|
||||
return cachedDiameters;
|
||||
}
|
||||
|
||||
const cachedRequest = diameterRequestCache.get(queryKey);
|
||||
if (cachedRequest) {
|
||||
return cachedRequest;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
||||
const foundIds = new Set(
|
||||
features.map((feature) => String(feature.getProperties().id)),
|
||||
);
|
||||
const missingIds = pipeIds.filter((pipeId) => !foundIds.has(pipeId));
|
||||
if (missingIds.length) {
|
||||
features = [
|
||||
...features,
|
||||
...(await queryFeaturesByIds(missingIds, "geo_pipes")),
|
||||
];
|
||||
}
|
||||
|
||||
const diameters: PipeDiameterMap = Object.fromEntries(
|
||||
pipeIds.map((pipeId) => [pipeId, null]),
|
||||
);
|
||||
features.forEach((feature) => {
|
||||
const properties = feature.getProperties();
|
||||
const pipeId = String(properties.id);
|
||||
const diameter = Number(properties.diameter);
|
||||
if (pipeIds.includes(pipeId)) {
|
||||
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
||||
}
|
||||
});
|
||||
|
||||
diameterCache.set(queryKey, diameters);
|
||||
return diameters;
|
||||
})();
|
||||
|
||||
diameterRequestCache.set(queryKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (diameterRequestCache.get(queryKey) === request) {
|
||||
diameterRequestCache.delete(queryKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
export const clearAnalysisReportDiameterCache = clearPipeDiameterCache;
|
||||
|
||||
const formatDateTime = (value: Date | string | null | undefined) => {
|
||||
if (!value) return "未记录";
|
||||
@@ -411,8 +355,8 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({
|
||||
() => scheme?.schemeDetail?.burst_ID ?? [],
|
||||
[scheme],
|
||||
);
|
||||
const diameterQueryKey = pipeIds.join("\u0000");
|
||||
const cachedDiameters = diameterCache.get(diameterQueryKey);
|
||||
const diameterQueryKey = getPipeDiameterQueryKey(pipeIds);
|
||||
const cachedDiameters = getCachedPipeDiameters(pipeIds);
|
||||
const [diameterState, setDiameterState] = useState<{
|
||||
queryKey: string | null;
|
||||
values: PipeDiameterMap;
|
||||
@@ -435,13 +379,13 @@ const AnalysisReport: React.FC<AnalysisReportProps> = ({
|
||||
if (!pipeIds.length) {
|
||||
return;
|
||||
}
|
||||
if (diameterCache.has(diameterQueryKey)) {
|
||||
if (getCachedPipeDiameters(pipeIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
loadPipeDiameters(pipeIds, diameterQueryKey)
|
||||
loadPipeDiameters(pipeIds)
|
||||
.then((nextDiameters) => {
|
||||
if (!cancelled) {
|
||||
setDiameterState({
|
||||
|
||||
@@ -53,7 +53,9 @@ import { toLonLat } from "ol/proj";
|
||||
import Timeline from "@components/olmap/core/Controls/Timeline";
|
||||
import { SchemaItem, SchemeRecord } from "./types";
|
||||
import {
|
||||
getCachedPipeDiameters,
|
||||
getPipeDiameterDisplay,
|
||||
loadPipeDiameters,
|
||||
type PipeDiameterMap,
|
||||
} from "./schemePipeDiameters";
|
||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
@@ -160,7 +162,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
network,
|
||||
scheme_type: SCHEME_TYPE,
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
@@ -251,40 +252,23 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedDiameters = getCachedPipeDiameters(pipeIds);
|
||||
if (cachedDiameters) {
|
||||
setPipeDiametersByScheme((previous) => ({
|
||||
...previous,
|
||||
[expandedId]: cachedDiameters,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingDiameterByScheme((previous) => ({
|
||||
...previous,
|
||||
[expandedId]: true,
|
||||
}));
|
||||
|
||||
const loadPipeDiameters = async () => {
|
||||
let features = await queryFeaturesByIds(pipeIds, "geo_pipes_mat");
|
||||
const foundPipeIds = new Set(
|
||||
features.map((feature) => String(feature.getProperties().id)),
|
||||
);
|
||||
const missingPipeIds = pipeIds.filter(
|
||||
(pipeId) => !foundPipeIds.has(pipeId),
|
||||
);
|
||||
|
||||
if (missingPipeIds.length > 0) {
|
||||
const fallbackFeatures = await queryFeaturesByIds(
|
||||
missingPipeIds,
|
||||
"geo_pipes",
|
||||
);
|
||||
features = [...features, ...fallbackFeatures];
|
||||
}
|
||||
|
||||
const nextDiameters: PipeDiameterMap = Object.fromEntries(
|
||||
pipeIds.map((pipeId) => [pipeId, null]),
|
||||
);
|
||||
|
||||
features.forEach((feature) => {
|
||||
const properties = feature.getProperties();
|
||||
const pipeId = String(properties.id);
|
||||
const diameter = Number(properties.diameter);
|
||||
nextDiameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
||||
});
|
||||
|
||||
const applyPipeDiameters = async () => {
|
||||
const nextDiameters = await loadPipeDiameters(pipeIds);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
@@ -299,7 +283,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
}));
|
||||
};
|
||||
|
||||
loadPipeDiameters().catch((error) => {
|
||||
applyPipeDiameters().catch((error) => {
|
||||
console.error("查询管径失败:", error);
|
||||
if (!cancelled) {
|
||||
setLoadingDiameterByScheme((previous) => ({
|
||||
|
||||
@@ -361,14 +361,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
}
|
||||
try {
|
||||
const params: any = {
|
||||
network: NETWORK_NAME,
|
||||
accident_element: ids,
|
||||
};
|
||||
if (disabled.length > 0) {
|
||||
params.disabled_valves = disabled;
|
||||
}
|
||||
const response = await api.get<ValveIsolationResult>(
|
||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
||||
const response = await api.post<ValveIsolationResult>(
|
||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analyses`,
|
||||
undefined,
|
||||
{
|
||||
params,
|
||||
paramsSerializer: {
|
||||
|
||||
@@ -1,4 +1,48 @@
|
||||
import { getPipeDiameterDisplay } from "./schemePipeDiameters";
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import {
|
||||
clearPipeDiameterCache,
|
||||
getPipeDiameterDisplay,
|
||||
loadPipeDiameters,
|
||||
} from "./schemePipeDiameters";
|
||||
|
||||
jest.mock("@/utils/mapQueryService", () => ({
|
||||
queryFeaturesByIds: jest.fn(),
|
||||
}));
|
||||
|
||||
const feature = (id: string, diameter: number) => ({
|
||||
getProperties: () => ({ id, diameter }),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearPipeDiameterCache();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("loadPipeDiameters", () => {
|
||||
it("shares an in-flight WFS request and reuses its resolved result", async () => {
|
||||
let resolveFeatures: ((features: ReturnType<typeof feature>[]) => void) | undefined;
|
||||
const pendingFeatures = new Promise<ReturnType<typeof feature>[]>((resolve) => {
|
||||
resolveFeatures = resolve;
|
||||
});
|
||||
(queryFeaturesByIds as jest.Mock).mockImplementation(
|
||||
async () => pendingFeatures,
|
||||
);
|
||||
|
||||
const firstRequest = loadPipeDiameters(["P-1"]);
|
||||
const duplicateRequest = loadPipeDiameters(["P-1"]);
|
||||
|
||||
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFeatures?.([feature("P-1", 315)]);
|
||||
await expect(firstRequest).resolves.toEqual({ "P-1": 315 });
|
||||
await expect(duplicateRequest).resolves.toEqual({ "P-1": 315 });
|
||||
|
||||
await expect(loadPipeDiameters(["P-1"])).resolves.toEqual({
|
||||
"P-1": 315,
|
||||
});
|
||||
expect(queryFeaturesByIds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPipeDiameterDisplay", () => {
|
||||
it("shows the actual diameter for one burst pipe", () => {
|
||||
|
||||
@@ -1,5 +1,94 @@
|
||||
import { queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||
import config from "@config/config";
|
||||
|
||||
export type PipeDiameterMap = Record<string, number | null | undefined>;
|
||||
|
||||
const diameterCache = new Map<string, PipeDiameterMap>();
|
||||
const diameterRequestCache = new Map<string, Promise<PipeDiameterMap>>();
|
||||
|
||||
const normalizePipeIds = (pipeIds: string[]) =>
|
||||
[...new Set(pipeIds.map((pipeId) => String(pipeId).trim()).filter(Boolean))];
|
||||
|
||||
export const getPipeDiameterQueryKey = (pipeIds: string[]) =>
|
||||
[
|
||||
config.MAP_URL,
|
||||
config.MAP_WORKSPACE,
|
||||
...normalizePipeIds(pipeIds).sort(),
|
||||
].join("\u0000");
|
||||
|
||||
export const getCachedPipeDiameters = (pipeIds: string[]) =>
|
||||
diameterCache.get(getPipeDiameterQueryKey(pipeIds));
|
||||
|
||||
export const clearPipeDiameterCache = () => {
|
||||
diameterCache.clear();
|
||||
diameterRequestCache.clear();
|
||||
};
|
||||
|
||||
export const loadPipeDiameters = async (
|
||||
pipeIds: string[],
|
||||
): Promise<PipeDiameterMap> => {
|
||||
const normalizedPipeIds = normalizePipeIds(pipeIds);
|
||||
if (normalizedPipeIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const queryKey = getPipeDiameterQueryKey(normalizedPipeIds);
|
||||
const cachedDiameters = diameterCache.get(queryKey);
|
||||
if (cachedDiameters) {
|
||||
return cachedDiameters;
|
||||
}
|
||||
|
||||
const cachedRequest = diameterRequestCache.get(queryKey);
|
||||
if (cachedRequest) {
|
||||
return cachedRequest;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
let features = await queryFeaturesByIds(
|
||||
normalizedPipeIds,
|
||||
"geo_pipes_mat",
|
||||
);
|
||||
const foundPipeIds = new Set(
|
||||
features.map((feature) => String(feature.getProperties().id)),
|
||||
);
|
||||
const missingPipeIds = normalizedPipeIds.filter(
|
||||
(pipeId) => !foundPipeIds.has(pipeId),
|
||||
);
|
||||
|
||||
if (missingPipeIds.length > 0) {
|
||||
const fallbackFeatures = await queryFeaturesByIds(
|
||||
missingPipeIds,
|
||||
"geo_pipes",
|
||||
);
|
||||
features = [...features, ...fallbackFeatures];
|
||||
}
|
||||
|
||||
const diameters: PipeDiameterMap = Object.fromEntries(
|
||||
normalizedPipeIds.map((pipeId) => [pipeId, null]),
|
||||
);
|
||||
features.forEach((feature) => {
|
||||
const properties = feature.getProperties();
|
||||
const pipeId = String(properties.id);
|
||||
const diameter = Number(properties.diameter);
|
||||
if (pipeId in diameters) {
|
||||
diameters[pipeId] = Number.isFinite(diameter) ? diameter : null;
|
||||
}
|
||||
});
|
||||
|
||||
diameterCache.set(queryKey, diameters);
|
||||
return diameters;
|
||||
})();
|
||||
|
||||
diameterRequestCache.set(queryKey, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (diameterRequestCache.get(queryKey) === request) {
|
||||
diameterRequestCache.delete(queryKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getPipeDiameterDisplay = (
|
||||
pipeIds: string[] | undefined,
|
||||
diameters: PipeDiameterMap | undefined,
|
||||
|
||||
@@ -228,7 +228,6 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
console.log("默认设置 pattern 为 CONSTANT");
|
||||
}
|
||||
const params = {
|
||||
network,
|
||||
start_time: start_time,
|
||||
source: sourceNode,
|
||||
concentration,
|
||||
@@ -237,7 +236,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
scheme_name: schemeName,
|
||||
};
|
||||
|
||||
await api.get(`${config.BACKEND_URL}/api/v1/contaminant-simulation`, {
|
||||
await api.post(`${config.BACKEND_URL}/api/v1/contaminant-simulations`, undefined, {
|
||||
params,
|
||||
});
|
||||
|
||||
|
||||
@@ -223,7 +223,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
network,
|
||||
scheme_type: SCHEME_TYPE,
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import { useNotification } from "@refinedev/core";
|
||||
import { api } from "@/lib/api";
|
||||
import { NETWORK_NAME, config } from "@config/config";
|
||||
import { config } from "@config/config";
|
||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||
import { LeakageResultDetail } from "./types";
|
||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||
@@ -108,9 +108,8 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
});
|
||||
try {
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/leakage/identify/`,
|
||||
`${config.BACKEND_URL}/api/v1/leakage-identifications`,
|
||||
{
|
||||
network: NETWORK_NAME,
|
||||
scheme_name: schemeName.trim(),
|
||||
dma_count: dmaCount,
|
||||
scada_start: startTime.toISOString(),
|
||||
|
||||
@@ -85,7 +85,6 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
network: NETWORK_NAME,
|
||||
scheme_type: "dma_leak_identification",
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
@@ -129,7 +128,6 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
`${config.BACKEND_URL}/api/v1/schemes/${encodeURIComponent(schemeName)}`,
|
||||
{
|
||||
params: {
|
||||
network: NETWORK_NAME,
|
||||
scheme_type: "dma_leak_identification",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -307,11 +307,10 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
|
||||
const params = {
|
||||
scheme_name: schemeName,
|
||||
network: NETWORK_NAME,
|
||||
start_time: formattedTime,
|
||||
valves: valves.map(v => v.id),
|
||||
valves_k: valves.map(v => v.k),
|
||||
drainage_node_ID: drainageNode,
|
||||
drainage_node_id: drainageNode,
|
||||
flush_flow: flushFlow,
|
||||
duration: duration
|
||||
};
|
||||
@@ -320,7 +319,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
||||
// but axios usually handles array as valves[]=1&valves[]=2
|
||||
// FastAPI default expects repeated query params.
|
||||
|
||||
const response = await api.get(`${config.BACKEND_URL}/api/v1/flushing-analysis`, {
|
||||
const response = await api.post(`${config.BACKEND_URL}/api/v1/flushing-analyses`, undefined, {
|
||||
params,
|
||||
// Ensure arrays are sent as repeated keys: valves=1&valves=2
|
||||
paramsSerializer: {
|
||||
|
||||
@@ -269,7 +269,6 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
network,
|
||||
scheme_type: SCHEME_TYPE,
|
||||
};
|
||||
if (!queryAll && queryDate) {
|
||||
|
||||
@@ -392,7 +392,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
});
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/composite/pipeline-health-prediction?query_time=${query_time}&network_name=${NETWORK_NAME}`,
|
||||
`${config.BACKEND_URL}/api/v1/pipeline-health-predictions?query_time=${query_time}`,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
|
||||
+1
-1
@@ -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) => {
|
||||
|
||||
@@ -105,11 +105,11 @@ const fetchFromBackend = async (
|
||||
const start_time = dayjs(range.from).toISOString();
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
// 清洗数据接口
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 原始数据
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
try {
|
||||
// 优先查询清洗数据和模拟数据
|
||||
const [cleaningRes, simulationRes] = await Promise.all([
|
||||
@@ -351,11 +351,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const end_time = dayjs(range.to).toISOString();
|
||||
|
||||
// 清洗数据接口
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
const cleaningDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 原始数据
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/scada-readings/fields?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/scada-simulations?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
|
||||
try {
|
||||
const [cleanRes, rawRes, simRes] = await Promise.all([
|
||||
apiFetch(cleaningDataUrl)
|
||||
@@ -530,7 +530,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const response = await api.post(
|
||||
`${
|
||||
config.BACKEND_URL
|
||||
}/api/v1/composite/clean-scada?device_ids=${deviceIds.join(
|
||||
}/api/v1/timeseries/scada-cleaning-runs?device_ids=${deviceIds.join(
|
||||
",",
|
||||
)}&start_time=${startTime}&end_time=${endTime}`,
|
||||
);
|
||||
|
||||
@@ -624,7 +624,7 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
|
||||
|
||||
// 调用后端清洗接口
|
||||
const response = await api.post(
|
||||
`${config.BACKEND_URL}/api/v1/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/scada-cleaning-runs?device_ids=all&start_time=${startTime}&end_time=${endTime}`,
|
||||
);
|
||||
|
||||
// 处理成功响应
|
||||
|
||||
@@ -108,13 +108,13 @@ const fetchFromBackend = async (
|
||||
.join(",");
|
||||
|
||||
// 监测值数据接口(use_cleaned=false)
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-scada?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`;
|
||||
const rawDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=false`;
|
||||
// 清洗数据接口(use_cleaned=true)
|
||||
const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-scada?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`;
|
||||
const cleanedDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-scada-readings?element_id=${feature_ids}&start_time=${start_time}&end_time=${end_time}&use_cleaned=true`;
|
||||
// 模拟数据接口
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-simulation?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
|
||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 策略模拟数据接口
|
||||
const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/composite/element-simulation?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`;
|
||||
const schemeSimulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}&scheme_type=${scheme_type}&scheme_name=${scheme_name}`;
|
||||
|
||||
try {
|
||||
if (type === "none") {
|
||||
|
||||
@@ -241,11 +241,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
nodePromise =
|
||||
sourceType === "scheme" && schemeName
|
||||
? apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||
{ signal },
|
||||
)
|
||||
: apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/records?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
||||
{ signal },
|
||||
);
|
||||
requests.push(nodePromise);
|
||||
@@ -270,11 +270,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
linkPromise =
|
||||
sourceType === "scheme" && schemeName
|
||||
? apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/records?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||
{ signal },
|
||||
)
|
||||
: apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/records?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`,
|
||||
{ signal },
|
||||
);
|
||||
requests.push(linkPromise);
|
||||
@@ -717,13 +717,12 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
});
|
||||
try {
|
||||
const body = {
|
||||
name: NETWORK_NAME,
|
||||
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||
duration: calculatedInterval,
|
||||
};
|
||||
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/simulations/run-by-date`,
|
||||
`${config.BACKEND_URL}/api/v1/simulation-runs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -431,11 +431,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
network: networkName,
|
||||
link: selectedValveId,
|
||||
});
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/getstatus/?${params.toString()}`,
|
||||
`${config.BACKEND_URL}/api/v1/status?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -484,11 +483,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
network: networkName,
|
||||
valve: selectedValveId,
|
||||
});
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/getvalveproperties/?${params.toString()}`,
|
||||
`${config.BACKEND_URL}/api/v1/valves/properties?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -534,13 +532,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
setIsValveStatusSaving(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
network: networkName,
|
||||
link: selectedValveId,
|
||||
});
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/setstatus/?${params.toString()}`,
|
||||
`${config.BACKEND_URL}/api/v1/status-properties?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -567,7 +564,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
setIsValveStatusSaving(false);
|
||||
}
|
||||
},
|
||||
[networkName, open, selectedValveId],
|
||||
[open, selectedValveId],
|
||||
);
|
||||
|
||||
const handleValveSettingSave = useCallback(
|
||||
@@ -600,13 +597,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
setIsValveSettingSaving(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
network: networkName,
|
||||
valve: selectedValveId,
|
||||
});
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/setvalveproperties/?${params.toString()}`,
|
||||
`${config.BACKEND_URL}/api/v1/valves/properties?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -637,7 +633,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
networkName,
|
||||
open,
|
||||
selectedValveId,
|
||||
selectedValveType,
|
||||
@@ -679,12 +674,12 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
if (queryType === "scheme") {
|
||||
response = await apiFetch(
|
||||
// `${config.BACKEND_URL}/queryschemesimulationrecordsbyidtime/?scheme_name=${schemeName}&id=${id}&querytime=${querytime}&type=${type}`
|
||||
`${config.BACKEND_URL}/api/v1/scheme/query/by-id-time?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/schemes/simulation-results?scheme_type=${schemeType}&scheme_name=${schemeName}&id=${id}&type=${type}&query_time=${querytime}`,
|
||||
);
|
||||
} else {
|
||||
response = await apiFetch(
|
||||
// `${config.BACKEND_URL}/querysimulationrecordsbyidtime/?id=${id}&querytime=${querytime}&type=${type}`
|
||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-id-time?id=${id}&type=${type}&query_time=${querytime}`,
|
||||
`${config.BACKEND_URL}/api/v1/timeseries/realtime/simulation-results?id=${id}&type=${type}&query_time=${querytime}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("useTimelineTimeConfig", () => {
|
||||
});
|
||||
expect(result.current.durationMinutes).toBe(1440);
|
||||
expect(String(apiFetch.mock.calls[0][0])).toContain(
|
||||
"/api/v1/gettimeproperties/?network=test-network",
|
||||
"/api/v1/network-options/time",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -68,9 +68,7 @@ export const useTimelineTimeConfig = (): TimelineTimeConfig => {
|
||||
const fetchTimeProperties = async () => {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/gettimeproperties/?network=${encodeURIComponent(
|
||||
networkName,
|
||||
)}`,
|
||||
`${config.BACKEND_URL}/api/v1/network-options/time`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch time properties: ${response.status}`);
|
||||
|
||||
@@ -164,7 +164,7 @@ export const useToolbarChatActions = ({
|
||||
? `?session_id=${encodeURIComponent(action.sessionId)}`
|
||||
: "";
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/render-ref/${encodeURIComponent(action.renderRef)}${query}`,
|
||||
`${config.AGENT_URL}/api/v1/agent/render-references/${encodeURIComponent(action.renderRef)}${query}`,
|
||||
{
|
||||
method: "GET",
|
||||
projectHeaderMode: "include",
|
||||
|
||||
@@ -62,7 +62,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/meta/projects`,
|
||||
`${config.BACKEND_URL}/api/v1/projects`,
|
||||
{ projectHeaderMode: "omit" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user