Compare commits

..
Author SHA1 Message Date
jiang b04378397c test: type logout audit fetch mock
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-09-09 15:07:45 +08:00
jiang bad8769000 fix(auth): redirect expired sessions before permission checks 2026-09-09 15:07:25 +08:00
jiang 29b8babd68 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.
2026-09-01 14:37:51 +08:00
33 changed files with 1657 additions and 3501 deletions
+5
View File
@@ -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.
+1 -1
View File
@@ -7,7 +7,7 @@
}, },
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "404a196c0177faed2aa5b46ee86430a034dfe990e0a77a43428a727748a882b6" "sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import { RoutePermissionGuard } from "./RoutePermissionGuard";
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
}));
describe("RoutePermissionGuard", () => {
beforeEach(() => {
useAccessStore.setState({
context: null,
permissions: [],
loading: false,
});
useAuthStore.setState({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("prioritizes an expired session over a missing route permission", () => {
useAuthStore.setState({
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
render(
<RoutePermissionGuard>
<div></div>
</RoutePermissionGuard>,
);
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.getByText("正在跳转到登录页面…")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
expect(screen.queryByText(/simulation\.view/)).not.toBeInTheDocument();
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
});
it("shows the permission error when the session is still valid", () => {
render(
<RoutePermissionGuard>
<div></div>
</RoutePermissionGuard>,
);
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
});
});
@@ -7,6 +7,7 @@ import type { ReactNode } from "react";
import { permissionForPath } from "@/lib/permissions"; import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore"; import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({ export const RoutePermissionGuard = ({
children, children,
@@ -16,8 +17,24 @@ export const RoutePermissionGuard = ({
const pathname = usePathname(); const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions); const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading); const loading = useAccessStore((state) => state.loading);
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const requiredPermission = permissionForPath(pathname); const requiredPermission = permissionForPath(pathname);
if (sessionExpired) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="warning">
<Stack spacing={0.5}>
<Typography variant="body1"></Typography>
<Typography variant="body2">
</Typography>
</Stack>
</Alert>
</Box>
);
}
if (requiredPermission && loading) { if (requiredPermission && loading) {
return ( return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}> <Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
@@ -1,5 +1,6 @@
import { createTheme, ThemeProvider } from "@mui/material/styles"; import { createTheme, ThemeProvider } from "@mui/material/styles";
import { act, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { SessionExpiryDialog } from "./SessionExpiryDialog"; import { SessionExpiryDialog } from "./SessionExpiryDialog";
@@ -10,6 +11,8 @@ jest.mock("next-auth/react", () => ({
describe("SessionExpiryDialog", () => { describe("SessionExpiryDialog", () => {
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
jest.mocked(signIn).mockReset().mockResolvedValue(undefined);
useAuthStore.setState({ useAuthStore.setState({
accessToken: null, accessToken: null,
sessionExpired: true, sessionExpired: true,
@@ -19,6 +22,7 @@ describe("SessionExpiryDialog", () => {
afterEach(() => { afterEach(() => {
act(() => useAuthStore.getState().clearSessionExpired()); act(() => useAuthStore.getState().clearSessionExpired());
jest.useRealTimers();
}); });
it("renders above every regular application overlay", () => { it("renders above every regular application overlay", () => {
@@ -36,4 +40,40 @@ describe("SessionExpiryDialog", () => {
zIndex: theme.zIndex.tooltip + 1, zIndex: theme.zIndex.tooltip + 1,
}); });
}); });
it("shows the expired state before automatically starting login", () => {
window.history.replaceState({}, "", "/network-simulation?tab=history");
render(<SessionExpiryDialog />);
expect(screen.getByText("登录已过期")).toBeInTheDocument();
expect(screen.getByText("即将自动跳转到登录页面。"))
.toBeInTheDocument();
expect(signIn).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(2_000);
});
expect(signIn).toHaveBeenCalledTimes(1);
expect(signIn).toHaveBeenCalledWith("keycloak", {
callbackUrl: "/network-simulation?tab=history",
redirect: true,
});
});
it("allows a retry when starting automatic login fails", async () => {
jest.mocked(signIn)
.mockRejectedValueOnce(new Error("network unavailable"))
.mockResolvedValueOnce(undefined);
render(<SessionExpiryDialog />);
await act(async () => {
jest.advanceTimersByTime(2_000);
});
fireEvent.click(screen.getByRole("button", { name: "重新认证" }));
expect(signIn).toHaveBeenCalledTimes(2);
});
}); });
+28 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined"; import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
import { import {
@@ -18,6 +18,7 @@ import { useTheme } from "@mui/material/styles";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
const WARNING_WINDOW_MS = 15 * 60 * 1000; const WARNING_WINDOW_MS = 15 * 60 * 1000;
const EXPIRED_REDIRECT_DELAY_MS = 2_000;
type SessionExpiryDialogProps = { type SessionExpiryDialogProps = {
expiresAt?: number; expiresAt?: number;
@@ -31,6 +32,7 @@ export const SessionExpiryDialog = ({
const reason = useAuthStore((state) => state.sessionExpiryReason); const reason = useAuthStore((state) => state.sessionExpiryReason);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const [warningDismissed, setWarningDismissed] = useState(false); const [warningDismissed, setWarningDismissed] = useState(false);
const redirectStartedRef = useRef(false);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 30_000); const timer = window.setInterval(() => setNow(Date.now()), 30_000);
@@ -47,10 +49,27 @@ export const SessionExpiryDialog = ({
[expiresAt, now, sessionExpired, warningDismissed], [expiresAt, now, sessionExpired, warningDismissed],
); );
const handleReauthenticate = () => { const handleReauthenticate = useCallback(() => {
if (redirectStartedRef.current) return;
redirectStartedRef.current = true;
const callbackUrl = `${window.location.pathname}${window.location.search}`; const callbackUrl = `${window.location.pathname}${window.location.search}`;
void signIn("keycloak", { callbackUrl, redirect: true }); void signIn("keycloak", { callbackUrl, redirect: true }).catch(() => {
}; redirectStartedRef.current = false;
});
}, []);
useEffect(() => {
if (!sessionExpired) {
redirectStartedRef.current = false;
return;
}
const timer = window.setTimeout(
handleReauthenticate,
EXPIRED_REDIRECT_DELAY_MS,
);
return () => window.clearTimeout(timer);
}, [handleReauthenticate, sessionExpired]);
const isOpen = sessionExpired || isExpiringSoon; const isOpen = sessionExpired || isExpiringSoon;
const title = sessionExpired ? "登录已过期" : "登录即将到期"; const title = sessionExpired ? "登录已过期" : "登录即将到期";
@@ -77,6 +96,11 @@ export const SessionExpiryDialog = ({
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
</Typography> </Typography>
{sessionExpired && (
<Typography variant="body2" color="text.secondary">
</Typography>
)}
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
+2 -11
View File
@@ -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,
});
});
});
+43
View File
@@ -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(
(payload: BurstDetectionResult) => {
setSchemeRunId?.("");
setResult(payload); setResult(payload);
setTab(2); 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(
(payload: BurstLocationResult) => {
setSchemeRunId?.("");
setResult(payload); setResult(payload);
setTab(2); setTab(2);
}, []); },
[setSchemeRunId],
);
const handleViewResult = useCallback((payload: BurstLocationResult) => { const handleViewResult = useCallback(
(payload: BurstLocationResult, runId: string) => {
setSchemeRunId?.(runId);
setResult(payload); setResult(payload);
setTab(2); 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(
(res: LeakageResultDetail) => {
setSchemeRunId?.("");
setResult(res); setResult(res);
}, []); },
[setSchemeRunId],
);
const handleViewResult = useCallback((res: LeakageResultDetail) => { const handleViewResult = useCallback(
(res: LeakageResultDetail, runId: string) => {
setSchemeRunId?.(runId);
setResult(res); setResult(res);
setLoadedResult(res); setLoadedResult(res);
setTab(2); 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, ...result,
rows: normalizeLeakageRows(result),
scheme_name: scheme.scheme_name, scheme_name: scheme.scheme_name,
scheme_detail: scheme.scheme_detail, scheme_detail: scheme.scheme_detail,
scheme_start_time: scheme.scheme_start_time, scheme_start_time: scheme.scheme_start_time,
create_time: scheme.create_time, create_time: scheme.create_time,
username: scheme.username, username: scheme.username,
} as LeakageResultDetail); } 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,
+5 -2
View File
@@ -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",
); );
+2 -8
View File
@@ -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) {
void Promise.resolve().then(() =>
applyConfig( applyConfig(
savedProjectId || savedNetwork || savedWorkspace, savedProjectId || savedNetwork || savedWorkspace,
savedWorkspace, savedWorkspace,
savedNetwork, savedNetwork,
savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT, savedExtent ? savedExtent.split(",").map(Number) : config.MAP_EXTENT,
),
); );
} }
}, [applyConfig, status]); }, [applyConfig, status]);
+275 -1601
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -2,12 +2,15 @@ import { completeLogout, reportLogoutAudit } from "./logoutFlow";
describe("reportLogoutAudit", () => { describe("reportLogoutAudit", () => {
it("starts an authenticated keepalive request", async () => { it("starts an authenticated keepalive request", async () => {
const fetcher = jest.fn(async () => ({ ok: true }) as Response); const fetcher = jest.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
({ ok: true }) as Response,
);
await reportLogoutAudit({ await reportLogoutAudit({
endpoint: "https://server.example/api/v1/audit-events", endpoint: "https://server.example/api/v1/audit-events",
accessToken: "access-token", accessToken: "access-token",
fetcher, fetcher: fetcher as typeof fetch,
}); });
expect(fetcher).toHaveBeenCalledTimes(1); expect(fetcher).toHaveBeenCalledTimes(1);
+1
View File
@@ -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;
} }