Compare commits

..
Author SHA1 Message Date
jiang cd9d04a370 feat(history): merge event simulation history with interpolation
Generic Container CI/CD / test-build-publish (push) Successful in 1m8s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m8s
2026-11-07 12:54:19 +08:00
jiang eff59c5a7e feat(profile): publish stable default avatar asset
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-11-04 15:20:50 +08:00
jiang d98fa359ae fix(auth): redirect expired routes into login flow
The global route guard previously rendered redirect copy without navigating and also blocked the login route itself. Allow the login page through, redirect protected unauthenticated routes, and avoid a /login callback loop.
2026-11-04 15:20:46 +08:00
jiang a191d81c29 fix(ui): remove avatar hover scaling
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 59s
2026-11-04 14:39:16 +08:00
jiang d4c8fc9db8 fix(auth): preserve intentional logout across browser return
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
Keep a bounded per-tab logout intent and broadcast it to already open tabs so back navigation after a normal logout is not mistaken for renewal failure. Preserve real refresh and session expiry signals, and keep IdP navigation reachable when local cleanup fails.
2026-11-04 14:34:18 +08:00
jiang 95ec090f2e fix(ui): keep account avatar static 2026-11-04 14:34:01 +08:00
17 changed files with 654 additions and 30 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+101 -1
View File
@@ -1,14 +1,20 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { signIn } from "next-auth/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useAccessStore } from "@/store/accessStore"; import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import {
clearIntentionalLogout,
markIntentionalLogout,
} from "@/lib/logoutIntent";
import { App } from "./RefineContext"; import { App } from "./RefineContext";
let mockSessionState: { let mockSessionState: {
data: { data: {
accessToken: string; accessToken: string;
user: { id: string; name: string }; user: { id: string; name: string };
error?: "RefreshAccessTokenError" | "SessionExpired";
} | null; } | null;
status: "authenticated" | "unauthenticated"; status: "authenticated" | "unauthenticated";
} = { } = {
@@ -20,6 +26,8 @@ let mockSessionState: {
}; };
let mockPathname = "/network-simulation"; let mockPathname = "/network-simulation";
let mockProjectProviderBlocks = false; let mockProjectProviderBlocks = false;
let capturedLogin: (() => Promise<unknown>) | null = null;
const replaceMock = jest.fn();
jest.mock("next-auth/react", () => ({ jest.mock("next-auth/react", () => ({
SessionProvider: ({ children }: { children: ReactNode }) => children, SessionProvider: ({ children }: { children: ReactNode }) => children,
@@ -29,10 +37,20 @@ jest.mock("next-auth/react", () => ({
jest.mock("next/navigation", () => ({ jest.mock("next/navigation", () => ({
usePathname: () => mockPathname, usePathname: () => mockPathname,
useRouter: () => ({ replace: replaceMock }),
})); }));
jest.mock("@refinedev/core", () => ({ jest.mock("@refinedev/core", () => ({
Refine: ({ children }: { children: ReactNode }) => children, Refine: ({
children,
authProvider,
}: {
children: ReactNode;
authProvider: { login: (params: Record<string, never>) => Promise<unknown> };
}) => {
capturedLogin = () => authProvider.login({});
return children;
},
})); }));
jest.mock("@refinedev/kbar", () => ({ jest.mock("@refinedev/kbar", () => ({
@@ -70,8 +88,12 @@ describe("RefineContext access authentication", () => {
const originalRequest = global.Request; const originalRequest = global.Request;
beforeEach(() => { beforeEach(() => {
clearIntentionalLogout();
mockPathname = "/network-simulation"; mockPathname = "/network-simulation";
mockProjectProviderBlocks = false; mockProjectProviderBlocks = false;
capturedLogin = null;
replaceMock.mockReset();
jest.mocked(signIn).mockClear();
mockSessionState = { mockSessionState = {
data: { data: {
accessToken: "expired-access-token", accessToken: "expired-access-token",
@@ -210,4 +232,82 @@ describe("RefineContext access authentication", () => {
}); });
}); });
}); });
it("starts login with a safe post-authentication callback from the login route", async () => {
mockPathname = "/login";
mockSessionState = { data: null, status: "unauthenticated" };
render(
<App>
<div>登录页面</div>
</App>,
);
expect(capturedLogin).not.toBeNull();
await capturedLogin!();
expect(signIn).toHaveBeenCalledWith("keycloak", {
callbackUrl: "/",
redirect: true,
});
});
it("does not treat returning to a protected route after logout as session expiry", async () => {
markIntentionalLogout();
mockSessionState = { data: null, status: "unauthenticated" };
useAuthStore.setState({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
render(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false,
sessionExpiryReason: null,
});
});
});
it("does not hide a real refresh failure after an intentional logout", async () => {
markIntentionalLogout();
mockSessionState = {
data: {
accessToken: "expired-access-token",
user: { id: "user-1", name: "Test User" },
error: "RefreshAccessTokenError",
},
status: "authenticated",
};
const { rerender } = render(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
});
mockSessionState = { data: null, status: "unauthenticated" };
rerender(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState().sessionExpired).toBe(true);
});
});
}); });
+9 -2
View File
@@ -24,6 +24,10 @@ import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow"; import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
import {
clearIntentionalLogout,
isIntentionalLogout,
} from "@/lib/logoutIntent";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft"; import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions"; import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config"; import { config } from "@config/config";
@@ -88,15 +92,17 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
useEffect(() => { useEffect(() => {
if (data?.error === "SessionExpired") { if (data?.error === "SessionExpired") {
clearIntentionalLogout();
markSessionExpired("session_max_age"); markSessionExpired("session_max_age");
return; return;
} }
if (data?.error === "RefreshAccessTokenError") { if (data?.error === "RefreshAccessTokenError") {
clearIntentionalLogout();
markSessionExpired("refresh_failed"); markSessionExpired("refresh_failed");
return; return;
} }
if (status === "unauthenticated") { if (status === "unauthenticated") {
if (to === "/login") { if (to === "/login" || isIntentionalLogout()) {
clearSessionExpired(); clearSessionExpired();
return; return;
} }
@@ -104,6 +110,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
return; return;
} }
if (status === "authenticated") { if (status === "authenticated") {
clearIntentionalLogout();
clearSessionExpired(); clearSessionExpired();
} }
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]); }, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
@@ -207,7 +214,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const authProvider: AuthProvider = { const authProvider: AuthProvider = {
login: async () => { login: async () => {
signIn("keycloak", { signIn("keycloak", {
callbackUrl: to ? to.toString() : "/", callbackUrl: to && to !== "/login" ? to.toString() : "/",
redirect: true, redirect: true,
}); });
return { success: true }; return { success: true };
@@ -4,8 +4,12 @@ import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { RoutePermissionGuard } from "./RoutePermissionGuard"; import { RoutePermissionGuard } from "./RoutePermissionGuard";
const replaceMock = jest.fn();
let mockPathname = "/network-simulation";
jest.mock("next/navigation", () => ({ jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation", usePathname: () => mockPathname,
useRouter: () => ({ replace: replaceMock }),
})); }));
jest.mock("@/components/header", () => ({ jest.mock("@/components/header", () => ({
@@ -16,6 +20,8 @@ jest.mock("@/components/header", () => ({
describe("RoutePermissionGuard", () => { describe("RoutePermissionGuard", () => {
beforeEach(() => { beforeEach(() => {
replaceMock.mockReset();
mockPathname = "/network-simulation";
useAccessStore.setState({ useAccessStore.setState({
context: null, context: null,
permissions: [], permissions: [],
@@ -86,7 +92,7 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByTestId("access-header")).toHaveTextContent("project"); expect(screen.getByTestId("access-header")).toHaveTextContent("project");
}); });
it("checks authentication before the expired state effect runs", () => { it("redirects an unauthenticated protected route to login", () => {
render( render(
<RoutePermissionGuard authenticated={false}> <RoutePermissionGuard authenticated={false}>
<div>受保护内容</div> <div>受保护内容</div>
@@ -95,5 +101,20 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByText("登录状态已失效")).toBeInTheDocument(); expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument(); expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith("/login");
});
it("allows the login route to render while unauthenticated", () => {
mockPathname = "/login";
render(
<RoutePermissionGuard authenticated={false}>
<div>登录页面</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("登录页面")).toBeInTheDocument();
expect(screen.queryByText("登录状态已失效")).not.toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
}); });
}); });
+14 -2
View File
@@ -8,8 +8,8 @@ import {
Stack, Stack,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react"; import { type ReactNode, useEffect } from "react";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { permissionForPath } from "@/lib/permissions"; import { permissionForPath } from "@/lib/permissions";
@@ -26,10 +26,22 @@ export const RoutePermissionGuard = ({
accessDeniedReason?: "account" | "project" | null; accessDeniedReason?: "account" | "project" | null;
}) => { }) => {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
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 sessionExpired = useAuthStore((state) => state.sessionExpired);
const requiredPermission = permissionForPath(pathname); const requiredPermission = permissionForPath(pathname);
const isLoginRoute = pathname === "/login";
useEffect(() => {
if (!authenticated && !isLoginRoute) {
router.replace("/login");
}
}, [authenticated, isLoginRoute, router]);
if (isLoginRoute) {
return children;
}
if (!authenticated || sessionExpired) { if (!authenticated || sessionExpired) {
return ( return (
+1 -1
View File
@@ -191,7 +191,7 @@ export const Header: React.FC<HeaderProps> = ({
}`, }`,
transition: "transform 0.3s ease", transition: "transform 0.3s ease",
".MuiButtonBase-root:hover &": { ".MuiButtonBase-root:hover &": {
transform: "rotate(5deg) scale(1.05)", transform: "rotate(5deg)",
borderColor: "primary.main", borderColor: "primary.main",
}, },
}} }}
@@ -126,10 +126,10 @@ describe("fetchHistoryData", () => {
] }); ] });
}); });
const result = await fetchHistoryData(targets, range, "scheme", "run-1"); const result = await fetchHistoryData(targets, range, "scheme", "run-1");
expect(result.series).toHaveLength(4); expect(result.series).toHaveLength(3);
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 }); expect(result.points[0].values).toEqual({ '["pipe","P-1","event_simulation"]': 36, '["junction","J-1","event_simulation"]': 30 });
expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72); expect(result.points[1].values['["pipe","P-1","event_simulation"]']).toBe(72);
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2); expect(result.points[2].values['["pipe","P-1","event_simulation"]']).toBeCloseTo(43.2);
expect(apiFetch).toHaveBeenCalledTimes(2); expect(apiFetch).toHaveBeenCalledTimes(2);
for (const [, init] of jest.mocked(apiFetch).mock.calls) { for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2); expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
@@ -317,7 +317,7 @@ describe("HistoryDataPanel requests", () => {
expect(getAnalysisScheme).toHaveBeenCalledTimes(2); expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
}); });
it("plots native timestamps and explicit gaps with the event interval marked", async () => { it("plots a single interpolated event curve with the event interval marked", async () => {
const series = { const series = {
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow", element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false, source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
@@ -340,16 +340,21 @@ describe("HistoryDataPanel requests", () => {
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any; const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") }); expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
expect(option.series[0].connectNulls).toBe(false); expect(option.series[0].connectNulls).toBe(false);
expect(option.series[0].data).toEqual([ expect(option.series).toHaveLength(1);
[Date.parse("2026-08-17T08:00:00Z"), 10], expect(option.series[0].name).toBe("管道 P-1 (事件模拟) [m³/h]");
[Date.parse("2026-08-17T09:00:00Z"), null], expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T08:00:00Z"), 10]);
[Date.parse("2026-08-17T11:00:00Z"), 12], expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:00:00Z"), 8.4]);
]); expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:15:00Z"), 8]);
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T11:00:00Z"), 12]);
expect(option.series[0].markArea.data[0]).toEqual([ expect(option.series[0].markArea.data[0]).toEqual([
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") }, { name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
{ xAxis: Date.parse("2026-08-17T10:00:00Z") }, { xAxis: Date.parse("2026-08-17T10:00:00Z") },
]); ]);
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]); expect(screen.queryByText(/当前时段暂无在线模拟数据/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "表格" }));
expect(await screen.findByRole("columnheader", { name: "管道 P-1 (事件模拟) [m³/h]" })).toBeInTheDocument();
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
expect(screen.getByRole("gridcell", { name: "8.40" })).toBeInTheDocument();
}); });
it("snaps off-step event context without changing the actual event marker", async () => { it("snaps off-step event context without changing the actual event marker", async () => {
@@ -40,9 +40,9 @@ import {
fetchElementHistory, fetchElementHistory,
historySeriesKey, historySeriesKey,
historySeriesLabel, historySeriesLabel,
toTimeSeriesPoints,
TimeSeriesPoint, TimeSeriesPoint,
} from "@/lib/elementHistory"; } from "@/lib/elementHistory";
import { mergeEventHistory, type EventHistoryOptions } from "@/lib/eventHistory";
import { import {
type AnalysisHistoryContext, type AnalysisHistoryContext,
getAnalysisHistoryContext, getAnalysisHistoryContext,
@@ -106,6 +106,7 @@ export const fetchHistoryData = async (
type: "realtime" | "scheme" | "none", type: "realtime" | "scheme" | "none",
schemeRunId?: string, schemeRunId?: string,
signal?: AbortSignal, signal?: AbortSignal,
eventOptions?: EventHistoryOptions,
): Promise<ElementHistoryResult> => { ): Promise<ElementHistoryResult> => {
if (featureInfos.length === 0) { if (featureInfos.length === 0) {
return { points: [], series: [] }; return { points: [], series: [] };
@@ -157,7 +158,9 @@ export const fetchHistoryData = async (
...online.series, ...online.series,
...analysis.series.filter((item) => item.source === "analysis_simulation"), ...analysis.series.filter((item) => item.source === "analysis_simulation"),
]; ];
return { series, points: toTimeSeriesPoints(series) }; return mergeEventHistory(series, range, eventOptions ?? {
eventRange: null, stepSeconds: 0,
});
} }
return fetchElementHistory( return fetchElementHistory(
elements, elements,
@@ -308,6 +311,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab); const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]); const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]); const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [hasOnlineSimulationData, setHasOnlineSimulationData] = useState(false);
const [loadingState, setLoadingState] = useState<LoadingState>("idle"); const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSimulating, setIsSimulating] = useState(false); const [isSimulating, setIsSimulating] = useState(false);
@@ -402,6 +406,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
requestControllerRef.current = null; requestControllerRef.current = null;
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]); setHistorySeries([]);
setHasOnlineSimulationData(false);
setLoadingState("idle"); setLoadingState("idle");
setError(null); setError(null);
return; return;
@@ -425,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: type, : type,
schemeContext?.simulationRunId, schemeContext?.simulationRunId,
requestController.signal, requestController.signal,
{ eventRange: eventRange ?? null, stepSeconds },
); );
if ( if (
requestController.signal.aborted || requestController.signal.aborted ||
@@ -432,6 +438,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
) return; ) return;
setTimeSeries(result.points); setTimeSeries(result.points);
setHistorySeries(result.series); setHistorySeries(result.series);
setHasOnlineSimulationData(result.hasOnlineSimulationData ?? result.series.some(
(series) => series.source === "realtime_simulation"
&& series.points.some((point) => point.value !== null && Number.isFinite(point.value)),
));
setLoadingState("success"); setLoadingState("success");
} catch (err) { } catch (err) {
if ( if (
@@ -443,13 +453,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
} }
setTimeSeries([]); setTimeSeries([]);
setHistorySeries([]); setHistorySeries([]);
setHasOnlineSimulationData(false);
setError(err instanceof Error ? err.message : "未知错误"); setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error"); setLoadingState("error");
} }
}, },
[ [
requestedFeatureInfos, hasDevices, normalizedRange, requestedFeatureInfos, hasDevices, normalizedRange,
schemeReady, schemeError, schemeContext, type, schemeReady, schemeError, schemeContext, type, eventRange, stepSeconds,
], ],
); );
@@ -1017,7 +1028,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Typography> </Typography>
)} )}
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" && {!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && ( !hasOnlineSimulationData && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}> <Typography variant="caption" sx={{ mt: 1, display: "block" }}>
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""} 当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
</Typography> </Typography>
+8 -1
View File
@@ -12,7 +12,9 @@ export type HistorySource =
| "scada_raw" | "scada_raw"
| "scada_cleaned" | "scada_cleaned"
| "realtime_simulation" | "realtime_simulation"
| "analysis_simulation"; | "analysis_simulation"
// 前端按事件时段拼接历史在线结果与方案结果。
| "event_simulation";
export interface ElementHistoryTarget { export interface ElementHistoryTarget {
element_id: string; element_id: string;
@@ -40,6 +42,8 @@ export interface TimeSeriesPoint {
export interface ElementHistoryResult { export interface ElementHistoryResult {
points: TimeSeriesPoint[]; points: TimeSeriesPoint[];
series: ElementHistorySeries[]; series: ElementHistorySeries[];
/** 事件序列合并后仍保留在线历史是否存在的信息。 */
hasOnlineSimulationData?: boolean;
} }
const SOURCE_SUFFIX: Record<HistorySource, string> = { const SOURCE_SUFFIX: Record<HistorySource, string> = {
@@ -47,6 +51,7 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
scada_cleaned: "clean", scada_cleaned: "clean",
realtime_simulation: "sim", realtime_simulation: "sim",
analysis_simulation: "scheme_sim", analysis_simulation: "scheme_sim",
event_simulation: "event_sim",
}; };
export const historySeriesKey = (series: ElementHistorySeries) => { export const historySeriesKey = (series: ElementHistorySeries) => {
@@ -64,6 +69,8 @@ export const historySourceLabel = (source: HistorySource) => {
return "实时模拟"; return "实时模拟";
case "analysis_simulation": case "analysis_simulation":
return "方案模拟"; return "方案模拟";
case "event_simulation":
return "事件模拟";
} }
}; };
+106
View File
@@ -0,0 +1,106 @@
import { type ElementHistorySeries, historySeriesKey } from "./elementHistory";
import { mergeEventHistory } from "./eventHistory";
const time = (minutes: number) => new Date(Date.UTC(2026, 7, 17, 8, minutes)).toISOString();
const series = (
source: ElementHistorySeries["source"],
points: Array<[number, number | null]>,
overrides: Partial<ElementHistorySeries> = {},
): ElementHistorySeries => ({
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source, source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
points: points.map(([minutes, value]) => ({ time: time(minutes), value })),
...overrides,
});
const range = { from: new Date(time(0)), to: new Date(time(120)) };
const options = { eventRange: { start: time(38), end: time(83) }, stepSeconds: 900 };
const values = (item: ElementHistorySeries) => Object.fromEntries(
item.points.map((point) => [point.time, point.value]),
);
describe("mergeEventHistory", () => {
it("uses event values only in the event window and interpolates mismatched times and transitions", () => {
const result = mergeEventHistory([
series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [60, 999], [90, 100], [120, 100]]),
series("analysis_simulation", [[23, 999], [38, 80], [53, 60], [68, 40], [83, 80], [98, 999]]),
], range, options);
expect(result.series).toHaveLength(1);
expect(result.series[0].source).toBe("event_simulation");
const merged = values(result.series[0]);
expect(merged[time(15)]).toBe(100);
expect(merged[time(38)]).toBe(80);
expect(merged[time(45)]).toBeCloseTo(70.6666667);
expect(merged[time(60)]).toBeCloseTo(50.6666667);
expect(merged[time(83)]).toBe(80);
expect(merged[time(90)]).toBe(100);
expect(merged[time(105)]).toBe(100);
expect(merged[time(23)]).toBeUndefined();
expect(merged[time(98)]).toBeUndefined();
expect(result.points.find((point) => point.timestamp === time(45))?.values[
historySeriesKey(result.series[0])
]).toBeCloseTo(70.6666667);
});
it("interpolates across the seam when the first event sample starts after the event boundary", () => {
const result = mergeEventHistory([
series("realtime_simulation", [[30, 100], [45, 999], [90, 100]]),
series("analysis_simulation", [[53, 54], [68, 24]]),
], range, options);
const merged = values(result.series[0]);
expect(merged[time(38)]).toBe(84);
expect(merged[time(45)]).toBe(70);
expect(merged[time(75)]).toBeCloseTo(48.181818);
expect(merged[time(0)]).toBeNull();
expect(merged[time(120)]).toBeNull();
});
it("converts different source units before merging and preserves SCADA samples", () => {
const observed = series("scada_raw", [[40, 12], [50, null]], { device_id: "F-1", source_unit: "L/s" });
const result = mergeEventHistory([
observed,
series("realtime_simulation", [[0, 10], [90, 10]], { source_unit: "L/s" }),
series("analysis_simulation", [[38, 20], [83, 20]]),
], range, options);
expect(result.series[0]).toBe(observed);
expect(result.points.find((point) => point.timestamp === time(40))?.values["F-1_raw"]).toBe(43.2);
const merged = values(result.series[1]);
expect(merged[time(0)]).toBe(36);
expect(merged[time(38)]).toBe(20);
expect(merged[time(90)]).toBe(36);
});
it("preserves explicit missing samples and never interpolates through them", () => {
const result = mergeEventHistory([
series("realtime_simulation", [[0, 100], [30, null], [90, 100], [120, 100]]),
series("analysis_simulation", [[38, 80], [53, null], [68, 40], [83, 80]]),
], range, options);
const merged = values(result.series[0]);
expect(merged[time(15)]).toBeNull();
expect(merged[time(30)]).toBeNull();
expect(merged[time(45)]).toBeNull();
expect(merged[time(53)]).toBeNull();
expect(merged[time(60)]).toBeNull();
expect(merged[time(75)]).toBeCloseTo(58.6666667);
});
it("keeps missing event results as gaps instead of inserting normal baseline values", () => {
const result = mergeEventHistory([
series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [90, 100], [120, 100]]),
], range, options);
expect(values(result.series[0])[time(45)]).toBeNull();
expect(values(result.series[0])[time(90)]).toBe(100);
});
it("keeps same-name pipes and junctions separate and supports analysis-only results", () => {
const result = mergeEventHistory([
series("analysis_simulation", [[38, 80], [83, 80]]),
series("analysis_simulation", [[38, 30], [83, 30]], {
element_type: "junction", metric: "pressure", source_unit: "m", display_unit: "m",
}),
], range, options);
expect(result.series).toHaveLength(2);
const point = result.points.find((item) => item.timestamp === time(45))!;
expect(Object.values(point.values)).toEqual([80, 30]);
expect(Object.values(result.points[0].values)).toEqual([null, null]);
});
});
+128
View File
@@ -0,0 +1,128 @@
import {
type ElementHistoryResult,
type ElementHistorySeries,
toTimeSeriesPoints,
} from "./elementHistory";
import { FLOW_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT, toDisplayValue } from "@/utils/units";
export interface EventHistoryOptions {
eventRange: { start: string; end: string } | null;
stepSeconds: number;
}
type Sample = { time: number; value: number | null };
const samples = (series: ElementHistorySeries | undefined): Sample[] => {
const values = new Map<number, number | null>();
series?.points.forEach((point) => {
const time = Date.parse(point.time);
if (!Number.isFinite(time)) return;
const value = toDisplayValue(point.value, series.metric, series.source_unit);
values.set(time, value !== null && Number.isFinite(value) ? value : null);
});
return Array.from(values, ([time, value]) => ({ time, value }))
.sort((left, right) => left.time - right.time);
};
/** 拼接已保存的模拟结果;仅在相邻有效样本之间插值,保留显式缺测,不外推。 */
export const mergeEventHistory = (
series: ElementHistorySeries[],
range: { from: Date; to: Date },
{ eventRange, stepSeconds }: EventHistoryOptions,
): ElementHistoryResult => {
const groups = new Map<string, {
online?: ElementHistorySeries;
analysis?: ElementHistorySeries;
}>();
const observed = series.filter((item) => item.source.startsWith("scada_"));
series.forEach((item) => {
if (item.source !== "realtime_simulation" && item.source !== "analysis_simulation") return;
const key = JSON.stringify([item.element_type, item.element_id, item.metric]);
const group = groups.get(key) ?? {};
if (item.source === "realtime_simulation") group.online = item;
else group.analysis = item;
groups.set(key, group);
});
const merged = Array.from(groups.values(), ({ online, analysis }) => {
const baseline = samples(online);
const event = samples(analysis);
const start = eventRange ? Date.parse(eventRange.start) : event[0]?.time ?? NaN;
const end = eventRange ? Date.parse(eventRange.end) : event.at(-1)?.time ?? NaN;
const hasEventRange = Number.isFinite(start) && Number.isFinite(end) && end >= start;
const anchors = new Map<number, number | null>();
baseline.forEach((point) => {
if (!hasEventRange || point.time < start || point.time > end) {
anchors.set(point.time, point.value);
}
});
event.forEach((point) => {
if (hasEventRange && point.time >= start && point.time <= end) {
anchors.set(point.time, point.value);
}
});
if (hasEventRange && !event.some((point) => point.time >= start && point.time <= end)) {
anchors.set(start, null);
anchors.set(end, null);
}
const ordered = Array.from(anchors, ([time, value]) => ({ time, value }))
.sort((left, right) => left.time - right.time);
const from = range.from.getTime();
const to = range.to.getTime();
const times = new Set([
...baseline.map((point) => point.time),
...ordered.map((point) => point.time),
from, to,
]);
if (hasEventRange) {
times.add(start);
times.add(end);
}
const step = stepSeconds * 1000;
if (Number.isFinite(step) && step > 0 && ordered.length) {
const first = Math.max(from, ordered[0].time);
const last = Math.min(to, ordered[ordered.length - 1].time);
for (let time = Math.ceil(first / step) * step; time <= last; time += step) {
times.add(time);
}
}
let index = 0;
const points = Array.from(times).filter((time) => time >= from && time <= to)
.sort((left, right) => left - right).map((time) => {
while (index < ordered.length && ordered[index].time < time) index += 1;
const right = ordered[index];
const left = ordered[index - 1];
let value: number | null = null;
if (right?.time === time) value = right.value;
else if (left?.value != null && right?.value != null) {
value = left.value + (right.value - left.value)
* (time - left.time) / (right.time - left.time);
}
return { time: new Date(time).toISOString(), value };
});
const metadata = analysis ?? online!;
const unit = metadata.metric === "flow" ? FLOW_DISPLAY_UNIT : PRESSURE_DISPLAY_UNIT;
return {
...metadata,
device_id: null,
source: "event_simulation" as const,
source_unit: unit,
display_unit: unit,
unit_inferred: Boolean(online?.unit_inferred || analysis?.unit_inferred),
points,
};
});
const result = [...observed, ...merged];
return {
series: result,
points: toTimeSeriesPoints(result),
hasOnlineSimulationData: series.some((item) =>
item.source === "realtime_simulation" && item.points.some((point) =>
point.value !== null && Number.isFinite(point.value)
&& Date.parse(point.time) >= range.from.getTime()
&& Date.parse(point.time) <= range.to.getTime(),
),
),
};
};
+25
View File
@@ -1,4 +1,8 @@
import { completeLogout, reportLogoutAudit } from "./logoutFlow"; import { completeLogout, reportLogoutAudit } from "./logoutFlow";
import {
clearIntentionalLogout,
isIntentionalLogout,
} from "./logoutIntent";
describe("reportLogoutAudit", () => { describe("reportLogoutAudit", () => {
it("starts an authenticated keepalive request", async () => { it("starts an authenticated keepalive request", async () => {
@@ -28,6 +32,10 @@ describe("reportLogoutAudit", () => {
}); });
describe("completeLogout", () => { describe("completeLogout", () => {
beforeEach(() => {
clearIntentionalLogout();
});
it("navigates immediately without waiting for audit reporting", async () => { it("navigates immediately without waiting for audit reporting", async () => {
const reportAudit = jest.fn( const reportAudit = jest.fn(
() => new Promise<unknown>(() => undefined), () => new Promise<unknown>(() => undefined),
@@ -44,6 +52,7 @@ describe("completeLogout", () => {
await Promise.resolve(); await Promise.resolve();
expect(reportAudit).toHaveBeenCalledTimes(1); expect(reportAudit).toHaveBeenCalledTimes(1);
expect(isIntentionalLogout()).toBe(true);
expect(clearLocalState).toHaveBeenCalledTimes(1); expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout"); expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
await expect(result).resolves.toEqual({ success: true }); await expect(result).resolves.toEqual({ success: true });
@@ -79,4 +88,20 @@ describe("completeLogout", () => {
expect(clearLocalState).toHaveBeenCalledTimes(1); expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout"); expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
}); });
it("still navigates when local cleanup throws", async () => {
const navigate = jest.fn();
await expect(
completeLogout({
reportAudit: jest.fn().mockResolvedValue(undefined),
clearLocalState: () => {
throw new DOMException("Storage unavailable", "SecurityError");
},
navigate,
}),
).resolves.toEqual({ success: true });
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
});
}); });
+8 -1
View File
@@ -1,3 +1,5 @@
import { markIntentionalLogout } from "@/lib/logoutIntent";
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout"; const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
type CompleteLogoutOptions = { type CompleteLogoutOptions = {
@@ -41,7 +43,12 @@ export const completeLogout = async ({
// Synchronous reporting errors must not block logout either. // Synchronous reporting errors must not block logout either.
} }
clearLocalState(); markIntentionalLogout();
try {
clearLocalState();
} catch {
// Browser storage cleanup failures must not prevent the IdP logout.
}
navigate(KEYCLOAK_LOGOUT_PATH); navigate(KEYCLOAK_LOGOUT_PATH);
return { success: true as const }; return { success: true as const };
}; };
+57
View File
@@ -0,0 +1,57 @@
import {
clearIntentionalLogout,
isIntentionalLogout,
markIntentionalLogout,
} from "./logoutIntent";
describe("intentional logout marker", () => {
afterEach(() => {
jest.restoreAllMocks();
clearIntentionalLogout();
});
it("stores a timestamp only for the current tab", () => {
jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
markIntentionalLogout();
expect(sessionStorage.getItem("tjwater:intentional-logout")).toBe(
"1700000000000",
);
expect(localStorage.getItem("tjwater:intentional-logout")).toBeNull();
expect(isIntentionalLogout()).toBe(true);
});
it("accepts an intentional logout event from another open tab", () => {
window.dispatchEvent(
new StorageEvent("storage", {
key: "tjwater:intentional-logout-event",
newValue: JSON.stringify({
type: "mark",
markedAt: Date.now(),
}),
}),
);
expect(isIntentionalLogout()).toBe(true);
});
it("does not broadcast a clear event when the current tab has no marker", () => {
clearIntentionalLogout();
const setItem = jest.spyOn(Storage.prototype, "setItem");
expect(isIntentionalLogout()).toBe(false);
expect(setItem).not.toHaveBeenCalled();
});
it("expires a stale marker", () => {
const now = 1_700_000_000_000;
const nowSpy = jest.spyOn(Date, "now").mockReturnValue(now);
markIntentionalLogout();
nowSpy.mockReturnValue(now + 30 * 60 * 1000 + 1);
expect(isIntentionalLogout()).toBe(false);
expect(sessionStorage.getItem("tjwater:intentional-logout")).toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
const INTENTIONAL_LOGOUT_STORAGE_KEY = "tjwater:intentional-logout";
const INTENTIONAL_LOGOUT_EVENT_KEY = "tjwater:intentional-logout-event";
const INTENTIONAL_LOGOUT_TTL_MS = 30 * 60 * 1000;
let memoryMarkedAt: number | null = null;
const withStorage = (
storageName: "localStorage" | "sessionStorage",
callback: (storage: Storage) => void,
) => {
if (typeof window === "undefined") return;
try {
callback(window[storageName]);
} catch {
// Logout must still proceed when browser storage is unavailable.
}
};
const setLocalMarker = (markedAt: number | null) => {
memoryMarkedAt = markedAt;
withStorage("sessionStorage", (storage) => {
if (markedAt === null) {
storage.removeItem(INTENTIONAL_LOGOUT_STORAGE_KEY);
} else {
storage.setItem(INTENTIONAL_LOGOUT_STORAGE_KEY, String(markedAt));
}
});
};
const broadcastIntent = (
payload: { type: "mark"; markedAt: number } | { type: "clear" },
) => {
withStorage("localStorage", (storage) => {
storage.setItem(INTENTIONAL_LOGOUT_EVENT_KEY, JSON.stringify(payload));
storage.removeItem(INTENTIONAL_LOGOUT_EVENT_KEY);
});
};
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key !== INTENTIONAL_LOGOUT_EVENT_KEY || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as {
type?: unknown;
markedAt?: unknown;
};
if (payload.type === "mark" && typeof payload.markedAt === "number") {
setLocalMarker(payload.markedAt);
} else if (payload.type === "clear") {
setLocalMarker(null);
}
} catch {
// Ignore malformed cross-tab events.
}
});
}
export const markIntentionalLogout = () => {
const markedAt = Date.now();
setLocalMarker(markedAt);
broadcastIntent({ type: "mark", markedAt });
};
export const clearIntentionalLogout = () => {
setLocalMarker(null);
broadcastIntent({ type: "clear" });
};
export const isIntentionalLogout = () => {
let markedAt = memoryMarkedAt;
withStorage("sessionStorage", (storage) => {
const stored = Number(storage.getItem(INTENTIONAL_LOGOUT_STORAGE_KEY));
if (Number.isFinite(stored) && stored > 0) markedAt = stored;
});
if (markedAt === null) return false;
const age = Date.now() - markedAt;
if (age < 0 || age > INTENTIONAL_LOGOUT_TTL_MS) {
setLocalMarker(null);
return false;
}
return true;
};
+45
View File
@@ -0,0 +1,45 @@
import {
clearIntentionalLogout,
markIntentionalLogout,
} from "@/lib/logoutIntent";
import { useAuthStore } from "./authStore";
describe("authStore intentional logout boundary", () => {
beforeEach(() => {
clearIntentionalLogout();
useAuthStore.setState({
accessToken: "access-token",
sessionExpired: false,
sessionExpiryReason: null,
});
});
afterEach(() => {
clearIntentionalLogout();
});
it("ignores late expiry signals after an intentional logout", () => {
markIntentionalLogout();
useAuthStore.getState().markSessionExpired("unauthorized");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("keeps real refresh failures visible after an intentional logout", () => {
markIntentionalLogout();
useAuthStore.getState().markSessionExpired("refresh_failed");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "refresh_failed",
});
});
});
+17 -5
View File
@@ -1,5 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { isIntentionalLogout } from "@/lib/logoutIntent";
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null;
sessionExpired: boolean; sessionExpired: boolean;
@@ -16,11 +18,21 @@ export const useAuthStore = create<AuthState>((set) => ({
sessionExpired: false, sessionExpired: false,
sessionExpiryReason: null, sessionExpiryReason: null,
setAccessToken: (token) => set({ accessToken: token }), setAccessToken: (token) => set({ accessToken: token }),
markSessionExpired: (reason) => set({ markSessionExpired: (reason) => {
accessToken: null, if (reason === "unauthorized" && isIntentionalLogout()) {
sessionExpired: true, set({
sessionExpiryReason: reason, accessToken: null,
}), sessionExpired: false,
sessionExpiryReason: null,
});
return;
}
set({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: reason,
});
},
clearSessionExpired: () => set({ clearSessionExpired: () => set({
sessionExpired: false, sessionExpired: false,
sessionExpiryReason: null, sessionExpiryReason: null,