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.
|
||||
|
||||
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
|
||||
|
||||
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": {
|
||||
"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,
|
||||
parseApplyLayerStylePayload,
|
||||
} from "./toolCallStyleHelpers";
|
||||
import { buildViewHistoryAction } from "./historyToolAction";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Interactive card rendered inside a chat bubble for tool actions */
|
||||
@@ -428,17 +429,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
||||
};
|
||||
}
|
||||
case "view_history": {
|
||||
const historyRange = resolveTimeRange();
|
||||
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,
|
||||
};
|
||||
return buildViewHistoryAction(params);
|
||||
}
|
||||
case "view_scada": {
|
||||
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,
|
||||
parseApplyLayerStylePayload,
|
||||
} from "../toolCallStyleHelpers";
|
||||
import { buildViewHistoryAction } from "../historyToolAction";
|
||||
|
||||
type ToolCallEvent = StreamEvent & { type: "tool_call" };
|
||||
|
||||
@@ -253,21 +254,12 @@ const buildToolAction = (
|
||||
}
|
||||
|
||||
if (tool === "view_history") {
|
||||
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
|
||||
const { startTime, endTime } = resolveTimeRange(params);
|
||||
const action = buildViewHistoryAction(params);
|
||||
return {
|
||||
action: {
|
||||
type: "view_history",
|
||||
featureInfos,
|
||||
dataType:
|
||||
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
|
||||
"realtime",
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
action,
|
||||
kind: "panel",
|
||||
title: "打开计算结果曲线",
|
||||
description: compactNames(featureInfos.map(([id]) => id)),
|
||||
description: compactNames(action.featureInfos.map(([id]) => id)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import dayjs from "dayjs";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
buildBurstDetectionRequest,
|
||||
createBurstDetectionAnalysisParametersState,
|
||||
fetchPressureSamplingInterval,
|
||||
parseScadaFrequencyMinutes,
|
||||
resolvePressureSamplingInterval,
|
||||
} from "./AnalysisParameters";
|
||||
|
||||
jest.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const get = api.get as jest.Mock;
|
||||
|
||||
describe("burst detection request", () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
});
|
||||
|
||||
it("requests the latest complete monitoring time by default", () => {
|
||||
const state = createBurstDetectionAnalysisParametersState();
|
||||
state.schemeName = " latest-case ";
|
||||
@@ -45,11 +59,27 @@ describe("burst detection request", () => {
|
||||
expect(parseScadaFrequencyMinutes("1:00:00")).toBe(60);
|
||||
expect(
|
||||
resolvePressureSamplingInterval([
|
||||
{ type: "pressure", transmission_frequency: "0:15:00" },
|
||||
{ type: "pressure", transmission_frequency: "0:15:00" },
|
||||
{ type: "pressure", transmission_frequency: "0:30:00" },
|
||||
{ type: "pipe_flow", transmission_frequency: "1:00:00" },
|
||||
{ device_type: "pressure", transmission_frequency: "0:15:00" },
|
||||
{ device_type: "pressure", transmission_frequency: "0:15:00" },
|
||||
{ device_type: "pressure", transmission_frequency: "0:30:00" },
|
||||
{ device_type: "pipe_flow", transmission_frequency: "1:00:00" },
|
||||
]),
|
||||
).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 {
|
||||
type?: string;
|
||||
device_type?: string;
|
||||
transmission_frequency?: string | number | null;
|
||||
}
|
||||
|
||||
interface ScadaInfoResponse {
|
||||
data?: ScadaInfoItem[];
|
||||
}
|
||||
|
||||
const currentQuarterHour = () => {
|
||||
const now = dayjs().second(0).millisecond(0);
|
||||
return now.minute(Math.floor(now.minute() / 15) * 15);
|
||||
@@ -86,7 +90,7 @@ export const parseScadaFrequencyMinutes = (
|
||||
export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
||||
const counts = new Map<number, number>();
|
||||
items
|
||||
.filter((item) => item.type?.toLowerCase() === "pressure")
|
||||
.filter((item) => item.device_type?.toLowerCase() === "pressure")
|
||||
.forEach((item) => {
|
||||
const minutes = parseScadaFrequencyMinutes(item.transmission_frequency);
|
||||
if (minutes && 1440 % minutes === 0) {
|
||||
@@ -99,6 +103,15 @@ export const resolvePressureSamplingInterval = (items: ScadaInfoItem[]) => {
|
||||
)[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 = (
|
||||
parameters: BurstDetectionAnalysisParametersState,
|
||||
) => ({
|
||||
@@ -141,13 +154,9 @@ const AnalysisParameters: React.FC<Props> = ({
|
||||
if (samplingIntervalSource !== "metadata") return;
|
||||
let active = true;
|
||||
setFrequencyLoading(true);
|
||||
api
|
||||
.get("/api/v1/scada-info")
|
||||
.then((response) => {
|
||||
fetchPressureSamplingInterval()
|
||||
.then((interval) => {
|
||||
if (!active) return;
|
||||
const interval = resolvePressureSamplingInterval(
|
||||
response.data as ScadaInfoItem[],
|
||||
);
|
||||
setParametersState((previous) =>
|
||||
previous.samplingIntervalSource === "metadata"
|
||||
? { ...previous, samplingIntervalMinutes: interval }
|
||||
|
||||
@@ -21,6 +21,7 @@ import SchemeQuery, {
|
||||
createBurstDetectionSchemeQueryState,
|
||||
type BurstDetectionSchemeQueryState,
|
||||
} from "./SchemeQuery";
|
||||
import { useData } from "@components/olmap/core/MapComponent";
|
||||
import { BurstDetectionResult, BurstDetectionSchemeRecord } from "./types";
|
||||
|
||||
const TabPanel = ({
|
||||
@@ -38,6 +39,7 @@ const TabPanel = ({
|
||||
);
|
||||
|
||||
const BurstDetectionPanel: React.FC = () => {
|
||||
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||
const [open, setOpen] = useState(true);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [result, setResult] = useState<BurstDetectionResult | null>(null);
|
||||
@@ -58,10 +60,23 @@ const BurstDetectionPanel: React.FC = () => {
|
||||
const drawerWidth = 450;
|
||||
const panelTitle = "爆管侦测";
|
||||
|
||||
const handleResult = useCallback((payload: BurstDetectionResult) => {
|
||||
setResult(payload);
|
||||
setTab(2);
|
||||
}, []);
|
||||
const handleResult = useCallback(
|
||||
(payload: BurstDetectionResult) => {
|
||||
setSchemeRunId?.("");
|
||||
setResult(payload);
|
||||
setTab(2);
|
||||
},
|
||||
[setSchemeRunId],
|
||||
);
|
||||
|
||||
const handleViewResult = useCallback(
|
||||
(payload: BurstDetectionResult, runId: string) => {
|
||||
setSchemeRunId?.(runId);
|
||||
setResult(payload);
|
||||
setTab(2);
|
||||
},
|
||||
[setSchemeRunId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -166,7 +181,7 @@ const BurstDetectionPanel: React.FC = () => {
|
||||
</TabPanel>
|
||||
<TabPanel value={tab} index={1}>
|
||||
<SchemeQuery
|
||||
onViewResult={handleResult}
|
||||
onViewResult={handleViewResult}
|
||||
schemes={schemes}
|
||||
onSchemesChange={setSchemes}
|
||||
state={queryState}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "./types";
|
||||
|
||||
interface Props {
|
||||
onViewResult: (result: BurstDetectionResult) => void;
|
||||
onViewResult: (result: BurstDetectionResult, runId: string) => void;
|
||||
schemes?: BurstDetectionSchemeRecord[];
|
||||
onSchemesChange?: (schemes: BurstDetectionSchemeRecord[]) => void;
|
||||
state?: BurstDetectionSchemeQueryState;
|
||||
@@ -174,7 +174,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
throw new Error("方案详情缺少侦测结果数据");
|
||||
}
|
||||
|
||||
onViewResult(normalizedResult);
|
||||
onViewResult(normalizedResult, runId);
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "方案加载成功",
|
||||
|
||||
@@ -19,6 +19,7 @@ import SchemeQuery, {
|
||||
type BurstLocationSchemeQueryState,
|
||||
} from "./SchemeQuery";
|
||||
import { BurstLocationResult, BurstSchemeRecord } from "./types";
|
||||
import { useData } from "@components/olmap/core/MapComponent";
|
||||
|
||||
const TabPanel = ({
|
||||
value,
|
||||
@@ -35,6 +36,7 @@ const TabPanel = ({
|
||||
);
|
||||
|
||||
const BurstLocationPanel: React.FC = () => {
|
||||
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||
const [open, setOpen] = useState(true);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [result, setResult] = useState<BurstLocationResult | null>(null);
|
||||
@@ -51,15 +53,23 @@ const BurstLocationPanel: React.FC = () => {
|
||||
const drawerWidth = 450;
|
||||
const panelTitle = "爆管定位";
|
||||
|
||||
const handleResult = useCallback((payload: BurstLocationResult) => {
|
||||
setResult(payload);
|
||||
setTab(2);
|
||||
}, []);
|
||||
const handleResult = useCallback(
|
||||
(payload: BurstLocationResult) => {
|
||||
setSchemeRunId?.("");
|
||||
setResult(payload);
|
||||
setTab(2);
|
||||
},
|
||||
[setSchemeRunId],
|
||||
);
|
||||
|
||||
const handleViewResult = useCallback((payload: BurstLocationResult) => {
|
||||
setResult(payload);
|
||||
setTab(2);
|
||||
}, []);
|
||||
const handleViewResult = useCallback(
|
||||
(payload: BurstLocationResult, runId: string) => {
|
||||
setSchemeRunId?.(runId);
|
||||
setResult(payload);
|
||||
setTab(2);
|
||||
},
|
||||
[setSchemeRunId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -46,7 +46,7 @@ import { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorNam
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface Props {
|
||||
onViewResult: (result: BurstLocationResult) => void;
|
||||
onViewResult: (result: BurstLocationResult, runId: string) => void;
|
||||
schemes?: BurstSchemeRecord[];
|
||||
onSchemesChange?: (schemes: BurstSchemeRecord[]) => void;
|
||||
state?: BurstLocationSchemeQueryState;
|
||||
@@ -285,7 +285,7 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
if (!normalizedResult) {
|
||||
throw new Error("方案详情缺少定位结果数据");
|
||||
}
|
||||
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult));
|
||||
onViewResult(enrichResultWithSimulationBurstIds(normalizedResult), runId);
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "方案加载成功",
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ChevronRight,
|
||||
FormatListBulleted,
|
||||
} 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 AnalysisParameters, {
|
||||
createDMALeakAnalysisParametersState,
|
||||
@@ -50,6 +50,7 @@ const DMA_AREA_INDEX_PROPERTY = "dma_area_index";
|
||||
|
||||
const DMALeakDetectionPanel: React.FC = () => {
|
||||
const map = useMap();
|
||||
const setSchemeRunId = useData()?.setSchemeRunId;
|
||||
const [open, setOpen] = useState(true);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [result, setResult] = useState<LeakageResultDetail | null>(null);
|
||||
@@ -79,15 +80,23 @@ const DMALeakDetectionPanel: React.FC = () => {
|
||||
[activeAreas.length],
|
||||
);
|
||||
|
||||
const handleAnalysisResult = useCallback((res: LeakageResultDetail) => {
|
||||
setResult(res);
|
||||
}, []);
|
||||
const handleAnalysisResult = useCallback(
|
||||
(res: LeakageResultDetail) => {
|
||||
setSchemeRunId?.("");
|
||||
setResult(res);
|
||||
},
|
||||
[setSchemeRunId],
|
||||
);
|
||||
|
||||
const handleViewResult = useCallback((res: LeakageResultDetail) => {
|
||||
setResult(res);
|
||||
setLoadedResult(res);
|
||||
setTab(2);
|
||||
}, []);
|
||||
const handleViewResult = useCallback(
|
||||
(res: LeakageResultDetail, runId: string) => {
|
||||
setSchemeRunId?.(runId);
|
||||
setResult(res);
|
||||
setLoadedResult(res);
|
||||
setTab(2);
|
||||
},
|
||||
[setSchemeRunId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
@@ -27,13 +27,17 @@ import {
|
||||
listAnalysisSchemes,
|
||||
} from "@/lib/analysisRuns";
|
||||
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 { useSchemeCreatorName } from "@components/olmap/core/useSchemeCreatorName";
|
||||
import { SchemeQueryEmptyState } from "@components/olmap/common/PanelEmptyState";
|
||||
|
||||
interface Props {
|
||||
onViewResult: (result: LeakageResultDetail) => void;
|
||||
onViewResult: (result: LeakageResultDetail, runId: string) => void;
|
||||
schemes?: LeakageSchemeRecord[];
|
||||
onSchemesChange?: (schemes: LeakageSchemeRecord[]) => void;
|
||||
state?: DMALeakSchemeQueryState;
|
||||
@@ -131,14 +135,18 @@ const SchemeQuery: React.FC<Props> = ({
|
||||
if (!result) {
|
||||
throw new Error("方案详情缺少漏损识别结果");
|
||||
}
|
||||
onViewResult({
|
||||
...result,
|
||||
scheme_name: scheme.scheme_name,
|
||||
scheme_detail: scheme.scheme_detail,
|
||||
scheme_start_time: scheme.scheme_start_time,
|
||||
create_time: scheme.create_time,
|
||||
username: scheme.username,
|
||||
} as LeakageResultDetail);
|
||||
onViewResult(
|
||||
{
|
||||
...result,
|
||||
rows: normalizeLeakageRows(result),
|
||||
scheme_name: scheme.scheme_name,
|
||||
scheme_detail: scheme.scheme_detail,
|
||||
scheme_start_time: scheme.scheme_start_time,
|
||||
create_time: scheme.create_time,
|
||||
username: scheme.username,
|
||||
} as LeakageResultDetail,
|
||||
runId,
|
||||
);
|
||||
} catch (error: any) {
|
||||
open?.({
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
scheme_id: string;
|
||||
scheme_name: string;
|
||||
|
||||
@@ -31,9 +31,12 @@ const run = {
|
||||
describe("sensor placement runs API adapter", () => {
|
||||
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({
|
||||
data: { items: [run], total: 1, limit: 1000, offset: 0 },
|
||||
data,
|
||||
});
|
||||
|
||||
await expect(listSensorPlacementSchemes()).resolves.toEqual([
|
||||
|
||||
@@ -26,6 +26,13 @@ type SensorPlacementRunResponse = {
|
||||
can_edit: boolean;
|
||||
};
|
||||
|
||||
type SensorPlacementRunPage = {
|
||||
items: SensorPlacementRunResponse[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
const toSensorPlacementScheme = (
|
||||
run: SensorPlacementRunResponse,
|
||||
): SensorPlacementScheme => ({
|
||||
@@ -53,12 +60,16 @@ export const optimizeSensorPlacement = async (
|
||||
export const listSensorPlacementSchemes = async (): Promise<
|
||||
SensorPlacementScheme[]
|
||||
> => {
|
||||
const response = await api.get<{
|
||||
items: SensorPlacementRunResponse[];
|
||||
}>(`${config.BACKEND_URL}/api/v1/sensor-placement-runs`, {
|
||||
params: { limit: 1000, offset: 0 },
|
||||
});
|
||||
return response.data.items.map(toSensorPlacementScheme);
|
||||
const response = await api.get<
|
||||
SensorPlacementRunPage | SensorPlacementRunResponse[]
|
||||
>(
|
||||
`${config.BACKEND_URL}/api/v1/sensor-placement-runs`,
|
||||
{ params: { limit: 1000, offset: 0 } },
|
||||
);
|
||||
const runs = Array.isArray(response.data)
|
||||
? response.data
|
||||
: response.data.items;
|
||||
return runs.map(toSensorPlacementScheme);
|
||||
};
|
||||
|
||||
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][];
|
||||
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
|
||||
type?: "realtime" | "scheme" | "none";
|
||||
/** 策略类型 */
|
||||
scheme_type?: string;
|
||||
/** 策略名称 */
|
||||
scheme_name?: string;
|
||||
/** 默认展示的选项卡 */
|
||||
defaultTab?: "chart" | "table";
|
||||
/** Y 轴数值的小数位数 */
|
||||
@@ -65,6 +61,8 @@ export interface SCADADataPanelProps {
|
||||
start_time?: string;
|
||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||
end_time?: string;
|
||||
/** 方案分析运行 ID;方案数据必须显式指定,避免读取到其他页面残留的全局方案。 */
|
||||
runId?: string;
|
||||
/** 关闭面板 */
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -73,6 +71,10 @@ type PanelTab = "chart" | "table";
|
||||
|
||||
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 = {
|
||||
color: "primary.contrastText",
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
@@ -81,51 +83,126 @@ const panelHeaderActionSx = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 从后端 API 获取 SCADA 数据
|
||||
*/
|
||||
const fetchFromBackend = async (
|
||||
const buildApiUrl = (
|
||||
path: string,
|
||||
params: Record<string, string | boolean>,
|
||||
) => {
|
||||
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][],
|
||||
range: { from: Date; to: Date },
|
||||
type: "realtime" | "scheme" | "none",
|
||||
scheme_type?: string,
|
||||
scheme_name?: string
|
||||
schemeRunId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TimeSeriesPoint[]> => {
|
||||
if (featureInfos.length === 0) {
|
||||
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 featureIds = featureInfos.map(([id]) => id);
|
||||
const uniqueFeatureInfos = Array.from(
|
||||
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 end_time = dayjs(range.to).toISOString();
|
||||
|
||||
// 将 featureInfos 转换为后端期望的格式: id1:type1,id2:type2
|
||||
const feature_infos = featureInfos
|
||||
const feature_infos = uniqueFeatureInfos
|
||||
.map(([id, type]) => `${id}:${type}`)
|
||||
.join(",");
|
||||
|
||||
// 监测值数据接口(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/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/timeseries/views/element-simulations?feature_infos=${feature_infos}&start_time=${start_time}&end_time=${end_time}`;
|
||||
// 策略模拟数据接口
|
||||
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}`;
|
||||
const fetchElementScadaData = async (useCleaned: boolean) => {
|
||||
const results = await mapWithConcurrency(
|
||||
featureIds,
|
||||
HISTORY_SCADA_CONCURRENCY,
|
||||
(elementId) =>
|
||||
fetchOptionalJson(
|
||||
buildApiUrl("/api/v1/timeseries/views/element-scada-readings", {
|
||||
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 {
|
||||
if (type === "none") {
|
||||
// 查询清洗值和监测值
|
||||
const [cleanedRes, rawRes] = await Promise.all([
|
||||
apiFetch(cleanedDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(rawDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
@@ -143,18 +220,10 @@ const fetchFromBackend = async (
|
||||
} else if (type === "scheme") {
|
||||
// 查询策略模拟值、实时模拟值、清洗值和监测值
|
||||
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
|
||||
apiFetch(cleanedDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(rawDataUrl)
|
||||
.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),
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
fetchOptionalJson(simulationDataUrl, signal),
|
||||
fetchOptionalJson(schemeSimulationDataUrl!, signal),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
@@ -176,15 +245,9 @@ const fetchFromBackend = async (
|
||||
} else {
|
||||
// realtime: 查询模拟值、清洗值和监测值
|
||||
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
||||
apiFetch(cleanedDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(rawDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
apiFetch(simulationDataUrl)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
fetchElementScadaData(true),
|
||||
fetchElementScadaData(false),
|
||||
fetchOptionalJson(simulationDataUrl, signal),
|
||||
]);
|
||||
|
||||
const cleanedData = transformBackendData(cleanedRes, featureIds);
|
||||
@@ -425,12 +488,11 @@ const emptyStateMessages: Record<
|
||||
const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
featureInfos,
|
||||
type = "none",
|
||||
scheme_type = "burst_analysis",
|
||||
scheme_name,
|
||||
defaultTab = "chart",
|
||||
fractionDigits = 2,
|
||||
start_time,
|
||||
end_time,
|
||||
runId,
|
||||
onClose,
|
||||
}) => {
|
||||
// 从 featureInfos 中提取设备 ID 列表
|
||||
@@ -465,6 +527,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
"raw" | "clean" | "sim" | "all"
|
||||
>(() => (featureInfos.length === 1 ? "all" : "clean"));
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(defaultTab);
|
||||
@@ -503,6 +566,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const handleFetch = useCallback(
|
||||
async (reason: string) => {
|
||||
if (!hasDevices) {
|
||||
requestControllerRef.current?.abort();
|
||||
setTimeSeries([]);
|
||||
setLoadingState("idle");
|
||||
setError(null);
|
||||
@@ -511,26 +575,43 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
|
||||
setLoadingState("loading");
|
||||
setError(null);
|
||||
requestControllerRef.current?.abort();
|
||||
const requestController = new AbortController();
|
||||
requestControllerRef.current = requestController;
|
||||
try {
|
||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||
const result = await fetchFromBackend(
|
||||
const result = await fetchHistoryData(
|
||||
featureInfos,
|
||||
{
|
||||
from: rangeFrom.toDate(),
|
||||
to: rangeTo.toDate(),
|
||||
},
|
||||
type,
|
||||
scheme_type,
|
||||
scheme_name
|
||||
runId,
|
||||
requestController.signal,
|
||||
);
|
||||
if (requestControllerRef.current !== requestController) return;
|
||||
setTimeSeries(result);
|
||||
setLoadingState("success");
|
||||
} catch (err) {
|
||||
if (
|
||||
requestControllerRef.current !== requestController ||
|
||||
(err instanceof Error && err.name === "AbortError")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "未知错误");
|
||||
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<
|
||||
"realtime" | "scheme" | "none"
|
||||
>("none");
|
||||
const [chatPanelRunId, setChatPanelRunId] = useState<string | null>(null);
|
||||
const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
@@ -142,6 +143,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
setHighlightFeatures,
|
||||
setChatPanelFeatureInfos,
|
||||
setChatPanelType,
|
||||
setChatPanelRunId,
|
||||
setChatPanelTimeRange,
|
||||
setShowHistoryPanel,
|
||||
setShowStyleEditor,
|
||||
@@ -349,6 +351,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
setHighlightFeatures([]);
|
||||
}
|
||||
setChatPanelFeatureInfos(null);
|
||||
setChatPanelRunId(null);
|
||||
setChatPanelTimeRange(null);
|
||||
break;
|
||||
}
|
||||
@@ -934,12 +937,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<ToolbarHistoryPanel
|
||||
showHistoryPanel={showHistoryPanel}
|
||||
chatPanelType={chatPanelType}
|
||||
chatPanelRunId={chatPanelRunId}
|
||||
chatPanelFeatureInfos={chatPanelFeatureInfos}
|
||||
chatPanelTimeRange={chatPanelTimeRange}
|
||||
highlightFeatures={highlightFeatures}
|
||||
HistoryPanel={HistoryPanel}
|
||||
schemeName={schemeName}
|
||||
queryType={queryType}
|
||||
schemeRunId={schemeRunId}
|
||||
onClose={() => {
|
||||
deactivateTool("history");
|
||||
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
||||
|
||||
@@ -10,6 +10,7 @@ import HistoryDataPanel from "./HistoryDataPanel";
|
||||
type ToolbarHistoryPanelProps = {
|
||||
showHistoryPanel: boolean;
|
||||
chatPanelType: "realtime" | "scheme" | "none";
|
||||
chatPanelRunId: string | null;
|
||||
chatPanelFeatureInfos: [string, string][] | null;
|
||||
chatPanelTimeRange: {
|
||||
startTime?: string;
|
||||
@@ -19,18 +20,21 @@ type ToolbarHistoryPanelProps = {
|
||||
HistoryPanel?: React.ComponentType<any>;
|
||||
schemeName?: string;
|
||||
queryType?: string;
|
||||
schemeRunId?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
||||
showHistoryPanel,
|
||||
chatPanelType,
|
||||
chatPanelRunId,
|
||||
chatPanelFeatureInfos,
|
||||
chatPanelTimeRange,
|
||||
highlightFeatures,
|
||||
HistoryPanel,
|
||||
schemeName,
|
||||
queryType,
|
||||
schemeRunId,
|
||||
onClose,
|
||||
}) => {
|
||||
const featureInfos = useMemo(
|
||||
@@ -75,8 +79,6 @@ const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
||||
return (
|
||||
<HistoryDataPanel
|
||||
featureInfos={featureInfos}
|
||||
scheme_type="burst_analysis"
|
||||
scheme_name={schemeName}
|
||||
type={
|
||||
chatPanelFeatureInfos
|
||||
? chatPanelType
|
||||
@@ -84,6 +86,7 @@ const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
|
||||
}
|
||||
start_time={chatPanelTimeRange?.startTime}
|
||||
end_time={chatPanelTimeRange?.endTime}
|
||||
runId={chatPanelFeatureInfos ? chatPanelRunId ?? undefined : schemeRunId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ type UseToolbarChatActionsParams = {
|
||||
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
|
||||
setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>;
|
||||
setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>;
|
||||
setChatPanelRunId: Dispatch<SetStateAction<string | null>>;
|
||||
setChatPanelTimeRange: Dispatch<
|
||||
SetStateAction<{ startTime?: string; endTime?: string } | null>
|
||||
>;
|
||||
@@ -37,6 +38,7 @@ export const useToolbarChatActions = ({
|
||||
setHighlightFeatures,
|
||||
setChatPanelFeatureInfos,
|
||||
setChatPanelType,
|
||||
setChatPanelRunId,
|
||||
setChatPanelTimeRange,
|
||||
setShowHistoryPanel,
|
||||
setShowStyleEditor,
|
||||
@@ -126,6 +128,7 @@ export const useToolbarChatActions = ({
|
||||
case "view_history": {
|
||||
setChatPanelFeatureInfos(action.featureInfos);
|
||||
setChatPanelType(action.dataType);
|
||||
setChatPanelRunId(action.runId ?? null);
|
||||
setChatPanelTimeRange({
|
||||
startTime: action.startTime,
|
||||
endTime: action.endTime,
|
||||
@@ -136,6 +139,7 @@ export const useToolbarChatActions = ({
|
||||
case "view_scada": {
|
||||
setChatPanelFeatureInfos(action.featureInfos);
|
||||
setChatPanelType("none");
|
||||
setChatPanelRunId(null);
|
||||
setChatPanelTimeRange({
|
||||
startTime: action.startTime,
|
||||
endTime: action.endTime,
|
||||
@@ -249,6 +253,7 @@ export const useToolbarChatActions = ({
|
||||
setChatPanelFeatureInfos,
|
||||
setChatPanelTimeRange,
|
||||
setChatPanelType,
|
||||
setChatPanelRunId,
|
||||
setHighlightFeatures,
|
||||
setShowHistoryPanel,
|
||||
setShowStyleEditor,
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("ProjectProvider authentication boundary", () => {
|
||||
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();
|
||||
mockUseSession.mockReturnValue({ status: "authenticated" });
|
||||
|
||||
@@ -95,7 +95,10 @@ describe("ProjectProvider authentication boundary", () => {
|
||||
</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(
|
||||
"a2d67c84-fd9d-4feb-a500-c357244b2760",
|
||||
);
|
||||
|
||||
@@ -73,14 +73,6 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
setIsConfigured(true);
|
||||
|
||||
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(
|
||||
`${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 (savedWorkspace && savedNetwork) {
|
||||
applyConfig(
|
||||
savedProjectId || savedNetwork || savedWorkspace,
|
||||
savedWorkspace,
|
||||
savedNetwork,
|
||||
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
|
||||
void Promise.resolve().then(() =>
|
||||
applyConfig(
|
||||
savedProjectId || savedNetwork || savedWorkspace,
|
||||
savedWorkspace,
|
||||
savedNetwork,
|
||||
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [applyConfig, status]);
|
||||
|
||||
+275
-1601
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ export type ChatToolAction =
|
||||
type: "view_history";
|
||||
featureInfos: [string, string][];
|
||||
dataType: "realtime" | "scheme" | "none";
|
||||
runId?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user