fix(api): align frontend with project-scoped backend
Use project-scoped endpoints and run IDs for current project, SCADA metadata, historical time series, migrated DMA results, and sensor placement runs. The regressions recurred because tests mocked pre-interceptor and new-only payload shapes, while history queries relied on implicit global scheme state and unbounded request fan-out. Cover post-interceptor pages, migrated result_rows, explicit run_id routing, multi-element requests, request limits, and cancellation.
This commit is contained in:
@@ -22,6 +22,11 @@ npm run start
|
|||||||
|
|
||||||
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
|
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
|
||||||
|
|
||||||
|
When the Server API changes, sync `contracts/server-v1.openapi.json` from the
|
||||||
|
backend source of truth, update its SHA-256 in `contracts/manifest.json`, then
|
||||||
|
run `npm run api:generate` and `npm run api:check` so the generated types and
|
||||||
|
contract mirror remain aligned.
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "404a196c0177faed2aa5b46ee86430a034dfe990e0a77a43428a727748a882b6"
|
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+682
-1741
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ import {
|
|||||||
describeApplyLayerStyle,
|
describeApplyLayerStyle,
|
||||||
parseApplyLayerStylePayload,
|
parseApplyLayerStylePayload,
|
||||||
} from "./toolCallStyleHelpers";
|
} from "./toolCallStyleHelpers";
|
||||||
|
import { buildViewHistoryAction } from "./historyToolAction";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Interactive card rendered inside a chat bubble for tool actions */
|
/* Interactive card rendered inside a chat bubble for tool actions */
|
||||||
@@ -428,17 +429,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "view_history": {
|
case "view_history": {
|
||||||
const historyRange = resolveTimeRange();
|
return buildViewHistoryAction(params);
|
||||||
return {
|
|
||||||
type: "view_history",
|
|
||||||
featureInfos:
|
|
||||||
(params.feature_infos as [string, string][] | undefined) ?? [],
|
|
||||||
dataType:
|
|
||||||
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
|
|
||||||
"realtime",
|
|
||||||
startTime: historyRange.startTime,
|
|
||||||
endTime: historyRange.endTime,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case "view_scada": {
|
case "view_scada": {
|
||||||
const scadaRange = resolveTimeRange();
|
const scadaRange = resolveTimeRange();
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { buildViewHistoryAction } from "./historyToolAction";
|
||||||
|
|
||||||
|
describe("buildViewHistoryAction", () => {
|
||||||
|
it("preserves the scheme run ID from Agent tool parameters", () => {
|
||||||
|
expect(
|
||||||
|
buildViewHistoryAction({
|
||||||
|
feature_infos: [["P-1", "pipe"]],
|
||||||
|
data_type: "scheme",
|
||||||
|
run_id: " 99dd4142-368b-54cb-bfca-d59ee48f6298 ",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
featureInfos: [["P-1", "pipe"]],
|
||||||
|
dataType: "scheme",
|
||||||
|
runId: "99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes invalid history parameters", () => {
|
||||||
|
expect(
|
||||||
|
buildViewHistoryAction({
|
||||||
|
feature_infos: [["", "pipe"], "invalid"],
|
||||||
|
data_type: "unexpected",
|
||||||
|
runId: " ",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "view_history",
|
||||||
|
featureInfos: [],
|
||||||
|
dataType: "realtime",
|
||||||
|
runId: undefined,
|
||||||
|
startTime: undefined,
|
||||||
|
endTime: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ChatToolAction } from "@/store/chatToolStore";
|
||||||
|
|
||||||
|
type ViewHistoryAction = Extract<ChatToolAction, { type: "view_history" }>;
|
||||||
|
|
||||||
|
const readOptionalString = (value: unknown) =>
|
||||||
|
typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
|
||||||
|
export const buildViewHistoryAction = (
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): ViewHistoryAction => {
|
||||||
|
const rawFeatureInfos = Array.isArray(params.feature_infos)
|
||||||
|
? params.feature_infos
|
||||||
|
: [];
|
||||||
|
const featureInfos = rawFeatureInfos
|
||||||
|
.filter(
|
||||||
|
(item): item is [unknown, unknown] =>
|
||||||
|
Array.isArray(item) && item.length >= 2,
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
([id, type]) => [String(id).trim(), String(type).trim()] as [string, string],
|
||||||
|
)
|
||||||
|
.filter(([id, type]) => id.length > 0 && type.length > 0);
|
||||||
|
const rawDataType = params.data_type;
|
||||||
|
const dataType =
|
||||||
|
rawDataType === "realtime" ||
|
||||||
|
rawDataType === "scheme" ||
|
||||||
|
rawDataType === "none"
|
||||||
|
? rawDataType
|
||||||
|
: "realtime";
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "view_history",
|
||||||
|
featureInfos,
|
||||||
|
dataType,
|
||||||
|
runId: readOptionalString(params.run_id ?? params.runId),
|
||||||
|
startTime: readOptionalString(
|
||||||
|
params.start_time ?? params.startTime ?? params.from ?? params.start,
|
||||||
|
),
|
||||||
|
endTime: readOptionalString(
|
||||||
|
params.end_time ?? params.endTime ?? params.to ?? params.end,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
describeApplyLayerStyle,
|
describeApplyLayerStyle,
|
||||||
parseApplyLayerStylePayload,
|
parseApplyLayerStylePayload,
|
||||||
} from "../toolCallStyleHelpers";
|
} from "../toolCallStyleHelpers";
|
||||||
|
import { buildViewHistoryAction } from "../historyToolAction";
|
||||||
|
|
||||||
type ToolCallEvent = StreamEvent & { type: "tool_call" };
|
type ToolCallEvent = StreamEvent & { type: "tool_call" };
|
||||||
|
|
||||||
@@ -253,21 +254,12 @@ const buildToolAction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tool === "view_history") {
|
if (tool === "view_history") {
|
||||||
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
|
const action = buildViewHistoryAction(params);
|
||||||
const { startTime, endTime } = resolveTimeRange(params);
|
|
||||||
return {
|
return {
|
||||||
action: {
|
action,
|
||||||
type: "view_history",
|
|
||||||
featureInfos,
|
|
||||||
dataType:
|
|
||||||
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
|
|
||||||
"realtime",
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
},
|
|
||||||
kind: "panel",
|
kind: "panel",
|
||||||
title: "打开计算结果曲线",
|
title: "打开计算结果曲线",
|
||||||
description: compactNames(featureInfos.map(([id]) => id)),
|
description: compactNames(action.featureInfos.map(([id]) => id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
buildBurstDetectionRequest,
|
buildBurstDetectionRequest,
|
||||||
createBurstDetectionAnalysisParametersState,
|
createBurstDetectionAnalysisParametersState,
|
||||||
|
fetchPressureSamplingInterval,
|
||||||
parseScadaFrequencyMinutes,
|
parseScadaFrequencyMinutes,
|
||||||
resolvePressureSamplingInterval,
|
resolvePressureSamplingInterval,
|
||||||
} from "./AnalysisParameters";
|
} from "./AnalysisParameters";
|
||||||
|
|
||||||
|
jest.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const get = api.get as jest.Mock;
|
||||||
|
|
||||||
describe("burst detection request", () => {
|
describe("burst detection request", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
it("requests the latest complete monitoring time by default", () => {
|
it("requests the latest complete monitoring time by default", () => {
|
||||||
const state = createBurstDetectionAnalysisParametersState();
|
const state = createBurstDetectionAnalysisParametersState();
|
||||||
state.schemeName = " latest-case ";
|
state.schemeName = " latest-case ";
|
||||||
@@ -45,11 +59,27 @@ describe("burst detection request", () => {
|
|||||||
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
|
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
|
||||||
expect(
|
expect(
|
||||||
resolvePressureSamplingInterval([
|
resolvePressureSamplingInterval([
|
||||||
{ type: "pressure", transmission_frequency: "0:15:00" },
|
{ device_type: "pressure", transmission_frequency: "0:15:00" },
|
||||||
{ type: "pressure", transmission_frequency: "0:15:00" },
|
{ device_type: "pressure", transmission_frequency: "0:15:00" },
|
||||||
{ type: "pressure", transmission_frequency: "0:30:00" },
|
{ device_type: "pressure", transmission_frequency: "0:30:00" },
|
||||||
{ type: "pipe_flow", transmission_frequency: "1:00:00" },
|
{ device_type: "pipe_flow", transmission_frequency: "1:00:00" },
|
||||||
]),
|
]),
|
||||||
).toBe(15);
|
).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads the pressure frequency from the pooled SCADA metadata endpoint", async () => {
|
||||||
|
get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
count: 2,
|
||||||
|
data: [
|
||||||
|
{ device_type: "pressure", transmission_frequency: "0:30:00" },
|
||||||
|
{ device_type: "pipe_flow", transmission_frequency: "0:15:00" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchPressureSamplingInterval()).resolves.toBe(30);
|
||||||
|
expect(get).toHaveBeenCalledWith("/api/v1/scada-info/database-view");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,10 +46,14 @@ export interface BurstDetectionAnalysisParametersState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ScadaInfoItem {
|
interface ScadaInfoItem {
|
||||||
type?: string;
|
device_type?: string;
|
||||||
transmission_frequency?: string | number | null;
|
transmission_frequency?: string | number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScadaInfoResponse {
|
||||||
|
data?: ScadaInfoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
const currentQuarterHour = () => {
|
const currentQuarterHour = () => {
|
||||||
const now = dayjs().second(0).millisecond(0);
|
const now = dayjs().second(0).millisecond(0);
|
||||||
return now.minute(Math.floor(now.minute() / 15) * 15);
|
return now.minute(Math.floor(now.minute() / 15) * 15);
|
||||||
@@ -86,7 +90,7 @@ export const parseScadaFrequencyMinutes = (
|
|||||||
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
||||||
const counts = new Map<number, number>();
|
const counts = new Map<number, number>();
|
||||||
items
|
items
|
||||||
.filter((item) => item.type?.toLowerCase() === "pressure")
|
.filter((item) => item.device_type?.toLowerCase() === "pressure")
|
||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
|
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
|
||||||
if (minutes && 1440 % minutes === 0) {
|
if (minutes && 1440 % minutes === 0) {
|
||||||
@@ -99,6 +103,15 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
|||||||
)[0]?.[0] ?? 15;
|
)[0]?.[0] ?? 15;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchPressureSamplingInterval = async () => {
|
||||||
|
const response = await api.get<ScadaInfoResponse>(
|
||||||
|
"/api/v1/scada-info/database-view",
|
||||||
|
);
|
||||||
|
return resolvePressureSamplingInterval(
|
||||||
|
Array.isArray(response.data.data) ? response.data.data : [],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const buildBurstDetectionRequest = (
|
export const buildBurstDetectionRequest = (
|
||||||
parameters: BurstDetectionAnalysisParametersState,
|
parameters: BurstDetectionAnalysisParametersState,
|
||||||
) => ({
|
) => ({
|
||||||
@@ -141,13 +154,9 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
if (samplingIntervalSource !== "metadata") return;
|
if (samplingIntervalSource !== "metadata") return;
|
||||||
let active = true;
|
let active = true;
|
||||||
setFrequencyLoading(true);
|
setFrequencyLoading(true);
|
||||||
api
|
fetchPressureSamplingInterval()
|
||||||
.get("/api/v1/scada-info")
|
.then((interval) => {
|
||||||
.then((response) => {
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const interval = resolvePressureSamplingInterval(
|
|
||||||
response.data as ScadaInfoItem[],
|
|
||||||
);
|
|
||||||
setParametersState((previous) =>
|
setParametersState((previous) =>
|
||||||
previous.samplingIntervalSource === "metadata"
|
previous.samplingIntervalSource === "metadata"
|
||||||
? { ...previous, samplingIntervalMinutes: interval }
|
? { ...previous, samplingIntervalMinutes: interval }
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import SchemeQuery, {
|
|||||||
createBurstDetectionSchemeQueryState,
|
createBurstDetectionSchemeQueryState,
|
||||||
type BurstDetectionSchemeQueryState,
|
type BurstDetectionSchemeQueryState,
|
||||||
} from "./SchemeQuery";
|
} from "./SchemeQuery";
|
||||||
|
import { useData } from "@components/olmap/core/MapComponent";
|
||||||
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
|
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
@@ -38,6 +39,7 @@ const TabPanel = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const BurstDetectionPanel: React.FC = () => {
|
const BurstDetectionPanel: React.FC = () => {
|
||||||
|
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<BurstDetectionResult | null>(null);
|
const [result, setResult] = useState<BurstDetectionResult | null>(null);
|
||||||
@@ -58,10 +60,23 @@ const BurstDetectionPanel: React.FC = () => {
|
|||||||
const drawerWidth = 450;
|
const drawerWidth = 450;
|
||||||
const panelTitle = "爆管侦测";
|
const panelTitle = "爆管侦测";
|
||||||
|
|
||||||
const handleResult = useCallback((payload: BurstDetectionResult) => {
|
const handleResult = useCallback(
|
||||||
setResult(payload);
|
(payload: BurstDetectionResult) => {
|
||||||
setTab(2);
|
setSchemeRunId?.("");
|
||||||
}, []);
|
setResult(payload);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewResult = useCallback(
|
||||||
|
(payload: BurstDetectionResult, runId: string) => {
|
||||||
|
setSchemeRunId?.(runId);
|
||||||
|
setResult(payload);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -166,7 +181,7 @@ const BurstDetectionPanel: React.FC = () => {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={tab} index={1}>
|
<TabPanel value={tab} index={1}>
|
||||||
<SchemeQuery
|
<SchemeQuery
|
||||||
onViewResult={handleResult}
|
onViewResult={handleViewResult}
|
||||||
schemes={schemes}
|
schemes={schemes}
|
||||||
onSchemesChange={setSchemes}
|
onSchemesChange={setSchemes}
|
||||||
state={queryState}
|
state={queryState}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: BurstDetectionResult) => void;
|
onViewResult: (result: BurstDetectionResult, runId: string) => void;
|
||||||
schemes?: BurstDetectionSchemeRecord[];
|
schemes?: BurstDetectionSchemeRecord[];
|
||||||
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
|
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
|
||||||
state?: BurstDetectionSchemeQueryState;
|
state?: BurstDetectionSchemeQueryState;
|
||||||
@@ -174,7 +174,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
throw new Error("方案详情缺少侦测结果数据");
|
throw new Error("方案详情缺少侦测结果数据");
|
||||||
}
|
}
|
||||||
|
|
||||||
onViewResult(normalizedResult);
|
onViewResult(normalizedResult, runId);
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "方案加载成功",
|
message: "方案加载成功",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import SchemeQuery, {
|
|||||||
type BurstLocationSchemeQueryState,
|
type BurstLocationSchemeQueryState,
|
||||||
} from "./SchemeQuery";
|
} from "./SchemeQuery";
|
||||||
import { BurstLocationResult, BurstSchemeRecord } from "./types";
|
import { BurstLocationResult, BurstSchemeRecord } from "./types";
|
||||||
|
import { useData } from "@components/olmap/core/MapComponent";
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
value,
|
value,
|
||||||
@@ -35,6 +36,7 @@ const TabPanel = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const BurstLocationPanel: React.FC = () => {
|
const BurstLocationPanel: React.FC = () => {
|
||||||
|
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<BurstLocationResult | null>(null);
|
const [result, setResult] = useState<BurstLocationResult | null>(null);
|
||||||
@@ -51,15 +53,23 @@ const BurstLocationPanel: React.FC = () => {
|
|||||||
const drawerWidth = 450;
|
const drawerWidth = 450;
|
||||||
const panelTitle = "爆管定位";
|
const panelTitle = "爆管定位";
|
||||||
|
|
||||||
const handleResult = useCallback((payload: BurstLocationResult) => {
|
const handleResult = useCallback(
|
||||||
setResult(payload);
|
(payload: BurstLocationResult) => {
|
||||||
setTab(2);
|
setSchemeRunId?.("");
|
||||||
}, []);
|
setResult(payload);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewResult = useCallback((payload: BurstLocationResult) => {
|
const handleViewResult = useCallback(
|
||||||
setResult(payload);
|
(payload: BurstLocationResult, runId: string) => {
|
||||||
setTab(2);
|
setSchemeRunId?.(runId);
|
||||||
}, []);
|
setResult(payload);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorNam
|
|||||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: BurstLocationResult) => void;
|
onViewResult: (result: BurstLocationResult, runId: string) => void;
|
||||||
schemes?: BurstSchemeRecord[];
|
schemes?: BurstSchemeRecord[];
|
||||||
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
|
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
|
||||||
state?: BurstLocationSchemeQueryState;
|
state?: BurstLocationSchemeQueryState;
|
||||||
@@ -285,7 +285,7 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
if (!normalizedResult) {
|
if (!normalizedResult) {
|
||||||
throw new Error("方案详情缺少定位结果数据");
|
throw new Error("方案详情缺少定位结果数据");
|
||||||
}
|
}
|
||||||
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
|
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult), runId);
|
||||||
open?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "方案加载成功",
|
message: "方案加载成功",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
FormatListBulleted,
|
FormatListBulleted,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useData, useMap } from "@components/olmap/core/MapComponent";
|
||||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
import AnalysisParameters, {
|
import AnalysisParameters, {
|
||||||
createDMALeakAnalysisParametersState,
|
createDMALeakAnalysisParametersState,
|
||||||
@@ -50,6 +50,7 @@ const DMA_AREA_INDEX_PROPERTY = "dma_area_index";
|
|||||||
|
|
||||||
const DMALeakDetectionPanel: React.FC = () => {
|
const DMALeakDetectionPanel: React.FC = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [result, setResult] = useState<LeakageResultDetail | null>(null);
|
const [result, setResult] = useState<LeakageResultDetail | null>(null);
|
||||||
@@ -79,15 +80,23 @@ const DMALeakDetectionPanel: React.FC = () => {
|
|||||||
[activeAreas.length],
|
[activeAreas.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAnalysisResult = useCallback((res: LeakageResultDetail) => {
|
const handleAnalysisResult = useCallback(
|
||||||
setResult(res);
|
(res: LeakageResultDetail) => {
|
||||||
}, []);
|
setSchemeRunId?.("");
|
||||||
|
setResult(res);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewResult = useCallback((res: LeakageResultDetail) => {
|
const handleViewResult = useCallback(
|
||||||
setResult(res);
|
(res: LeakageResultDetail, runId: string) => {
|
||||||
setLoadedResult(res);
|
setSchemeRunId?.(runId);
|
||||||
setTab(2);
|
setResult(res);
|
||||||
}, []);
|
setLoadedResult(res);
|
||||||
|
setTab(2);
|
||||||
|
},
|
||||||
|
[setSchemeRunId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|||||||
@@ -27,13 +27,17 @@ import {
|
|||||||
listAnalysisSchemes,
|
listAnalysisSchemes,
|
||||||
} from "@/lib/analysisRuns";
|
} from "@/lib/analysisRuns";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
import {
|
||||||
|
LeakageResultDetail,
|
||||||
|
LeakageSchemeRecord,
|
||||||
|
normalizeLeakageRows,
|
||||||
|
} from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
||||||
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onViewResult: (result: LeakageResultDetail) => void;
|
onViewResult: (result: LeakageResultDetail, runId: string) => void;
|
||||||
schemes?: LeakageSchemeRecord[];
|
schemes?: LeakageSchemeRecord[];
|
||||||
onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void;
|
onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void;
|
||||||
state?: DMALeakSchemeQueryState;
|
state?: DMALeakSchemeQueryState;
|
||||||
@@ -131,14 +135,18 @@ const SchemeQuery: React.FC<Props> = ({
|
|||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("方案详情缺少漏损识别结果");
|
throw new Error("方案详情缺少漏损识别结果");
|
||||||
}
|
}
|
||||||
onViewResult({
|
onViewResult(
|
||||||
...result,
|
{
|
||||||
scheme_name: scheme.scheme_name,
|
...result,
|
||||||
scheme_detail: scheme.scheme_detail,
|
rows: normalizeLeakageRows(result),
|
||||||
scheme_start_time: scheme.scheme_start_time,
|
scheme_name: scheme.scheme_name,
|
||||||
create_time: scheme.create_time,
|
scheme_detail: scheme.scheme_detail,
|
||||||
username: scheme.username,
|
scheme_start_time: scheme.scheme_start_time,
|
||||||
} as LeakageResultDetail);
|
create_time: scheme.create_time,
|
||||||
|
username: scheme.username,
|
||||||
|
} as LeakageResultDetail,
|
||||||
|
runId,
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { normalizeLeakageRows } from "./types";
|
||||||
|
|
||||||
|
const migratedRow = {
|
||||||
|
Area: "DMA-01",
|
||||||
|
LeakageRatioRaw: 0.12,
|
||||||
|
LeakageRatio: 0.1,
|
||||||
|
LeakageFlow_m3_per_s: 0.02,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("normalizeLeakageRows", () => {
|
||||||
|
it("restores migrated DMA results stored under result_rows", () => {
|
||||||
|
expect(normalizeLeakageRows({ result_rows: [migratedRow] })).toEqual([
|
||||||
|
migratedRow,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the canonical rows field when both shapes are present", () => {
|
||||||
|
const canonicalRow = { ...migratedRow, Area: "DMA-02" };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeLeakageRows({
|
||||||
|
rows: [canonicalRow],
|
||||||
|
result_rows: [migratedRow],
|
||||||
|
}),
|
||||||
|
).toEqual([canonicalRow]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,18 @@ export interface LeakageRow {
|
|||||||
LeakageFlow_m3_per_s: number;
|
LeakageFlow_m3_per_s: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeLeakageRows = (
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): LeakageRow[] => {
|
||||||
|
if (Array.isArray(payload.rows)) {
|
||||||
|
return payload.rows as LeakageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(payload.result_rows)
|
||||||
|
? (payload.result_rows as LeakageRow[])
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
export interface LeakageSchemeRecord {
|
export interface LeakageSchemeRecord {
|
||||||
scheme_id: string;
|
scheme_id: string;
|
||||||
scheme_name: string;
|
scheme_name: string;
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ const run = {
|
|||||||
describe("sensor placement runs API adapter", () => {
|
describe("sensor placement runs API adapter", () => {
|
||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it("lists paged runs and maps run fields to the existing screen model", async () => {
|
it.each([
|
||||||
|
["contract page", { items: [run], total: 1, limit: 1000, offset: 0 }],
|
||||||
|
["interceptor-unwrapped page", [run]],
|
||||||
|
])("maps runs from the %s response", async (_label, data) => {
|
||||||
jest.mocked(api.get).mockResolvedValue({
|
jest.mocked(api.get).mockResolvedValue({
|
||||||
data: { items: [run], total: 1, limit: 1000, offset: 0 },
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(listSensorPlacementSchemes()).resolves.toEqual([
|
await expect(listSensorPlacementSchemes()).resolves.toEqual([
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ type SensorPlacementRunResponse = {
|
|||||||
can_edit: boolean;
|
can_edit: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SensorPlacementRunPage = {
|
||||||
|
items: SensorPlacementRunResponse[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
const toSensorPlacementScheme = (
|
const toSensorPlacementScheme = (
|
||||||
run: SensorPlacementRunResponse,
|
run: SensorPlacementRunResponse,
|
||||||
): SensorPlacementScheme => ({
|
): SensorPlacementScheme => ({
|
||||||
@@ -53,12 +60,16 @@ export const optimizeSensorPlacement = async (
|
|||||||
export const listSensorPlacementSchemes = async (): Promise<
|
export const listSensorPlacementSchemes = async (): Promise<
|
||||||
SensorPlacementScheme[]
|
SensorPlacementScheme[]
|
||||||
> => {
|
> => {
|
||||||
const response = await api.get<{
|
const response = await api.get<
|
||||||
items: SensorPlacementRunResponse[];
|
SensorPlacementRunPage | SensorPlacementRunResponse[]
|
||||||
}>(`${config.BACKEND_URL}/api/v1/sensor-placement-runs`, {
|
>(
|
||||||
params: { limit: 1000, offset: 0 },
|
`${config.BACKEND_URL}/api/v1/sensor-placement-runs`,
|
||||||
});
|
{ params: { limit: 1000, offset: 0 } },
|
||||||
return response.data.items.map(toSensorPlacementScheme);
|
);
|
||||||
|
const runs = Array.isArray(response.data)
|
||||||
|
? response.data
|
||||||
|
: response.data.items;
|
||||||
|
return runs.map(toSensorPlacementScheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSensorPlacementScheme = async (
|
export const getSensorPlacementScheme = async (
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { fetchHistoryData } from "./HistoryDataPanel";
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||||
|
useData: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jsonResponse = (payload: unknown, status = 200) =>
|
||||||
|
({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: jest.fn().mockResolvedValue(payload),
|
||||||
|
}) as unknown as Response;
|
||||||
|
|
||||||
|
const range = {
|
||||||
|
from: new Date("2026-08-17T09:00:00.000Z"),
|
||||||
|
to: new Date("2026-08-17T10:00:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("fetchHistoryData", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("queries SCADA readings once per selected network element", async () => {
|
||||||
|
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
const elementId = url.searchParams.get("element_id") ?? "";
|
||||||
|
return jsonResponse({
|
||||||
|
[elementId]: [{ time: range.from.toISOString(), value: 1 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchHistoryData(
|
||||||
|
[
|
||||||
|
["J-1", "junction"],
|
||||||
|
["J-2", "junction"],
|
||||||
|
],
|
||||||
|
range,
|
||||||
|
"none",
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementIds = jest
|
||||||
|
.mocked(apiFetch)
|
||||||
|
.mock.calls.map(([input]) =>
|
||||||
|
new URL(String(input)).searchParams.get("element_id"),
|
||||||
|
);
|
||||||
|
expect(elementIds).toEqual(["J-1", "J-2", "J-1", "J-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the analysis run ID for historical scheme simulation data", async () => {
|
||||||
|
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ "P-1": [] }));
|
||||||
|
|
||||||
|
await fetchHistoryData(
|
||||||
|
[["P-1", "pipe"]],
|
||||||
|
range,
|
||||||
|
"scheme",
|
||||||
|
"99dd4142-368b-54cb-bfca-d59ee48f6298",
|
||||||
|
);
|
||||||
|
|
||||||
|
const simulationUrls = jest
|
||||||
|
.mocked(apiFetch)
|
||||||
|
.mock.calls.map(([input]) => new URL(String(input)))
|
||||||
|
.filter((url) => url.pathname.endsWith("/element-simulations"));
|
||||||
|
|
||||||
|
expect(simulationUrls).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
simulationUrls.map((url) => url.searchParams.get("run_id")),
|
||||||
|
).toEqual([null, "99dd4142-368b-54cb-bfca-d59ee48f6298"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized element selections before issuing requests", async () => {
|
||||||
|
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({}));
|
||||||
|
const featureInfos = Array.from(
|
||||||
|
{ length: 201 },
|
||||||
|
(_, index) => [`J-${index}`, "junction"] as [string, string],
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchHistoryData(featureInfos, range, "none"),
|
||||||
|
).rejects.toThrow("历史数据一次最多查询 200 个管网元素");
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits concurrent SCADA requests for multi-element history", async () => {
|
||||||
|
let activeRequests = 0;
|
||||||
|
let peakRequests = 0;
|
||||||
|
jest.mocked(apiFetch).mockImplementation(async (input) => {
|
||||||
|
activeRequests += 1;
|
||||||
|
peakRequests = Math.max(peakRequests, activeRequests);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
activeRequests -= 1;
|
||||||
|
const elementId = new URL(String(input)).searchParams.get("element_id") ?? "";
|
||||||
|
return jsonResponse({ [elementId]: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchHistoryData(
|
||||||
|
Array.from(
|
||||||
|
{ length: 20 },
|
||||||
|
(_, index) => [`J-${index}`, "junction"] as [string, string],
|
||||||
|
),
|
||||||
|
range,
|
||||||
|
"none",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(peakRequests).toBeLessThanOrEqual(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,10 +53,6 @@ export interface SCADADataPanelProps {
|
|||||||
featureInfos: [string, string][];
|
featureInfos: [string, string][];
|
||||||
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
|
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
|
||||||
type?: "realtime" | "scheme" | "none";
|
type?: "realtime" | "scheme" | "none";
|
||||||
/** 策略类型 */
|
|
||||||
scheme_type?: string;
|
|
||||||
/** 策略名称 */
|
|
||||||
scheme_name?: string;
|
|
||||||
/** 默认展示的选项卡 */
|
/** 默认展示的选项卡 */
|
||||||
defaultTab?: "chart" | "table";
|
defaultTab?: "chart" | "table";
|
||||||
/** Y 轴数值的小数位数 */
|
/** Y 轴数值的小数位数 */
|
||||||
@@ -65,6 +61,8 @@ export interface SCADADataPanelProps {
|
|||||||
start_time?: string;
|
start_time?: string;
|
||||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
|
/** 方案分析运行 ID;方案数据必须显式指定,避免读取到其他页面残留的全局方案。 */
|
||||||
|
runId?: string;
|
||||||
/** 关闭面板 */
|
/** 关闭面板 */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -73,6 +71,10 @@ type PanelTab = "chart" | "table";
|
|||||||
|
|
||||||
type LoadingState = "idle" | "loading" | "success" | "error";
|
type LoadingState = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
const MAX_HISTORY_ELEMENTS = 200;
|
||||||
|
const MAX_HISTORY_ELEMENT_ID_LENGTH = 128;
|
||||||
|
const HISTORY_SCADA_CONCURRENCY = 4;
|
||||||
|
|
||||||
const panelHeaderActionSx = {
|
const panelHeaderActionSx = {
|
||||||
color: "primary.contrastText",
|
color: "primary.contrastText",
|
||||||
backgroundColor: "rgba(255,255,255,0.08)",
|
backgroundColor: "rgba(255,255,255,0.08)",
|
||||||
@@ -81,51 +83,126 @@ const panelHeaderActionSx = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const buildApiUrl = (
|
||||||
* 从后端 API 获取 SCADA 数据
|
path: string,
|
||||||
*/
|
params: Record<string, string | boolean>,
|
||||||
const fetchFromBackend = async (
|
) => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
return `${config.BACKEND_URL}${path}?${searchParams.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOptionalJson = async (url: string, signal?: AbortSignal) => {
|
||||||
|
const response = await apiFetch(url, { signal });
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`历史数据请求失败: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapWithConcurrency = async <Input, Output>(
|
||||||
|
items: Input[],
|
||||||
|
limit: number,
|
||||||
|
mapper: (item: Input, index: number) => Promise<Output>,
|
||||||
|
): Promise<Output[]> => {
|
||||||
|
const results = new Array<Output>(items.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
const workerCount = Math.min(limit, items.length);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: workerCount }, async () => {
|
||||||
|
while (nextIndex < items.length) {
|
||||||
|
const currentIndex = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从后端 API 获取管网元素的监测、实时模拟和方案模拟数据。 */
|
||||||
|
export const fetchHistoryData = async (
|
||||||
featureInfos: [string, string][],
|
featureInfos: [string, string][],
|
||||||
range: { from: Date; to: Date },
|
range: { from: Date; to: Date },
|
||||||
type: "realtime" | "scheme" | "none",
|
type: "realtime" | "scheme" | "none",
|
||||||
scheme_type?: string,
|
schemeRunId?: string,
|
||||||
scheme_name?: string
|
signal?: AbortSignal,
|
||||||
): Promise<TimeSeriesPoint[]> => {
|
): Promise<TimeSeriesPoint[]> => {
|
||||||
if (featureInfos.length === 0) {
|
if (featureInfos.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
if (featureInfos.length > MAX_HISTORY_ELEMENTS) {
|
||||||
|
throw new Error(`历史数据一次最多查询 ${MAX_HISTORY_ELEMENTS} 个管网元素`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
featureInfos.some(
|
||||||
|
([id]) =>
|
||||||
|
id.trim().length === 0 || id.length > MAX_HISTORY_ELEMENT_ID_LENGTH,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("历史数据包含无效的管网元素 ID");
|
||||||
|
}
|
||||||
|
|
||||||
// 提取设备 ID 列表
|
const uniqueFeatureInfos = Array.from(
|
||||||
const featureIds = featureInfos.map(([id]) => id);
|
new Map(featureInfos.map((featureInfo) => [featureInfo[0], featureInfo])).values(),
|
||||||
|
);
|
||||||
|
const featureIds = uniqueFeatureInfos.map(([id]) => id);
|
||||||
|
|
||||||
const feature_ids = featureIds.join(",");
|
|
||||||
const start_time = dayjs(range.from).toISOString();
|
const start_time = dayjs(range.from).toISOString();
|
||||||
const end_time = dayjs(range.to).toISOString();
|
const end_time = dayjs(range.to).toISOString();
|
||||||
|
|
||||||
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
|
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
|
||||||
const feature_infos = featureInfos
|
const feature_infos = uniqueFeatureInfos
|
||||||
.map(([id, type]) => `${id}:${type}`)
|
.map(([id, type]) => `${id}:${type}`)
|
||||||
.join(",");
|
.join(",");
|
||||||
|
|
||||||
// 监测值数据接口(use_cleaned=false)
|
const fetchElementScadaData = async (useCleaned: boolean) => {
|
||||||
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`;
|
const results = await mapWithConcurrency(
|
||||||
// 清洗数据接口(use_cleaned=true)
|
featureIds,
|
||||||
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`;
|
HISTORY_SCADA_CONCURRENCY,
|
||||||
// 模拟数据接口
|
(elementId) =>
|
||||||
const simulationDataUrl = `${config.BACKEND_URL}/api/v1/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
|
fetchOptionalJson(
|
||||||
// 策略模拟数据接口
|
buildApiUrl("/api/v1/timeseries/views/element-scada-readings", {
|
||||||
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}`;
|
element_id: elementId,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
use_cleaned: useCleaned,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Object.assign({}, ...results.filter(Boolean));
|
||||||
|
};
|
||||||
|
|
||||||
|
const simulationDataUrl = buildApiUrl(
|
||||||
|
"/api/v1/timeseries/views/element-simulations",
|
||||||
|
{ feature_infos, start_time, end_time },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (type === "scheme" && !schemeRunId) {
|
||||||
|
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemeSimulationDataUrl = schemeRunId
|
||||||
|
? buildApiUrl("/api/v1/timeseries/views/element-simulations", {
|
||||||
|
feature_infos,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
run_id: schemeRunId,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === "none") {
|
if (type === "none") {
|
||||||
// 查询清洗值和监测值
|
// 查询清洗值和监测值
|
||||||
const [cleanedRes, rawRes] = await Promise.all([
|
const [cleanedRes, rawRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetchElementScadaData(true),
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
fetchElementScadaData(false),
|
||||||
.catch(() => null),
|
|
||||||
apiFetch(rawDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||||
@@ -143,18 +220,10 @@ const fetchFromBackend = async (
|
|||||||
} else if (type === "scheme") {
|
} else if (type === "scheme") {
|
||||||
// 查询策略模拟值、实时模拟值、清洗值和监测值
|
// 查询策略模拟值、实时模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
|
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetchElementScadaData(true),
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
fetchElementScadaData(false),
|
||||||
.catch(() => null),
|
fetchOptionalJson(simulationDataUrl, signal),
|
||||||
apiFetch(rawDataUrl)
|
fetchOptionalJson(schemeSimulationDataUrl!, signal),
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
apiFetch(simulationDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
apiFetch(schemeSimulationDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||||
@@ -176,15 +245,9 @@ const fetchFromBackend = async (
|
|||||||
} else {
|
} else {
|
||||||
// realtime: 查询模拟值、清洗值和监测值
|
// realtime: 查询模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
fetchElementScadaData(true),
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
fetchElementScadaData(false),
|
||||||
.catch(() => null),
|
fetchOptionalJson(simulationDataUrl, signal),
|
||||||
apiFetch(rawDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
apiFetch(simulationDataUrl)
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||||
@@ -425,12 +488,11 @@ const emptyStateMessages: Record<
|
|||||||
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||||
featureInfos,
|
featureInfos,
|
||||||
type = "none",
|
type = "none",
|
||||||
scheme_type = "burst_analysis",
|
|
||||||
scheme_name,
|
|
||||||
defaultTab = "chart",
|
defaultTab = "chart",
|
||||||
fractionDigits = 2,
|
fractionDigits = 2,
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
|
runId,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
// 从 featureInfos 中提取设备 ID 列表
|
// 从 featureInfos 中提取设备 ID 列表
|
||||||
@@ -465,6 +527,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
"raw" | "clean" | "sim" | "all"
|
"raw" | "clean" | "sim" | "all"
|
||||||
>(() => (featureInfos.length === 1 ? "all" : "clean"));
|
>(() => (featureInfos.length === 1 ? "all" : "clean"));
|
||||||
const draggableRef = useRef<HTMLDivElement>(null);
|
const draggableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const requestControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
@@ -503,6 +566,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const handleFetch = useCallback(
|
const handleFetch = useCallback(
|
||||||
async (reason: string) => {
|
async (reason: string) => {
|
||||||
if (!hasDevices) {
|
if (!hasDevices) {
|
||||||
|
requestControllerRef.current?.abort();
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setLoadingState("idle");
|
setLoadingState("idle");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -511,26 +575,43 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
|
|
||||||
setLoadingState("loading");
|
setLoadingState("loading");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
requestControllerRef.current?.abort();
|
||||||
|
const requestController = new AbortController();
|
||||||
|
requestControllerRef.current = requestController;
|
||||||
try {
|
try {
|
||||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||||
const result = await fetchFromBackend(
|
const result = await fetchHistoryData(
|
||||||
featureInfos,
|
featureInfos,
|
||||||
{
|
{
|
||||||
from: rangeFrom.toDate(),
|
from: rangeFrom.toDate(),
|
||||||
to: rangeTo.toDate(),
|
to: rangeTo.toDate(),
|
||||||
},
|
},
|
||||||
type,
|
type,
|
||||||
scheme_type,
|
runId,
|
||||||
scheme_name
|
requestController.signal,
|
||||||
);
|
);
|
||||||
|
if (requestControllerRef.current !== requestController) return;
|
||||||
setTimeSeries(result);
|
setTimeSeries(result);
|
||||||
setLoadingState("success");
|
setLoadingState("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
requestControllerRef.current !== requestController ||
|
||||||
|
(err instanceof Error && err.name === "AbortError")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : "未知错误");
|
setError(err instanceof Error ? err.message : "未知错误");
|
||||||
setLoadingState("error");
|
setLoadingState("error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[featureInfos, hasDevices, normalizedRange, type, scheme_type, scheme_name]
|
[featureInfos, hasDevices, normalizedRange, runId, type],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
requestControllerRef.current?.abort();
|
||||||
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 设备变化时自动查询
|
// 设备变化时自动查询
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const [chatPanelType, setChatPanelType] = useState<
|
const [chatPanelType, setChatPanelType] = useState<
|
||||||
"realtime" | "scheme" | "none"
|
"realtime" | "scheme" | "none"
|
||||||
>("none");
|
>("none");
|
||||||
|
const [chatPanelRunId, setChatPanelRunId] = useState<string | null>(null);
|
||||||
const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{
|
const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
@@ -142,6 +143,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setHighlightFeatures,
|
setHighlightFeatures,
|
||||||
setChatPanelFeatureInfos,
|
setChatPanelFeatureInfos,
|
||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
|
setChatPanelRunId,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
setShowStyleEditor,
|
setShowStyleEditor,
|
||||||
@@ -349,6 +351,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
}
|
}
|
||||||
setChatPanelFeatureInfos(null);
|
setChatPanelFeatureInfos(null);
|
||||||
|
setChatPanelRunId(null);
|
||||||
setChatPanelTimeRange(null);
|
setChatPanelTimeRange(null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -934,12 +937,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
<ToolbarHistoryPanel
|
<ToolbarHistoryPanel
|
||||||
showHistoryPanel={showHistoryPanel}
|
showHistoryPanel={showHistoryPanel}
|
||||||
chatPanelType={chatPanelType}
|
chatPanelType={chatPanelType}
|
||||||
|
chatPanelRunId={chatPanelRunId}
|
||||||
chatPanelFeatureInfos={chatPanelFeatureInfos}
|
chatPanelFeatureInfos={chatPanelFeatureInfos}
|
||||||
chatPanelTimeRange={chatPanelTimeRange}
|
chatPanelTimeRange={chatPanelTimeRange}
|
||||||
highlightFeatures={highlightFeatures}
|
highlightFeatures={highlightFeatures}
|
||||||
HistoryPanel={HistoryPanel}
|
HistoryPanel={HistoryPanel}
|
||||||
schemeName={schemeName}
|
schemeName={schemeName}
|
||||||
queryType={queryType}
|
queryType={queryType}
|
||||||
|
schemeRunId={schemeRunId}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
deactivateTool("history");
|
deactivateTool("history");
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import HistoryDataPanel from "./HistoryDataPanel";
|
|||||||
type ToolbarHistoryPanelProps = {
|
type ToolbarHistoryPanelProps = {
|
||||||
showHistoryPanel: boolean;
|
showHistoryPanel: boolean;
|
||||||
chatPanelType: "realtime" | "scheme" | "none";
|
chatPanelType: "realtime" | "scheme" | "none";
|
||||||
|
chatPanelRunId: string | null;
|
||||||
chatPanelFeatureInfos: [string, string][] | null;
|
chatPanelFeatureInfos: [string, string][] | null;
|
||||||
chatPanelTimeRange: {
|
chatPanelTimeRange: {
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
@@ -19,18 +20,21 @@ type ToolbarHistoryPanelProps = {
|
|||||||
HistoryPanel?: React.ComponentType<any>;
|
HistoryPanel?: React.ComponentType<any>;
|
||||||
schemeName?: string;
|
schemeName?: string;
|
||||||
queryType?: string;
|
queryType?: string;
|
||||||
|
schemeRunId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
||||||
showHistoryPanel,
|
showHistoryPanel,
|
||||||
chatPanelType,
|
chatPanelType,
|
||||||
|
chatPanelRunId,
|
||||||
chatPanelFeatureInfos,
|
chatPanelFeatureInfos,
|
||||||
chatPanelTimeRange,
|
chatPanelTimeRange,
|
||||||
highlightFeatures,
|
highlightFeatures,
|
||||||
HistoryPanel,
|
HistoryPanel,
|
||||||
schemeName,
|
schemeName,
|
||||||
queryType,
|
queryType,
|
||||||
|
schemeRunId,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const featureInfos = useMemo(
|
const featureInfos = useMemo(
|
||||||
@@ -75,8 +79,6 @@ const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<HistoryDataPanel
|
<HistoryDataPanel
|
||||||
featureInfos={featureInfos}
|
featureInfos={featureInfos}
|
||||||
scheme_type="burst_analysis"
|
|
||||||
scheme_name={schemeName}
|
|
||||||
type={
|
type={
|
||||||
chatPanelFeatureInfos
|
chatPanelFeatureInfos
|
||||||
? chatPanelType
|
? chatPanelType
|
||||||
@@ -84,6 +86,7 @@ const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
|||||||
}
|
}
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
|
runId={chatPanelFeatureInfos ? chatPanelRunId ?? undefined : schemeRunId}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type UseToolbarChatActionsParams = {
|
|||||||
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
|
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
|
||||||
setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>;
|
setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>;
|
||||||
setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>;
|
setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>;
|
||||||
|
setChatPanelRunId: Dispatch<SetStateAction<string | null>>;
|
||||||
setChatPanelTimeRange: Dispatch<
|
setChatPanelTimeRange: Dispatch<
|
||||||
SetStateAction<{ startTime?: string; endTime?: string } | null>
|
SetStateAction<{ startTime?: string; endTime?: string } | null>
|
||||||
>;
|
>;
|
||||||
@@ -37,6 +38,7 @@ export const useToolbarChatActions = ({
|
|||||||
setHighlightFeatures,
|
setHighlightFeatures,
|
||||||
setChatPanelFeatureInfos,
|
setChatPanelFeatureInfos,
|
||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
|
setChatPanelRunId,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
setShowStyleEditor,
|
setShowStyleEditor,
|
||||||
@@ -126,6 +128,7 @@ export const useToolbarChatActions = ({
|
|||||||
case "view_history": {
|
case "view_history": {
|
||||||
setChatPanelFeatureInfos(action.featureInfos);
|
setChatPanelFeatureInfos(action.featureInfos);
|
||||||
setChatPanelType(action.dataType);
|
setChatPanelType(action.dataType);
|
||||||
|
setChatPanelRunId(action.runId ?? null);
|
||||||
setChatPanelTimeRange({
|
setChatPanelTimeRange({
|
||||||
startTime: action.startTime,
|
startTime: action.startTime,
|
||||||
endTime: action.endTime,
|
endTime: action.endTime,
|
||||||
@@ -136,6 +139,7 @@ export const useToolbarChatActions = ({
|
|||||||
case "view_scada": {
|
case "view_scada": {
|
||||||
setChatPanelFeatureInfos(action.featureInfos);
|
setChatPanelFeatureInfos(action.featureInfos);
|
||||||
setChatPanelType("none");
|
setChatPanelType("none");
|
||||||
|
setChatPanelRunId(null);
|
||||||
setChatPanelTimeRange({
|
setChatPanelTimeRange({
|
||||||
startTime: action.startTime,
|
startTime: action.startTime,
|
||||||
endTime: action.endTime,
|
endTime: action.endTime,
|
||||||
@@ -249,6 +253,7 @@ export const useToolbarChatActions = ({
|
|||||||
setChatPanelFeatureInfos,
|
setChatPanelFeatureInfos,
|
||||||
setChatPanelTimeRange,
|
setChatPanelTimeRange,
|
||||||
setChatPanelType,
|
setChatPanelType,
|
||||||
|
setChatPanelRunId,
|
||||||
setHighlightFeatures,
|
setHighlightFeatures,
|
||||||
setShowHistoryPanel,
|
setShowHistoryPanel,
|
||||||
setShowStyleEditor,
|
setShowStyleEditor,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe("ProjectProvider authentication boundary", () => {
|
|||||||
expect(mockApiFetch).not.toHaveBeenCalled();
|
expect(mockApiFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores and opens a saved project after authentication", async () => {
|
it("restores a saved project and reads its current metadata", async () => {
|
||||||
seedSavedProject();
|
seedSavedProject();
|
||||||
mockUseSession.mockReturnValue({ status: "authenticated" });
|
mockUseSession.mockReturnValue({ status: "authenticated" });
|
||||||
|
|
||||||
@@ -95,7 +95,10 @@ describe("ProjectProvider authentication boundary", () => {
|
|||||||
</ProjectProvider>,
|
</ProjectProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(mockApiFetch).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(mockApiFetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
"http://backend.test/api/v1/projects/current",
|
||||||
|
);
|
||||||
expect(mockSetCurrentProjectId).toHaveBeenCalledWith(
|
expect(mockSetCurrentProjectId).toHaveBeenCalledWith(
|
||||||
"a2d67c84-fd9d-4feb-a500-c357244b2760",
|
"a2d67c84-fd9d-4feb-a500-c357244b2760",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,14 +73,6 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
setIsConfigured(true);
|
setIsConfigured(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openResponse = await apiFetch(
|
|
||||||
`${config.BACKEND_URL}/api/v1/projects/current`,
|
|
||||||
{ method: "POST" },
|
|
||||||
);
|
|
||||||
if (!openResponse.ok) {
|
|
||||||
throw new Error(`Failed to open project: HTTP ${openResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const infoResponse = await apiFetch(
|
const infoResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/projects/current`,
|
`${config.BACKEND_URL}/api/v1/projects/current`,
|
||||||
);
|
);
|
||||||
@@ -130,11 +122,13 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
|
|
||||||
// If we have saved config, use it.
|
// If we have saved config, use it.
|
||||||
if (savedWorkspace && savedNetwork) {
|
if (savedWorkspace && savedNetwork) {
|
||||||
applyConfig(
|
void Promise.resolve().then(() =>
|
||||||
savedProjectId || savedNetwork || savedWorkspace,
|
applyConfig(
|
||||||
savedWorkspace,
|
savedProjectId || savedNetwork || savedWorkspace,
|
||||||
savedNetwork,
|
savedWorkspace,
|
||||||
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
|
savedNetwork,
|
||||||
|
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [applyConfig, status]);
|
}, [applyConfig, status]);
|
||||||
|
|||||||
+275
-1601
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ export type ChatToolAction =
|
|||||||
type: "view_history";
|
type: "view_history";
|
||||||
featureInfos: [string, string][];
|
featureInfos: [string, string][];
|
||||||
dataType: "realtime" | "scheme" | "none";
|
dataType: "realtime" | "scheme" | "none";
|
||||||
|
runId?: string;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user