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:
2026-09-01 14:37:51 +08:00
parent 0dad61ff1f
commit 29b8babd68
28 changed files with 1512 additions and 3494 deletions
@@ -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,