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
jiang e3ab82f6df fix(3d): unify station area mode labels
Generic Container CI/CD / test-build-publish (push) Successful in 1m0s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m0s
2026-11-04 14:12:49 +08:00
jiang 5a0bb6864a fix(auth): align restricted header and logout state 2026-11-04 14:12:44 +08:00
23 changed files with 861 additions and 200 deletions
+36 -3
View File
@@ -17,6 +17,7 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
}); });
test("已认证但未授权的账号会看到明确说明", async ({ page }) => { test("已认证但未授权的账号会看到明确说明", async ({ page }) => {
await page.addInitScript(() => window.localStorage.clear());
await page.route("**/api/v1/access-context", (route) => await page.route("**/api/v1/access-context", (route) =>
route.fulfill({ route.fulfill({
status: 403, status: 403,
@@ -31,12 +32,44 @@ test("已认证但未授权的账号会看到明确说明", async ({ page }) =>
await expect( await expect(
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"), page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
).toBeVisible(); ).toBeVisible();
await expect(page.getByText("TJWater 智慧水务平台")).toBeVisible(); const accountMenu = page.getByRole("button", {
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible(); name: "E2E 测试用户 账号菜单",
});
await expect(accountMenu).toBeVisible();
await accountMenu.click();
await expect(page.getByRole("menuitem", { name: "登出" })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "切换项目" })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible(); await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible(); await expect(accountMenu).toBeVisible();
});
test("无权访问项目时可以在窄屏切换项目", async ({ page }) => {
await page.route("**/api/v1/access-context", (route) =>
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({ detail: "No access to project" }),
}),
);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await expect(page.getByText("无权访问当前项目")).toBeVisible();
await page
.getByRole("button", { name: "E2E 测试用户 账号菜单" })
.click();
await page.getByRole("menuitem", { name: "切换项目" }).click();
const projectDialog = page.getByRole("dialog");
await expect(projectDialog).toBeVisible();
const bounds = await projectDialog.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
}); });
test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => { test("系统管理员可以手动同步 Keycloak 用户目录", async ({ page }) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+136 -9
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";
} = { } = {
@@ -18,6 +24,10 @@ let mockSessionState: {
}, },
status: "authenticated", status: "authenticated",
}; };
let mockPathname = "/network-simulation";
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,
@@ -26,11 +36,21 @@ jest.mock("next-auth/react", () => ({
})); }));
jest.mock("next/navigation", () => ({ jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation", 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", () => ({
@@ -43,6 +63,11 @@ jest.mock("@refinedev/mui", () => ({
})); }));
jest.mock("@refinedev/nextjs-router", () => ({})); jest.mock("@refinedev/nextjs-router", () => ({}));
jest.mock("@/components/header", () => ({
Header: ({ accessMode }: { accessMode: string }) => (
<div data-testid="access-header">{accessMode}</div>
),
}));
jest.mock("@providers/data-provider", () => ({ dataProvider: {} })); jest.mock("@providers/data-provider", () => ({ dataProvider: {} }));
jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({ jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
useAppNotificationProvider: {}, useAppNotificationProvider: {},
@@ -51,7 +76,8 @@ jest.mock("@contexts/color-mode", () => ({
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children, ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
})); }));
jest.mock("@/contexts/ProjectContext", () => ({ jest.mock("@/contexts/ProjectContext", () => ({
ProjectProvider: ({ children }: { children: ReactNode }) => children, ProjectProvider: ({ children }: { children: ReactNode }) =>
mockProjectProviderBlocks ? <div>请选择项目环境</div> : children,
})); }));
jest.mock("@/lib/authToken", () => ({ jest.mock("@/lib/authToken", () => ({
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"), getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
@@ -62,6 +88,12 @@ describe("RefineContext access authentication", () => {
const originalRequest = global.Request; const originalRequest = global.Request;
beforeEach(() => { beforeEach(() => {
clearIntentionalLogout();
mockPathname = "/network-simulation";
mockProjectProviderBlocks = false;
capturedLogin = null;
replaceMock.mockReset();
jest.mocked(signIn).mockClear();
mockSessionState = { mockSessionState = {
data: { data: {
accessToken: "expired-access-token", accessToken: "expired-access-token",
@@ -116,6 +148,7 @@ describe("RefineContext access authentication", () => {
}); });
it("shows an authorization message when access-context rejects the account", async () => { it("shows an authorization message when access-context rejects the account", async () => {
mockProjectProviderBlocks = true;
global.fetch = jest.fn().mockResolvedValue({ global.fetch = jest.fn().mockResolvedValue({
ok: false, ok: false,
status: 403, status: 403,
@@ -132,10 +165,7 @@ describe("RefineContext access authentication", () => {
expect( expect(
await screen.findByText("账号尚未获得访问权限"), await screen.findByText("账号尚未获得访问权限"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(screen.getByTestId("access-header")).toHaveTextContent("account");
screen.getByRole("button", { name: "退出登录" }),
).toBeInTheDocument();
expect(screen.getByText("Test User")).toBeInTheDocument();
expect(screen.queryByText("应用内容")).not.toBeInTheDocument(); expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
expect(useAuthStore.getState()).toMatchObject({ expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false, sessionExpired: false,
@@ -159,8 +189,9 @@ describe("RefineContext access authentication", () => {
expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument(); expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "切换项目" }), screen.queryByRole("button", { name: "切换项目" }),
).toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
expect(screen.queryByText("应用内容")).not.toBeInTheDocument(); expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
}); });
@@ -183,4 +214,100 @@ describe("RefineContext access authentication", () => {
}); });
}); });
}); });
it("does not treat an intentional logout landing on login as session expiry", async () => {
mockPathname = "/login";
mockSessionState = { data: null, status: "unauthenticated" };
render(
<App>
<div>登录页面</div>
</App>,
);
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false,
sessionExpiryReason: null,
});
});
});
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);
});
});
}); });
+18 -15
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";
@@ -68,7 +72,6 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const markSessionExpired = useAuthStore((state) => state.markSessionExpired); const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired); const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
const setCurrentProject = useProjectStore((state) => state.setCurrentProject);
const currentProjectCode = useProjectStore( const currentProjectCode = useProjectStore(
(state) => state.currentProjectCode, (state) => state.currentProjectCode,
); );
@@ -89,21 +92,28 @@ 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" || isIntentionalLogout()) {
clearSessionExpired();
return;
}
markSessionExpired("unauthorized"); markSessionExpired("unauthorized");
return; return;
} }
if (status === "authenticated") { if (status === "authenticated") {
clearIntentionalLogout();
clearSessionExpired(); clearSessionExpired();
} }
}, [clearSessionExpired, data?.error, markSessionExpired, status]); }, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
useEffect(() => { useEffect(() => {
if (status !== "authenticated") { if (status !== "authenticated") {
@@ -190,6 +200,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
: useAuthStore.getState().accessToken, : useAuthStore.getState().accessToken,
}), }),
clearLocalState: () => { clearLocalState: () => {
const authStore = useAuthStore.getState();
authStore.setAccessToken(null);
authStore.clearSessionExpired();
if (data?.user?.id) { if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
} }
@@ -198,17 +211,10 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
navigate: (path) => window.location.assign(path), navigate: (path) => window.location.assign(path),
}); });
const handleChooseAnotherProject = () => {
setCurrentProject(null, null);
localStorage.removeItem("MAP_WORKSPACE");
localStorage.removeItem("MAP_EXTENT");
window.location.assign("/");
};
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 };
@@ -406,7 +412,6 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
]; ];
return ( return (
<ProjectProvider>
<RefineKbarProvider> <RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}> <ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider> <RefineSnackbarProvider>
@@ -426,17 +431,15 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
<RoutePermissionGuard <RoutePermissionGuard
authenticated={status === "authenticated"} authenticated={status === "authenticated"}
accessDeniedReason={accessDeniedReason} accessDeniedReason={accessDeniedReason}
userName={data?.user?.name ?? data?.user?.username}
onLogout={() => void handleLogout()}
onChooseAnotherProject={handleChooseAnotherProject}
> >
<ProjectProvider>
{props.children} {props.children}
</ProjectProvider>
</RoutePermissionGuard> </RoutePermissionGuard>
<RefineKbar /> <RefineKbar />
</Refine> </Refine>
</RefineSnackbarProvider> </RefineSnackbarProvider>
</ColorModeContextProvider> </ColorModeContextProvider>
</RefineKbarProvider> </RefineKbarProvider>
</ProjectProvider>
); );
}; };
@@ -1,5 +1,36 @@
import authOptions from "./options"; import authOptions from "./options";
describe("Keycloak profile mapping", () => {
const mapProfile = authOptions.providers[0].profile as (profile: {
sub: string;
preferred_username: string;
name?: string;
email?: string;
picture?: string;
}) => { image?: string | null };
it("uses the Keycloak picture instead of a shared local avatar", () => {
expect(
mapProfile({
sub: "user-1",
preferred_username: "zjb",
picture: "https://keycloak.example/avatar/zjb.png",
}),
).toMatchObject({
image: "https://keycloak.example/avatar/zjb.png",
});
});
it("leaves the image empty when Keycloak has no picture", () => {
const profile = mapProfile({
sub: "user-1",
preferred_username: "zjb",
});
expect(profile.image).toBeUndefined();
});
});
describe("NextAuth access token refresh", () => { describe("NextAuth access token refresh", () => {
it("forces a Keycloak refresh for an agent credential request", async () => { it("forces a Keycloak refresh for an agent credential request", async () => {
const previousEnv = { const previousEnv = {
+5 -2
View File
@@ -1,7 +1,6 @@
import { NextAuthOptions } from "next-auth"; import { NextAuthOptions } from "next-auth";
import { JWT } from "next-auth/jwt"; import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak"; import KeycloakProvider from "next-auth/providers/keycloak";
import Avatar from "@assets/avatar/avatar-small.jpeg";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000; const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
@@ -86,12 +85,16 @@ const authOptions: NextAuthOptions = {
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "", clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
issuer: process.env.KEYCLOAK_ISSUER ?? "", issuer: process.env.KEYCLOAK_ISSUER ?? "",
profile(profile) { profile(profile) {
const picture =
typeof (profile as { picture?: unknown }).picture === "string"
? String((profile as { picture: string }).picture).trim()
: "";
return { return {
id: profile.sub, id: profile.sub,
username: profile.preferred_username, username: profile.preferred_username,
name: profile.name ?? profile.preferred_username, name: profile.name ?? profile.preferred_username,
email: profile.email, email: profile.email,
image: Avatar.src, image: picture || null,
}; };
}, },
}), }),
@@ -1,15 +1,27 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { useAccessStore } from "@/store/accessStore"; 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", () => ({
Header: ({ accessMode }: { accessMode: string }) => (
<div data-testid="access-header">{accessMode}</div>
),
})); }));
describe("RoutePermissionGuard", () => { describe("RoutePermissionGuard", () => {
beforeEach(() => { beforeEach(() => {
replaceMock.mockReset();
mockPathname = "/network-simulation";
useAccessStore.setState({ useAccessStore.setState({
context: null, context: null,
permissions: [], permissions: [],
@@ -53,46 +65,34 @@ describe("RoutePermissionGuard", () => {
}); });
it("keeps a minimal identity header and logout action for pending accounts", () => { it("keeps a minimal identity header and logout action for pending accounts", () => {
const onLogout = jest.fn();
render( render(
<RoutePermissionGuard <RoutePermissionGuard
authenticated authenticated
accessDeniedReason="account" accessDeniedReason="account"
userName="zjb"
onLogout={onLogout}
> >
<div>受保护内容</div> <div>受保护内容</div>
</RoutePermissionGuard>, </RoutePermissionGuard>,
); );
expect(screen.getByText("TJWater 智慧水务平台")).toBeInTheDocument(); expect(screen.getByTestId("access-header")).toHaveTextContent("account");
expect(screen.getByText("当前登录账号")).toBeInTheDocument();
expect(screen.getByText("zjb")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "退出登录" }));
expect(onLogout).toHaveBeenCalledTimes(1);
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument(); expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
}); });
it("keeps project recovery available when only the selected project is denied", () => { it("keeps project recovery available when only the selected project is denied", () => {
const onChooseAnotherProject = jest.fn();
render( render(
<RoutePermissionGuard <RoutePermissionGuard
authenticated authenticated
accessDeniedReason="project" accessDeniedReason="project"
onChooseAnotherProject={onChooseAnotherProject}
> >
<div>受保护内容</div> <div>受保护内容</div>
</RoutePermissionGuard>, </RoutePermissionGuard>,
); );
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument(); expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "切换项目" })); expect(screen.getByTestId("access-header")).toHaveTextContent("project");
expect(onChooseAnotherProject).toHaveBeenCalledTimes(1);
}); });
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>
@@ -101,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();
}); });
}); });
+16 -75
View File
@@ -1,23 +1,17 @@
"use client"; "use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import LogoutIcon from "@mui/icons-material/Logout";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import { import {
Alert, Alert,
AppBar,
Avatar,
Box, Box,
Button,
CircularProgress, CircularProgress,
Stack, Stack,
Toolbar,
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 { Title } from "@/components/title"; import { Header } from "@/components/header";
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"; import { useAuthStore } from "@/store/authStore";
@@ -26,22 +20,28 @@ export const RoutePermissionGuard = ({
children, children,
authenticated, authenticated,
accessDeniedReason = null, accessDeniedReason = null,
userName,
onLogout,
onChooseAnotherProject,
}: { }: {
children: ReactNode; children: ReactNode;
authenticated: boolean; authenticated: boolean;
accessDeniedReason?: "account" | "project" | null; accessDeniedReason?: "account" | "project" | null;
userName?: string | null;
onLogout?: () => void;
onChooseAnotherProject?: () => void;
}) => { }) => {
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 (
@@ -59,70 +59,11 @@ export const RoutePermissionGuard = ({
} }
if (accessDeniedReason) { if (accessDeniedReason) {
const displayName = userName?.trim() || "已登录账号";
const accountPending = accessDeniedReason === "account"; const accountPending = accessDeniedReason === "account";
return ( return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}> <Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<AppBar <Header accessMode={accessDeniedReason} />
color="inherit"
elevation={0}
position="static"
sx={{ borderBottom: 1, borderColor: "divider" }}
>
<Toolbar sx={{ gap: 2, justifyContent: "space-between" }}>
<Title />
<Stack direction="row" spacing={1.5} alignItems="center">
<Avatar sx={{ width: 32, height: 32, bgcolor: "primary.main" }}>
{displayName.slice(0, 1).toUpperCase()}
</Avatar>
<Box sx={{ display: { xs: "none", sm: "block" } }}>
<Typography variant="caption" color="text.secondary">
当前登录账号
</Typography>
<Typography variant="body2" fontWeight={600}>
{displayName}
</Typography>
</Box>
{!accountPending && (
<Button
aria-label="切换项目"
color="inherit"
onClick={onChooseAnotherProject}
sx={{
gap: { xs: 0, sm: 1 },
minWidth: { xs: 40, sm: "auto" },
px: { xs: 1, sm: 2 },
"&:active": { transform: "translateY(1px)" },
}}
variant="outlined"
>
<SwapHorizIcon fontSize="small" />
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
切换项目
</Box>
</Button>
)}
<Button
aria-label="退出登录"
color="inherit"
onClick={onLogout}
sx={{
gap: { xs: 0, sm: 1 },
minWidth: { xs: 40, sm: "auto" },
px: { xs: 1, sm: 2 },
"&:active": { transform: "translateY(1px)" },
}}
variant="outlined"
>
<LogoutIcon fontSize="small" />
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
退出登录
</Box>
</Button>
</Stack>
</Toolbar>
</AppBar>
<Box <Box
component="main" component="main"
sx={{ sx={{
+27 -6
View File
@@ -3,6 +3,7 @@
import { ColorModeContext } from "@contexts/color-mode"; import { ColorModeContext } from "@contexts/color-mode";
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined"; import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
import LightModeOutlined from "@mui/icons-material/LightModeOutlined"; import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
import { FiUser } from "react-icons/fi";
import { IoChatbubbleEllipsesOutline } from "react-icons/io5"; import { IoChatbubbleEllipsesOutline } from "react-icons/io5";
import Logout from "@mui/icons-material/Logout"; import Logout from "@mui/icons-material/Logout";
import SwapHoriz from "@mui/icons-material/SwapHoriz"; import SwapHoriz from "@mui/icons-material/SwapHoriz";
@@ -37,8 +38,13 @@ type IUser = {
avatar?: string; avatar?: string;
}; };
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ type HeaderProps = RefineThemedLayoutHeaderProps & {
accessMode?: "default" | "account" | "project";
};
export const Header: React.FC<HeaderProps> = ({
sticky = true, sticky = true,
accessMode = "default",
}) => { }) => {
const { mode, setMode } = useContext(ColorModeContext); const { mode, setMode } = useContext(ColorModeContext);
const { mutate: logout } = useLogout(); const { mutate: logout } = useLogout();
@@ -51,6 +57,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
); );
const { data: user } = useGetIdentity<IUser>(); const { data: user } = useGetIdentity<IUser>();
const restricted = accessMode !== "default";
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@@ -92,7 +99,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
justifyContent="flex-end" justifyContent="flex-end"
alignItems="center" alignItems="center"
> >
<HamburgerMenu /> {!restricted && <HamburgerMenu />}
<Stack <Stack
direction="row" direction="row"
width="100%" width="100%"
@@ -110,6 +117,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
{(user?.avatar || user?.name) && ( {(user?.avatar || user?.name) && (
<> <>
{!restricted && (
<IconButton <IconButton
color="inherit" color="inherit"
onClick={() => setShowChatbox(true)} onClick={() => setShowChatbox(true)}
@@ -117,13 +125,18 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
> >
<IoChatbubbleEllipsesOutline /> <IoChatbubbleEllipsesOutline />
</IconButton> </IconButton>
)}
<ButtonBase <ButtonBase
aria-label={
user?.name ? `${user.name} 账号菜单` : "账号菜单"
}
onClick={handleMenuOpen} onClick={handleMenuOpen}
sx={{ sx={{
borderRadius: "30px", borderRadius: "30px",
padding: "6px 12px", padding: "6px 12px",
marginLeft: "8px", marginLeft: "8px",
transition: "all 0.3s ease", transition:
"background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease",
border: "1px solid transparent", border: "1px solid transparent",
"&:hover": { "&:hover": {
backgroundColor: backgroundColor:
@@ -178,11 +191,13 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
}`, }`,
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",
}, },
}} }}
/> >
{!user?.avatar && <FiUser aria-hidden="true" />}
</Avatar>
</Stack> </Stack>
</ButtonBase> </ButtonBase>
<Menu <Menu
@@ -208,13 +223,15 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
}, },
}} }}
> >
{accessMode !== "account" && (
<MenuItem onClick={handleSwitchProjectClick}> <MenuItem onClick={handleSwitchProjectClick}>
<ListItemIcon> <ListItemIcon>
<SwapHoriz fontSize="small" /> <SwapHoriz fontSize="small" />
</ListItemIcon> </ListItemIcon>
<ListItemText>切换项目</ListItemText> <ListItemText>切换项目</ListItemText>
</MenuItem> </MenuItem>
<Divider /> )}
{accessMode !== "account" && <Divider />}
<MenuItem onClick={() => logout()}> <MenuItem onClick={() => logout()}>
<ListItemIcon> <ListItemIcon>
<Logout fontSize="small" /> <Logout fontSize="small" />
@@ -222,17 +239,21 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
<ListItemText>登出</ListItemText> <ListItemText>登出</ListItemText>
</MenuItem> </MenuItem>
</Menu> </Menu>
{accessMode !== "account" && (
<ProjectSelector <ProjectSelector
open={showProjectSelector} open={showProjectSelector}
onSelect={handleProjectSelect} onSelect={handleProjectSelect}
onClose={() => setShowProjectSelector(false)} onClose={() => setShowProjectSelector(false)}
/> />
)}
</> </>
)} )}
{!restricted && (
<GlobalChatbox <GlobalChatbox
open={showChatbox} open={showChatbox}
onClose={() => setShowChatbox(false)} onClose={() => setShowChatbox(false)}
/> />
)}
</Stack> </Stack>
</Stack> </Stack>
</Toolbar> </Toolbar>
@@ -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>
+5 -2
View File
@@ -132,8 +132,11 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
paper: { paper: {
sx: { sx: {
borderRadius: 2, borderRadius: 2,
padding: 2, boxSizing: "border-box",
minWidth: 400, margin: { xs: 2, sm: 4 },
padding: { xs: 1.5, sm: 2 },
width: { xs: "calc(100vw - 32px)", sm: 400 },
maxWidth: "calc(100vw - 32px)",
background: "rgba(255, 255, 255, 0.95)", background: "rgba(255, 255, 255, 0.95)",
backdropFilter: "blur(10px)", backdropFilter: "blur(10px)",
position: "relative", position: "relative",
@@ -110,7 +110,7 @@ describe("ThreeDimensionalControls", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("button", { name: "站房精细版" })); fireEvent.click(screen.getByRole("button", { name: "站区精细版" }));
expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" }); expect(onCommand).toHaveBeenCalledWith({ name: "set-mode", mode: "detail" });
fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), { fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), {
target: { value: "0.35" }, target: { value: "0.35" },
@@ -35,7 +35,7 @@ const sceneModes: Array<{ mode: SceneMode; label: string }> = [
{ mode: "network", label: "供水管网" }, { mode: "network", label: "供水管网" },
{ mode: "hydraulic", label: "泵组与管网" }, { mode: "hydraulic", label: "泵组与管网" },
{ mode: "map", label: "站区轻量版" }, { mode: "map", label: "站区轻量版" },
{ mode: "detail", label: "站房精细版" }, { mode: "detail", label: "站区精细版" },
{ mode: "pump", label: "CAD 泵房参考" }, { mode: "pump", label: "CAD 泵房参考" },
{ mode: "meters", label: "设备样件" }, { mode: "meters", label: "设备样件" },
]; ];
+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");
});
}); });
+7
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.
} }
markIntentionalLogout();
try {
clearLocalState(); 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",
});
});
});
+14 -2
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) => {
if (reason === "unauthorized" && isIntentionalLogout()) {
set({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
return;
}
set({
accessToken: null, accessToken: null,
sessionExpired: true, sessionExpired: true,
sessionExpiryReason: reason, sessionExpiryReason: reason,
}), });
},
clearSessionExpired: () => set({ clearSessionExpired: () => set({
sessionExpired: false, sessionExpired: false,
sessionExpiryReason: null, sessionExpiryReason: null,