Compare commits

...
Author SHA1 Message Date
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
jiang 80f05e481b fix(auth): keep logout and project recovery on access denial
Generic Container CI/CD / test-build-publish (push) Successful in 1m3s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m3s
2026-11-04 13:51:05 +08:00
jiang 6ff5322353 fix(api): align generated server contract
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 13:38:21 +08:00
jiang ca1c41aa9d feat(admin): sync and authorize Keycloak users
Generic Container CI/CD / test-build-publish (push) Successful in 1m5s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m5s
2026-11-04 13:35:29 +08:00
jiang 7f1ab4a79c style(history): clarify event comparison curves
Generic Container CI/CD / test-build-publish (push) Successful in 1m3s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m3s
Remove simulation area fills, distinguish scheme curves with dashed lines, and soften the event interval. Improve chart spacing, axis labels, tooltips and the range slider while retaining observed SCADA markers.

Validation: 19 focused HistoryDataPanel tests and scoped ESLint pass in an isolated checkout. Browser chart previews cover wide, compact and mixed flow/SCADA pressure states.
2026-11-03 14:16:48 +08:00
jiang e67f9542cd feat(history): add aligned online simulation action
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 59s
Reuse the existing simulation endpoint and align backfill to the hydraulic timestep. Block backfill when event metadata fails so the fallback query window cannot be calculated as event history.
2026-11-03 13:21:59 +08:00
jiang 0dc7187de2 feat(history): show event context with online simulation and SCADA
Generic Container CI/CD / test-build-publish (push) Successful in 1m2s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m2s
2026-11-03 12:40:39 +08:00
32 changed files with 2185 additions and 244 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
"sha256": "1edfd78405cfe2a50022afe70a4c4f32caa4e98e3b5b54b40841d532058f2fd6"
}
}
}
+156
View File
@@ -1201,6 +1201,59 @@
"type": "string"
},
"JsonValue": {},
"KeycloakUserSyncIssue": {
"properties": {
"reason": {
"title": "Reason",
"type": "string"
},
"username": {
"title": "Username",
"type": "string"
}
},
"required": [
"username",
"reason"
],
"title": "KeycloakUserSyncIssue",
"type": "object"
},
"KeycloakUserSyncResponse": {
"properties": {
"created": {
"title": "Created",
"type": "integer"
},
"issues": {
"items": {
"$ref": "#/components/schemas/KeycloakUserSyncIssue"
},
"title": "Issues",
"type": "array"
},
"scanned": {
"title": "Scanned",
"type": "integer"
},
"skipped": {
"title": "Skipped",
"type": "integer"
},
"updated": {
"title": "Updated",
"type": "integer"
}
},
"required": [
"scanned",
"created",
"updated",
"skipped"
],
"title": "KeycloakUserSyncResponse",
"type": "object"
},
"LeakageIdentifyRequestRest": {
"properties": {
"dma_count": {
@@ -3201,6 +3254,92 @@
]
}
},
"/api/v1/admin/keycloak-user-syncs": {
"post": {
"operationId": "post_admin_keycloak_user_syncs",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KeycloakUserSyncResponse"
}
}
},
"description": "Successful Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Authentication required"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Insufficient permission"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource not found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource conflict"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Validation error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Dependency unavailable"
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
],
"summary": "Sync Keycloak User Directory",
"tags": [
"Metadata Admin"
]
}
},
"/api/v1/admin/project-provisions": {
"post": {
"operationId": "post_admin_project_provisions",
@@ -35969,6 +36108,23 @@
"type": "array"
}
},
{
"description": "事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
"in": "query",
"name": "accident_element_type",
"required": false,
"schema": {
"default": "auto",
"description": "事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
"enum": [
"auto",
"link",
"node"
],
"title": "Accident Element Type",
"type": "string"
}
},
{
"in": "header",
"name": "X-Project-Id",
+73
View File
@@ -15,3 +15,76 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
await expect(page.getByRole("heading", { name: "查询结果" })).toBeVisible();
});
test("已认证但未授权的账号会看到明确说明", async ({ page }) => {
await page.addInitScript(() => window.localStorage.clear());
await page.route("**/api/v1/access-context", (route) =>
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({ detail: "User authorization pending" }),
}),
);
await page.goto("/");
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
await expect(
page.getByText("身份认证已成功,但当前账号尚未由系统管理员启用"),
).toBeVisible();
const accountMenu = page.getByRole("button", {
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 expect(page.getByText("账号尚未获得访问权限")).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 }) => {
await page.goto("/system-admin");
await page.getByRole("tab", { name: "系统用户" }).click();
await page.getByRole("button", { name: "同步 Keycloak 用户" }).click();
await expect(page.getByRole("button", { name: "正在同步" })).toBeDisabled();
await expect(
page.getByText("已扫描 3 个 Keycloak 身份,新增 1 个,更新 1 个,跳过 1 个。"),
).toBeVisible();
await expect(page.getByText("跳过项包括服务账号、缺少邮箱或身份冲突")).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "同步 Keycloak 用户" })).toBeVisible();
await expect(page.getByRole("button", { name: "刷新", exact: true })).toBeVisible();
});
+18 -1
View File
@@ -50,10 +50,27 @@ export const mockBackend = async (page: Page) => {
return json(route, { count: 0 });
}
if (
url.pathname === "/api/v1/admin/keycloak-user-syncs" &&
route.request().method() === "POST"
) {
await new Promise((resolve) => setTimeout(resolve, 150));
return json(route, {
scanned: 3,
created: 1,
updated: 1,
skipped: 1,
issues: [{ username: "no-email", reason: "missing_email" }],
});
}
if (
url.pathname === "/api/v1/audit-logs" ||
url.pathname === "/api/v1/admin/users" ||
url.pathname === "/api/v1/admin/projects"
url.pathname === "/api/v1/admin/projects" ||
/^\/api\/v1\/admin\/projects\/[^/]+\/(members|databases)$/.test(
url.pathname,
)
) {
return json(route, []);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+179 -3
View File
@@ -1,14 +1,20 @@
import { render, screen, waitFor } from "@testing-library/react";
import { signIn } from "next-auth/react";
import type { ReactNode } from "react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import {
clearIntentionalLogout,
markIntentionalLogout,
} from "@/lib/logoutIntent";
import { App } from "./RefineContext";
let mockSessionState: {
data: {
accessToken: string;
user: { id: string; name: string };
error?: "RefreshAccessTokenError" | "SessionExpired";
} | null;
status: "authenticated" | "unauthenticated";
} = {
@@ -18,6 +24,10 @@ let mockSessionState: {
},
status: "authenticated",
};
let mockPathname = "/network-simulation";
let mockProjectProviderBlocks = false;
let capturedLogin: (() => Promise<unknown>) | null = null;
const replaceMock = jest.fn();
jest.mock("next-auth/react", () => ({
SessionProvider: ({ children }: { children: ReactNode }) => children,
@@ -26,11 +36,21 @@ jest.mock("next-auth/react", () => ({
}));
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
usePathname: () => mockPathname,
useRouter: () => ({ replace: replaceMock }),
}));
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", () => ({
@@ -43,6 +63,11 @@ jest.mock("@refinedev/mui", () => ({
}));
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/notification-provider/useAppNotificationProvider", () => ({
useAppNotificationProvider: {},
@@ -51,7 +76,8 @@ jest.mock("@contexts/color-mode", () => ({
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock("@/contexts/ProjectContext", () => ({
ProjectProvider: ({ children }: { children: ReactNode }) => children,
ProjectProvider: ({ children }: { children: ReactNode }) =>
mockProjectProviderBlocks ? <div>请选择项目环境</div> : children,
}));
jest.mock("@/lib/authToken", () => ({
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
@@ -62,6 +88,12 @@ describe("RefineContext access authentication", () => {
const originalRequest = global.Request;
beforeEach(() => {
clearIntentionalLogout();
mockPathname = "/network-simulation";
mockProjectProviderBlocks = false;
capturedLogin = null;
replaceMock.mockReset();
jest.mocked(signIn).mockClear();
mockSessionState = {
data: {
accessToken: "expired-access-token",
@@ -115,6 +147,54 @@ describe("RefineContext access authentication", () => {
});
});
it("shows an authorization message when access-context rejects the account", async () => {
mockProjectProviderBlocks = true;
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ detail: "User authorization pending" }),
} as Response);
render(
<App>
<div>应用内容</div>
</App>,
);
expect(
await screen.findByText("账号尚未获得访问权限"),
).toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("account");
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("keeps project recovery available when the selected project is denied", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ detail: "No access to project" }),
} as Response);
render(
<App>
<div>应用内容</div>
</App>,
);
expect(await screen.findByText("无权访问当前项目")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "切换项目" }),
).not.toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
});
it("prioritizes an unauthenticated session over route permissions", async () => {
mockSessionState = { data: null, status: "unauthenticated" };
@@ -134,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);
});
});
});
+85 -45
View File
@@ -9,7 +9,7 @@ import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { RefineSnackbarProvider } from "@refinedev/mui";
import { SessionProvider, signIn, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import routerProvider from "@refinedev/nextjs-router";
import { NavigationLink } from "@components/loading/NavigationLink";
@@ -24,6 +24,10 @@ import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch";
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
import {
clearIntentionalLogout,
isIntentionalLogout,
} from "@/lib/logoutIntent";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
@@ -75,6 +79,9 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
const resetAccess = useAccessStore((state) => state.reset);
const [accessDeniedReason, setAccessDeniedReason] = useState<
"account" | "project" | null
>(null);
const can = (permission: string) => permissions.includes(permission);
useEffect(() => {
@@ -85,39 +92,64 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
useEffect(() => {
if (data?.error === "SessionExpired") {
clearIntentionalLogout();
markSessionExpired("session_max_age");
return;
}
if (data?.error === "RefreshAccessTokenError") {
clearIntentionalLogout();
markSessionExpired("refresh_failed");
return;
}
if (status === "unauthenticated") {
if (to === "/login" || isIntentionalLogout()) {
clearSessionExpired();
return;
}
markSessionExpired("unauthorized");
return;
}
if (status === "authenticated") {
clearIntentionalLogout();
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
useEffect(() => {
if (status !== "authenticated") {
setAccessDeniedReason(null);
resetAccess();
return;
}
let cancelled = false;
setAccessDeniedReason(null);
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
})
.then(async (response) => {
if (cancelled) return;
if (response.status === 403) {
let detail = "";
try {
const payload = await response.json();
detail = typeof payload?.detail === "string" ? payload.detail : "";
} catch {
// Treat an unparseable 403 as a project access failure so the user
// keeps a recovery path instead of being trapped in the account shell.
}
setAccessDeniedReason(
detail === "User authorization pending" ? "account" : "project",
);
resetAccess();
return;
}
if (!response.ok) {
resetAccess();
return;
}
setAccessDeniedReason(null);
setAccessContext(await response.json());
})
.catch(() => {
@@ -157,32 +189,37 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
return <span>loading...</span>;
}
const handleLogout = () =>
completeLogout({
reportAudit: () =>
reportLogoutAudit({
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
accessToken:
typeof data?.accessToken === "string"
? data.accessToken
: useAuthStore.getState().accessToken,
}),
clearLocalState: () => {
const authStore = useAuthStore.getState();
authStore.setAccessToken(null);
authStore.clearSessionExpired();
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
clearSessionRecoveryDrafts();
},
navigate: (path) => window.location.assign(path),
});
const authProvider: AuthProvider = {
login: async () => {
signIn("keycloak", {
callbackUrl: to ? to.toString() : "/",
callbackUrl: to && to !== "/login" ? to.toString() : "/",
redirect: true,
});
return { success: true };
},
logout: () =>
completeLogout({
reportAudit: () =>
reportLogoutAudit({
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
accessToken:
typeof data?.accessToken === "string"
? data.accessToken
: useAuthStore.getState().accessToken,
}),
clearLocalState: () => {
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
clearSessionRecoveryDrafts();
},
navigate: (path) => window.location.assign(path),
}),
logout: handleLogout,
onError: async (error) => {
return { error };
},
@@ -375,31 +412,34 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
];
return (
<ProjectProvider>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={appRouterProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
accessControlProvider={accessControlProvider}
resources={resources}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={appRouterProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
accessControlProvider={accessControlProvider}
resources={resources}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard
authenticated={status === "authenticated"}
accessDeniedReason={accessDeniedReason}
>
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard authenticated={status === "authenticated"}>
<ProjectProvider>
{props.children}
</RoutePermissionGuard>
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</ProjectProvider>
</ProjectProvider>
</RoutePermissionGuard>
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
);
};
@@ -1,5 +1,36 @@
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", () => {
it("forces a Keycloak refresh for an agent credential request", async () => {
const previousEnv = {
+5 -2
View File
@@ -1,7 +1,6 @@
import { NextAuthOptions } from "next-auth";
import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";
import Avatar from "@assets/avatar/avatar-small.jpeg";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
@@ -86,12 +85,16 @@ const authOptions: NextAuthOptions = {
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
issuer: process.env.KEYCLOAK_ISSUER ?? "",
profile(profile) {
const picture =
typeof (profile as { picture?: unknown }).picture === "string"
? String((profile as { picture: string }).picture).trim()
: "";
return {
id: profile.sub,
username: profile.preferred_username,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: Avatar.src,
image: picture || null,
};
},
}),
@@ -0,0 +1,87 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { apiFetch } from "@/lib/apiFetch";
import { SystemAdminPanel } from "./SystemAdminPanel";
const openNotification = jest.fn();
jest.mock("@refinedev/core", () => ({
useNotification: () => ({ open: openNotification }),
}));
jest.mock("@config/config", () => ({
config: { BACKEND_URL: "https://server.example.com" },
}));
jest.mock("@/lib/apiFetch", () => ({
apiFetch: jest.fn(),
}));
jest.mock("@/lib/sessionRecoveryDraft", () => ({
useSessionRecoveryDraft: jest.fn(),
}));
jest.mock("@/store/projectStore", () => ({
useProjectStore: (selector: (state: { currentProjectId: null }) => unknown) =>
selector({ currentProjectId: null }),
}));
const jsonResponse = (body: unknown) =>
({
ok: true,
status: 200,
json: async () => body,
text: async () => JSON.stringify(body),
}) as Response;
describe("SystemAdminPanel Keycloak synchronization", () => {
beforeEach(() => {
jest.clearAllMocks();
(apiFetch as jest.Mock).mockImplementation((input: string) => {
if (input.endsWith("/api/v1/admin/users/me")) {
return Promise.resolve(
jsonResponse({
id: "admin-id",
keycloak_id: "admin-keycloak-id",
username: "admin",
email: "admin@example.com",
role: "admin",
is_active: true,
is_superuser: true,
}),
);
}
if (input.endsWith("/api/v1/admin/keycloak-user-syncs")) {
return Promise.resolve(
jsonResponse({
scanned: 3,
created: 1,
updated: 1,
skipped: 1,
issues: [{ username: "no-email", reason: "missing_email" }],
}),
);
}
return Promise.resolve(jsonResponse([]));
});
});
it("syncs the Keycloak directory and shows the result", async () => {
render(<SystemAdminPanel />);
fireEvent.click(await screen.findByRole("tab", { name: "系统用户" }));
fireEvent.click(
screen.getByRole("button", { name: "同步 Keycloak 用户" }),
);
await waitFor(() => {
expect(apiFetch).toHaveBeenCalledWith(
"https://server.example.com/api/v1/admin/keycloak-user-syncs",
expect.objectContaining({ method: "POST" }),
);
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"已扫描 3 个 Keycloak 身份,新增 1 个,更新 1 个,跳过 1 个。",
);
});
});
+77 -4
View File
@@ -53,6 +53,7 @@ import {
Save as SaveIcon,
Security as SecurityIcon,
Storage as StorageIcon,
Sync as SyncIcon,
} from "@mui/icons-material";
import { useNotification } from "@refinedev/core";
import { config } from "@config/config";
@@ -70,6 +71,17 @@ type MetadataUser = {
is_superuser: boolean;
};
type KeycloakUserSyncResult = {
scanned: number;
created: number;
updated: number;
skipped: number;
issues: Array<{
username: string;
reason: "missing_email" | "service_account" | "identity_conflict" | string;
}>;
};
type ProjectMember = {
id: string;
user_id: string;
@@ -469,6 +481,9 @@ export const SystemAdminPanel = () => {
const [adminChecked, setAdminChecked] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true);
const [syncingKeycloakUsers, setSyncingKeycloakUsers] = useState(false);
const [keycloakSyncResult, setKeycloakSyncResult] =
useState<KeycloakUserSyncResult | null>(null);
const [projectId, setProjectId] = useState(currentProjectId ?? "");
const [memberForm, setMemberForm] = useState({
user_id: "",
@@ -564,6 +579,33 @@ export const SystemAdminPanel = () => {
setUsers(await response.json());
}, []);
const syncKeycloakUsers = useCallback(async () => {
if (syncingKeycloakUsers) return;
setSyncingKeycloakUsers(true);
try {
const response = await apiFetch(
`${config.BACKEND_URL}/api/v1/admin/keycloak-user-syncs`,
{
method: "POST",
projectHeaderMode: "omit",
},
);
if (!response.ok) {
throw new Error(await readErrorText(response));
}
const result = (await response.json()) as KeycloakUserSyncResult;
setKeycloakSyncResult(result);
await loadUsers();
notifySuccess(
`Keycloak 用户同步完成:新增 ${result.created},更新 ${result.updated},跳过 ${result.skipped}`,
);
} catch (error) {
notifyError(error instanceof Error ? error.message : String(error));
} finally {
setSyncingKeycloakUsers(false);
}
}, [loadUsers, notifyError, notifySuccess, syncingKeycloakUsers]);
const applyProjectForm = useCallback((project: AdminProject | null) => {
if (!project) {
setProjectForm(defaultProjectForm);
@@ -1238,13 +1280,44 @@ export const SystemAdminPanel = () => {
<Stack spacing={2}>
<SectionHeader
title="系统用户"
description="Keycloak 信息只读展示;这里维护系统角色和启用状态。超级管理员由后台初始化或受控脚本设置,不在页面修改。"
description="从 Keycloak 同步身份后,在这里维护系统角色和启用状态。新用户默认停用,项目权限仍需单独配置。"
action={
<Button startIcon={<RefreshIcon />} variant="outlined" onClick={loadUsers}>
刷新
</Button>
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Button
startIcon={
syncingKeycloakUsers ? (
<CircularProgress color="inherit" size={16} />
) : (
<SyncIcon />
)
}
variant="contained"
disabled={syncingKeycloakUsers}
onClick={() => void syncKeycloakUsers()}
>
{syncingKeycloakUsers ? "正在同步" : "同步 Keycloak 用户"}
</Button>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
disabled={syncingKeycloakUsers}
onClick={() => void loadUsers()}
>
刷新
</Button>
</Stack>
}
/>
{keycloakSyncResult && (
<Alert
severity={keycloakSyncResult.skipped > 0 ? "warning" : "success"}
sx={{ borderRadius: 2 }}
>
{`已扫描 ${keycloakSyncResult.scanned} 个 Keycloak 身份,新增 ${keycloakSyncResult.created} 个,更新 ${keycloakSyncResult.updated} 个,跳过 ${keycloakSyncResult.skipped} 个。`}
{keycloakSyncResult.skipped > 0 &&
" 跳过项包括服务账号、缺少邮箱或身份冲突,请检查 Keycloak 用户资料。"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
<Table size="small" sx={tableSx}>
<TableHead>
@@ -4,12 +4,24 @@ import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import { RoutePermissionGuard } from "./RoutePermissionGuard";
const replaceMock = jest.fn();
let mockPathname = "/network-simulation";
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", () => {
beforeEach(() => {
replaceMock.mockReset();
mockPathname = "/network-simulation";
useAccessStore.setState({
context: null,
permissions: [],
@@ -52,7 +64,35 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
});
it("checks authentication before the expired state effect runs", () => {
it("keeps a minimal identity header and logout action for pending accounts", () => {
render(
<RoutePermissionGuard
authenticated
accessDeniedReason="account"
>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByTestId("access-header")).toHaveTextContent("account");
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
});
it("keeps project recovery available when only the selected project is denied", () => {
render(
<RoutePermissionGuard
authenticated
accessDeniedReason="project"
>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
});
it("redirects an unauthenticated protected route to login", () => {
render(
<RoutePermissionGuard authenticated={false}>
<div>受保护内容</div>
@@ -61,5 +101,20 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByText("登录状态已失效")).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();
});
});
+57 -3
View File
@@ -1,10 +1,17 @@
"use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import {
Alert,
Box,
CircularProgress,
Stack,
Typography,
} from "@mui/material";
import { usePathname, useRouter } from "next/navigation";
import { type ReactNode, useEffect } from "react";
import { Header } from "@/components/header";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
@@ -12,15 +19,29 @@ import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({
children,
authenticated,
accessDeniedReason = null,
}: {
children: ReactNode;
authenticated: boolean;
accessDeniedReason?: "account" | "project" | null;
}) => {
const pathname = usePathname();
const router = useRouter();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const sessionExpired = useAuthStore((state) => state.sessionExpired);
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) {
return (
@@ -37,6 +58,39 @@ export const RoutePermissionGuard = ({
);
}
if (accessDeniedReason) {
const accountPending = accessDeniedReason === "account";
return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<Header accessMode={accessDeniedReason} />
<Box
component="main"
sx={{
width: "100%",
maxWidth: 840,
mx: "auto",
px: { xs: 2, sm: 3 },
py: { xs: 4, md: 8 },
}}
>
<Alert severity="warning" icon={<LockOutlinedIcon />}>
<Stack spacing={0.5}>
<Typography fontWeight={700}>
{accountPending ? "账号尚未获得访问权限" : "无权访问当前项目"}
</Typography>
<Typography variant="body2">
{accountPending
? "身份认证已成功,但当前账号尚未由系统管理员启用。请联系系统管理员完成授权后重试。"
: "当前账号无法访问所选项目。请选择其他已授权项目,或联系系统管理员调整项目权限。"}
</Typography>
</Stack>
</Alert>
</Box>
</Box>
);
}
if (requiredPermission && loading) {
return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
+49 -28
View File
@@ -3,6 +3,7 @@
import { ColorModeContext } from "@contexts/color-mode";
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
import { FiUser } from "react-icons/fi";
import { IoChatbubbleEllipsesOutline } from "react-icons/io5";
import Logout from "@mui/icons-material/Logout";
import SwapHoriz from "@mui/icons-material/SwapHoriz";
@@ -37,8 +38,13 @@ type IUser = {
avatar?: string;
};
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
type HeaderProps = RefineThemedLayoutHeaderProps & {
accessMode?: "default" | "account" | "project";
};
export const Header: React.FC<HeaderProps> = ({
sticky = true,
accessMode = "default",
}) => {
const { mode, setMode } = useContext(ColorModeContext);
const { mutate: logout } = useLogout();
@@ -51,6 +57,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
);
const { data: user } = useGetIdentity<IUser>();
const restricted = accessMode !== "default";
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -92,7 +99,7 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
justifyContent="flex-end"
alignItems="center"
>
<HamburgerMenu />
{!restricted && <HamburgerMenu />}
<Stack
direction="row"
width="100%"
@@ -110,20 +117,26 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
{(user?.avatar || user?.name) && (
<>
<IconButton
color="inherit"
onClick={() => setShowChatbox(true)}
sx={{ mr: 1 }}
>
<IoChatbubbleEllipsesOutline />
</IconButton>
{!restricted && (
<IconButton
color="inherit"
onClick={() => setShowChatbox(true)}
sx={{ mr: 1 }}
>
<IoChatbubbleEllipsesOutline />
</IconButton>
)}
<ButtonBase
aria-label={
user?.name ? `${user.name} 账号菜单` : "账号菜单"
}
onClick={handleMenuOpen}
sx={{
borderRadius: "30px",
padding: "6px 12px",
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",
"&:hover": {
backgroundColor:
@@ -178,11 +191,13 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
}`,
transition: "transform 0.3s ease",
".MuiButtonBase-root:hover &": {
transform: "rotate(5deg) scale(1.05)",
transform: "rotate(5deg)",
borderColor: "primary.main",
},
}}
/>
>
{!user?.avatar && <FiUser aria-hidden="true" />}
</Avatar>
</Stack>
</ButtonBase>
<Menu
@@ -208,13 +223,15 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
},
}}
>
<MenuItem onClick={handleSwitchProjectClick}>
<ListItemIcon>
<SwapHoriz fontSize="small" />
</ListItemIcon>
<ListItemText>切换项目</ListItemText>
</MenuItem>
<Divider />
{accessMode !== "account" && (
<MenuItem onClick={handleSwitchProjectClick}>
<ListItemIcon>
<SwapHoriz fontSize="small" />
</ListItemIcon>
<ListItemText>切换项目</ListItemText>
</MenuItem>
)}
{accessMode !== "account" && <Divider />}
<MenuItem onClick={() => logout()}>
<ListItemIcon>
<Logout fontSize="small" />
@@ -222,17 +239,21 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
<ListItemText>登出</ListItemText>
</MenuItem>
</Menu>
<ProjectSelector
open={showProjectSelector}
onSelect={handleProjectSelect}
onClose={() => setShowProjectSelector(false)}
/>
{accessMode !== "account" && (
<ProjectSelector
open={showProjectSelector}
onSelect={handleProjectSelect}
onClose={() => setShowProjectSelector(false)}
/>
)}
</>
)}
<GlobalChatbox
open={showChatbox}
onClose={() => setShowChatbox(false)}
/>
{!restricted && (
<GlobalChatbox
open={showChatbox}
onClose={() => setShowChatbox(false)}
/>
)}
</Stack>
</Stack>
</Toolbar>
@@ -1,21 +1,41 @@
import React from "react";
import { render, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import dayjs from "dayjs";
import ReactECharts from "echarts-for-react";
import { apiFetch } from "@/lib/apiFetch";
import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
jest.mock("@/lib/apiFetch", () => ({
apiFetch: jest.fn(),
}));
jest.mock("@/lib/analysisRuns", () => ({
...jest.requireActual("@/lib/analysisRuns"),
getAnalysisScheme: jest.fn(),
}));
jest.mock("@components/olmap/core/MapComponent", () => ({
useData: () => null,
}));
jest.mock("@mui/x-date-pickers", () => ({
DateTimePicker: () => null,
DateTimePicker: ({ label, value, onChange }: {
label: string; value: dayjs.Dayjs; onChange: (value: dayjs.Dayjs) => void;
}) => React.createElement("input", {
"aria-label": label,
value: value.toISOString(),
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(dayjs(event.target.value)),
}),
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock("echarts-for-react", () => () => null);
jest.mock("echarts-for-react", () => jest.fn(() => null));
jest.mock("./useTimelineTimeConfig", () => ({
useTimelineTimeConfig: () => ({
durationMinutes: 1440, stepMinutes: 15, properties: { "HYDRAULIC TIMESTEP": "0:15" },
}),
}));
const jsonResponse = (payload: unknown, status = 200) =>
({
@@ -66,14 +86,63 @@ describe("fetchHistoryData", () => {
"99dd4142-368b-54cb-bfca-d59ee48f6298",
);
expect(apiFetch).toHaveBeenCalledTimes(1);
const [, init] = jest.mocked(apiFetch).mock.calls[0];
expect(JSON.parse(String(init?.body))).toMatchObject({
expect(apiFetch).toHaveBeenCalledTimes(2);
const bodies = jest.mocked(apiFetch).mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
expect(bodies[0]).toMatchObject({ mode: "realtime_comparison" });
expect(bodies[0]).not.toHaveProperty("run_id");
expect(bodies[1]).toMatchObject({
mode: "analysis_comparison",
run_id: "99dd4142-368b-54cb-bfca-d59ee48f6298",
});
});
it("combines element simulations without SCADA and deduplicates monitoring", async () => {
const base = {
element_id: "P-1", element_type: "pipe", device_id: null,
metric: "flow", source_unit: "L/s", display_unit: "m³/h", unit_inferred: false,
};
const observed = {
...base, device_id: "F-1", source: "scada_raw",
points: [{ time: "2026-08-17T09:30:00Z", value: 5 }],
};
const targets: [string, string][] = [["P-1", "pipe"], ["J-1", "junction"]];
jest.mocked(apiFetch).mockImplementation(async (_url, init) => {
const { mode } = JSON.parse(String(init?.body));
return jsonResponse({ series: mode === "analysis_comparison" ? [
observed,
{ ...base, source: "analysis_simulation", points: [
{ time: "2026-08-17T09:30:00Z", value: 20 },
] },
] : [
observed,
{ ...base, source: "realtime_simulation", points: [
{ time: "2026-08-17T09:00:00Z", value: 10 },
{ time: "2026-08-17T10:00:00Z", value: 12 },
] },
{ ...base, element_id: "J-1", element_type: "junction", metric: "pressure",
source_unit: "m", source: "realtime_simulation", points: [
{ time: "2026-08-17T09:00:00Z", value: 30 },
] },
] });
});
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
expect(result.series).toHaveLength(4);
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 });
expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72);
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2);
expect(apiFetch).toHaveBeenCalledTimes(2);
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
}
});
it("reports an aggregate request failure instead of showing an incomplete comparison", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(jsonResponse({ series: [] }))
.mockResolvedValueOnce(jsonResponse({ detail: "analysis unavailable" }, 503));
await expect(fetchHistoryData([["P-1", "pipe"]], range, "scheme", "run-1"))
.rejects.toThrow("analysis unavailable");
});
it("rejects oversized element selections before issuing requests", async () => {
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({}));
const featureInfos = Array.from(
@@ -103,10 +172,20 @@ describe("fetchHistoryData", () => {
});
});
const eventRun = (runId = "run-1", startedAt = "2026-08-17T09:00:00Z") =>
toAnalysisSchemeRecord({
run_id: runId, name: "event", run_type: "burst_analysis", created_by: "alice",
created_at: "2026-11-03T00:00:00Z", started_at: startedAt,
status: "completed", parameters: { modify_total_duration: 3600 },
});
describe("HistoryDataPanel requests", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun());
useAccessStore.setState({ permissions: ["simulation.run"], loading: false });
useProjectStore.getState().setCurrentProject("project-1", "network-1");
});
afterEach(() => jest.clearAllMocks());
@@ -137,4 +216,193 @@ describe("HistoryDataPanel requests", () => {
);
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
});
const props = {
featureInfos: [["P-1", "pipe"]] as [string, string][],
type: "scheme" as const, runId: "run-1", onClose: jest.fn(),
};
it("initializes a historical event with one hour of context on both sides", async () => {
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
expect(JSON.parse(String(init?.body))).toMatchObject({
start_time: "2026-08-17T08:00:00.000Z", end_time: "2026-08-17T11:00:00.000Z",
});
}
expect(screen.getByText(/事件方案时段:2026-08-17 17:00/)).toBeInTheDocument();
expect(screen.getByLabelText("开始时间")).toHaveValue("2026-08-17T08:00:00.000Z");
});
it("keeps explicit query dates and issues one pair of requests per date change", async () => {
render(React.createElement(HistoryDataPanel, {
...props, start_time: "2026-08-17T07:00:00Z", end_time: "2026-08-17T12:00:00Z",
}));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
fireEvent.change(screen.getByLabelText("开始时间"), { target: { value: "2026-08-17T06:00:00Z" } });
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4));
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({
start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z",
});
expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
expect(getAnalysisScheme).toHaveBeenCalledTimes(1);
});
it("ignores a stale scheme response after switching events", async () => {
let resolveOld!: (value: ReturnType<typeof eventRun>) => void;
jest.mocked(getAnalysisScheme).mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve; }))
.mockResolvedValueOnce(eventRun("run-2", "2026-08-18T09:00:00Z"));
const { rerender } = render(React.createElement(HistoryDataPanel, props));
expect(apiFetch).not.toHaveBeenCalled();
rerender(React.createElement(HistoryDataPanel, { ...props, runId: "run-2" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
await act(async () => { resolveOld(eventRun()); });
expect(apiFetch).toHaveBeenCalledTimes(2);
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toMatchObject({
run_id: "run-2", start_time: "2026-08-18T08:00:00.000Z",
});
});
it("preserves the manually chosen end date when only the start date prop changes", async () => {
const partialProps = { ...props, type: "none" as const, start_time: "2026-08-17T07:00:00Z" };
const { rerender } = render(React.createElement(HistoryDataPanel, partialProps));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText("结束时间"), { target: { value: "2026-08-17T12:00:00Z" } });
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
rerender(React.createElement(HistoryDataPanel, { ...partialProps, start_time: "2026-08-17T06:00:00Z" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(3));
expect(screen.getByLabelText("结束时间")).toHaveValue("2026-08-17T12:00:00.000Z");
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[2][1]?.body))).toMatchObject({
start_time: "2026-08-17T06:00:00.000Z", end_time: "2026-08-17T12:00:00.000Z",
});
});
it("queries online element readings for an analysis without hydraulic results", async () => {
jest.mocked(getAnalysisScheme).mockResolvedValue({ ...eventRun(), run_type: "burst_detection" });
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1));
const body = JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body));
expect(body.mode).toBe("realtime_comparison");
expect(body).not.toHaveProperty("run_id");
});
it("does not restore stale curves after the selected elements are cleared", async () => {
const pending: Array<(value: Response) => void> = [];
jest.mocked(apiFetch).mockImplementation(() => new Promise((resolve) => { pending.push(resolve); }));
const { rerender } = render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
rerender(React.createElement(HistoryDataPanel, { ...props, featureInfos: [] }));
expect(jest.mocked(apiFetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
expect(jest.mocked(apiFetch).mock.calls[1][1]?.signal?.aborted).toBe(true);
await act(async () => {
pending[0](jsonResponse({ series: [{
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source: "realtime_simulation", source_unit: "m3/h", display_unit: "m³/h",
unit_inferred: false, points: [{ time: "2026-08-17T09:00:00Z", value: 10 }],
}] }));
pending[1](jsonResponse({ series: [] }));
});
expect(ReactECharts).not.toHaveBeenCalled();
expect(screen.getByText("尚未选择地图要素")).toBeInTheDocument();
});
it("allows a failed event metadata query to be retried", async () => {
jest.mocked(getAnalysisScheme).mockRejectedValueOnce(new Error("unavailable"));
render(React.createElement(HistoryDataPanel, props));
await screen.findByText(/无法读取事件方案/);
expect(apiFetch).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
});
it("plots native timestamps and explicit gaps with the event interval marked", async () => {
const series = {
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
};
jest.mocked(apiFetch).mockImplementation(async (_url, init) => {
const { mode } = JSON.parse(String(init?.body));
return jsonResponse({ series: [{ ...series,
source: mode === "analysis_comparison" ? "analysis_simulation" : "realtime_simulation",
points: mode === "analysis_comparison" ? [
{ time: "2026-08-17T09:15:00Z", value: 8 },
] : [
{ time: "2026-08-17T08:00:00Z", value: 10 },
{ time: "2026-08-17T09:00:00Z", value: null },
{ time: "2026-08-17T11:00:00Z", value: 12 },
],
}] });
});
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(ReactECharts).toHaveBeenCalled());
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.series[0].connectNulls).toBe(false);
expect(option.series[0].data).toEqual([
[Date.parse("2026-08-17T08:00:00Z"), 10],
[Date.parse("2026-08-17T09:00:00Z"), null],
[Date.parse("2026-08-17T11:00:00Z"), 12],
]);
expect(option.series[0].markArea.data[0]).toEqual([
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
]);
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
});
it("snaps off-step event context without changing the actual event marker", async () => {
jest.mocked(getAnalysisScheme).mockResolvedValue(eventRun("run-1", "2026-07-30T05:38:00Z"));
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body))).toMatchObject({
start_time: "2026-07-30T04:30:00.000Z", end_time: "2026-07-30T07:45:00.000Z",
});
expect(screen.getByText(/事件方案时段:2026-07-30 13:38/)).toBeInTheDocument();
});
it("runs online simulation once, shows progress, then refreshes both history sources", async () => {
let finishSimulation!: (value: Response) => void;
jest.mocked(apiFetch).mockImplementation(async (url) => {
if (String(url).endsWith("/network-options/time")) return jsonResponse({ "HYDRAULIC TIMESTEP": "0:15" });
if (String(url).endsWith("/simulation-runs")) return new Promise((resolve) => { finishSimulation = resolve; });
return jsonResponse({ series: [] });
});
render(React.createElement(HistoryDataPanel, props));
const button = screen.getByRole("button", { name: "在线模拟" });
await waitFor(() => expect(button).toBeEnabled());
fireEvent.click(button);
await screen.findByText("正在计算在线模拟数据,完成后将自动刷新图表。");
expect(screen.getByRole("button", { name: "模拟中…" })).toBeDisabled();
await waitFor(() => expect(finishSimulation).toBeDefined());
fireEvent.click(button);
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/simulation-runs"))).toHaveLength(1);
await act(async () => { finishSimulation(jsonResponse({ status: "success" })); });
await waitFor(() => expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/element-history/query"))).toHaveLength(4));
expect(screen.getByRole("button", { name: "在线模拟" })).toBeEnabled();
});
it("disables the write action for a viewer while allowing history reads", async () => {
useAccessStore.setState({ permissions: ["simulation.view"] });
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled();
expect(screen.getByRole("button", { name: "刷新" })).toBeEnabled();
});
it("shows a simulation failure without retrying or claiming data was refreshed", async () => {
jest.mocked(apiFetch).mockImplementation(async (url) => {
if (String(url).endsWith("/network-options/time")) return jsonResponse({ "HYDRAULIC TIMESTEP": "0:15" });
if (String(url).endsWith("/simulation-runs")) return jsonResponse({ detail: "计算服务不可用" }, 503);
return jsonResponse({ series: [] });
});
render(React.createElement(HistoryDataPanel, props));
const button = screen.getByRole("button", { name: "在线模拟" });
await waitFor(() => expect(button).toBeEnabled());
fireEvent.click(button);
expect(await screen.findByRole("alert")).toHaveTextContent("计算服务不可用");
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/simulation-runs"))).toHaveLength(1);
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/element-history/query"))).toHaveLength(2);
});
});
@@ -22,11 +22,10 @@ import {
Tooltip,
Typography,
} from "@mui/material";
import { Close, Refresh, ShowChart, TableChart } from "@mui/icons-material";
import { Close, PlayArrow, Refresh, ShowChart, TableChart } from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales";
import ReactECharts from "echarts-for-react";
import * as echarts from "echarts";
import "dayjs/locale/zh-cn"; // 引入中文包
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
@@ -41,8 +40,23 @@ import {
fetchElementHistory,
historySeriesKey,
historySeriesLabel,
toTimeSeriesPoints,
TimeSeriesPoint,
} from "@/lib/elementHistory";
import {
type AnalysisHistoryContext,
getAnalysisHistoryContext,
getAnalysisScheme,
} from "@/lib/analysisRuns";
import {
alignSimulationRange,
parseHydraulicStepSeconds,
runOnlineSimulation,
} from "@/lib/onlineSimulation";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { useTimelineTimeConfig } from "./useTimelineTimeConfig";
import {
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
@@ -54,7 +68,7 @@ dayjs.extend(timezone);
export interface SCADADataPanelProps {
/** 选中的要素信息列表,格式为 [[id, type], [id, type]] */
featureInfos: [string, string][];
/** 数据类型: realtime-查询模拟值和监测值, none-仅查询监测值, scheme-查询策略模拟值和监测值 */
/** 数据类型: realtime-在线模拟与监测, none-仅监测, scheme-方案、在线模拟与监测 */
type?: "realtime" | "scheme" | "none";
/** 默认展示的选项卡 */
defaultTab?: "chart" | "table";
@@ -107,6 +121,13 @@ export const fetchHistoryData = async (
) {
throw new Error("历史数据包含无效的管网元素 ID");
}
if (
featureInfos.some(
([, elementType]) => !["pipe", "junction"].includes(elementType.toLowerCase()),
)
) {
throw new Error("历史数据仅支持管道和节点");
}
const uniqueFeatureInfos = Array.from(
new Map(
@@ -118,25 +139,33 @@ export const fetchHistoryData = async (
throw new Error("历史方案缺少分析运行 ID,无法读取方案时序数据");
}
try {
return await fetchElementHistory(
uniqueFeatureInfos.map(([id, elementType]) => ({
element_id: id.trim(),
element_type: elementType.toLowerCase() as "pipe" | "junction",
})),
range,
type === "none"
? "observed"
: type === "scheme"
? "analysis_comparison"
: "realtime_comparison",
schemeRunId,
signal,
);
} catch (error) {
console.error("[HistoryDataPanel] 从后端获取数据失败:", error);
throw error;
const elements = uniqueFeatureInfos.map(([id, elementType]) => ({
element_id: id.trim(),
element_type: elementType.toLowerCase() as "pipe" | "junction",
}));
if (type === "scheme") {
const [online, analysis] = await Promise.all([
fetchElementHistory(
elements, range, "realtime_comparison", undefined, signal,
),
fetchElementHistory(
elements, range, "analysis_comparison", schemeRunId, signal,
),
]);
// 两次聚合查询均返回监测序列,只保留在线查询中的一份。
const series = [
...online.series,
...analysis.series.filter((item) => item.source === "analysis_simulation"),
];
return { series, points: toTimeSeriesPoints(series) };
}
return fetchElementHistory(
elements,
range,
type === "none" ? "observed" : "realtime_comparison",
undefined,
signal,
);
};
const formatTimestamp = (timestamp: string) =>
@@ -186,11 +215,11 @@ const emptyStateMessages: Record<
> = {
chart: {
title: "暂无时序数据",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。",
},
table: {
title: "暂无表格数据",
subtitle: "请调整时间范围,或确认所选设备在该时段已有数据。",
subtitle: "请调整时间范围,或确认所选元素在该时段已有数据。",
},
};
@@ -204,35 +233,86 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
runId,
onClose,
}) => {
// 从 featureInfos 中提取设备 ID 列表
const deviceIds = useMemo(
() => featureInfos.map(([id]) => id),
[featureInfos]
const timeConfig = useTimelineTimeConfig();
const stepSeconds = parseHydraulicStepSeconds(timeConfig.properties["HYDRAULIC TIMESTEP"])
?? timeConfig.stepMinutes * 60;
const canRunSimulation = useAccessStore((state) =>
state.permissions.includes(permissionCodes.simulationRun),
);
const projectId = useProjectStore((state) => state.currentProjectId);
const [schemeState, setSchemeState] = useState<{
runId: string;
context?: AnalysisHistoryContext;
error?: string;
} | null>(null);
const [schemeReload, setSchemeReload] = useState(0);
const schemeReady = type !== "scheme" || !runId || schemeState?.runId === runId;
const schemeContext =
type === "scheme" && schemeState?.runId === runId
? schemeState?.context
: undefined;
const schemeError =
type === "scheme" && schemeState?.runId === runId
? schemeState?.error
: undefined;
const eventRange = schemeContext?.eventRange;
const rangeKey = JSON.stringify([type, runId]);
const defaultRange = useMemo(() => {
const now = dayjs();
const start = start_time ? dayjs(start_time) : null;
const end = end_time ? dayjs(end_time) : null;
const range = {
from: start?.isValid()
? start
: eventRange
? dayjs(eventRange.start).subtract(1, "hour")
: now.subtract(1, "day"),
to: end?.isValid()
? end
: eventRange
? dayjs(eventRange.end).add(1, "hour")
: now,
};
const aligned = alignSimulationRange({ from: range.from.toDate(), to: range.to.toDate() }, stepSeconds);
return { from: dayjs(aligned.from), to: dayjs(aligned.to) };
}, [start_time, end_time, eventRange, stepSeconds]);
const [selectedRange, setSelectedRange] = useState<{
key: string;
from: Dayjs;
to: Dayjs;
startTime?: string;
endTime?: string;
} | null>(null);
const rangeForContext = selectedRange?.key === rangeKey ? selectedRange : null;
const from = rangeForContext && (
rangeForContext.startTime === start_time || !start_time || !dayjs(start_time).isValid()
) ? rangeForContext.from : defaultRange.from;
const to = rangeForContext && (
rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid()
) ? rangeForContext.to : defaultRange.to;
const setFrom = (value: Dayjs) => {
const range = alignSimulationRange({ from: value.toDate(), to: to.toDate() }, stepSeconds);
setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time });
};
const setTo = (value: Dayjs) => {
const range = alignSimulationRange({ from: from.toDate(), to: value.toDate() }, stepSeconds);
setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time });
};
const [from, setFrom] = useState<Dayjs>(() => {
if (start_time) {
const parsedStart = dayjs(start_time);
if (parsedStart.isValid()) {
return parsedStart;
}
}
return dayjs().subtract(1, "day");
});
const [to, setTo] = useState<Dayjs>(() => {
if (end_time) {
const parsedEnd = dayjs(end_time);
if (parsedEnd.isValid()) {
return parsedEnd;
}
}
return dayjs();
});
useEffect(() => {
if (!rangeForContext || (
rangeForContext.startTime === start_time && rangeForContext.endTime === end_time
)) return;
setSelectedRange({ key: rangeKey, from, to, startTime: start_time, endTime: end_time });
}, [rangeForContext, rangeKey, from, to, start_time, end_time]);
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationError, setSimulationError] = useState<string | null>(null);
const simulationControllerRef = useRef<AbortController | null>(null);
const draggableRef = useRef<HTMLDivElement>(null);
const requestControllerRef = useRef<AbortController | null>(null);
@@ -241,22 +321,30 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}, [defaultTab]);
useEffect(() => {
if (!start_time && !end_time) return;
if (start_time) {
const parsedStart = dayjs(start_time);
if (parsedStart.isValid()) {
setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart));
}
}
if (end_time) {
const parsedEnd = dayjs(end_time);
if (parsedEnd.isValid()) {
setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd));
}
}
}, [start_time, end_time]);
if (type !== "scheme" || !runId) return;
let cancelled = false;
getAnalysisScheme(runId).then(
(run) => {
if (!cancelled) {
setSchemeState({ runId, context: getAnalysisHistoryContext(run) });
}
},
() => {
if (!cancelled) {
setSchemeState({ runId, error: "无法读取事件方案,请刷新重试" });
}
},
);
return () => {
cancelled = true;
};
}, [type, runId, schemeReload]);
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const normalizedRange = useMemo(() => {
const ordered = ensureValidRange(from, to);
const aligned = alignSimulationRange({ from: ordered.from.toDate(), to: ordered.to.toDate() }, stepSeconds);
return { from: dayjs(aligned.from), to: dayjs(aligned.to) };
}, [from, to, stepSeconds]);
const featureInfosKey = JSON.stringify(featureInfos);
const requestedFeatureInfos = useMemo(
@@ -265,6 +353,42 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const hasDevices = requestedFeatureInfos.length > 0;
const hasData = timeSeries.length > 0;
const simulationContext = JSON.stringify([
projectId, type, runId, featureInfosKey, start_time, end_time,
]);
const simulationContextRef = useRef(simulationContext);
useEffect(() => {
simulationContextRef.current = simulationContext;
setSimulationError(null);
}, [simulationContext]);
const handleOnlineSimulation = async () => {
if (simulationControllerRef.current || !canRunSimulation || !projectId || !hasDevices || !schemeReady || schemeError) return;
const controller = new AbortController();
simulationControllerRef.current = controller;
setIsSimulating(true);
setSimulationError(null);
try {
const result = await runOnlineSimulation({
from: normalizedRange.from.toDate(), to: normalizedRange.to.toDate(),
}, controller.signal);
if (controller.signal.aborted || simulationContextRef.current !== simulationContext) return;
// 更新查询范围会触发历史数据重查,读取刚保存的在线结果。
setSelectedRange({
key: rangeKey, from: dayjs(result.range.from), to: dayjs(result.range.to),
startTime: start_time, endTime: end_time,
});
} catch (err) {
if (!controller.signal.aborted && simulationContextRef.current === simulationContext) {
setSimulationError(err instanceof Error ? err.message : "在线模拟失败,请刷新确认已生成的数据");
}
} finally {
if (simulationControllerRef.current === controller) {
simulationControllerRef.current = null;
setIsSimulating(false);
}
}
};
const dataset = useMemo(
() => buildDataset(timeSeries, historySeries, fractionDigits),
@@ -272,9 +396,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const handleFetch = useCallback(
async (reason: string) => {
if (!hasDevices) {
async () => {
if (!hasDevices || !schemeReady || schemeError) {
requestControllerRef.current?.abort();
requestControllerRef.current = null;
setTimeSeries([]);
setHistorySeries([]);
setLoadingState("idle");
@@ -295,44 +420,52 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
from: rangeFrom.toDate(),
to: rangeTo.toDate(),
},
type,
runId,
type === "scheme" && schemeContext && !schemeContext.simulationRunId
? "realtime"
: type,
schemeContext?.simulationRunId,
requestController.signal,
);
if (requestControllerRef.current !== requestController) return;
if (
requestController.signal.aborted ||
requestControllerRef.current !== requestController
) return;
setTimeSeries(result.points);
setHistorySeries(result.series);
setLoadingState("success");
} catch (err) {
if (
requestControllerRef.current !== requestController ||
requestController.signal.aborted ||
(err instanceof Error && err.name === "AbortError")
) {
return;
}
setTimeSeries([]);
setHistorySeries([]);
setError(err instanceof Error ? err.message : "未知错误");
setLoadingState("error");
}
},
[requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
[
requestedFeatureInfos, hasDevices, normalizedRange,
schemeReady, schemeError, schemeContext, type,
],
);
useEffect(
() => () => {
requestControllerRef.current?.abort();
simulationControllerRef.current?.abort();
simulationControllerRef.current = null;
},
[],
);
// 设备变化时自动查询
// 元素、方案或查询范围变化时自动查询。
useEffect(() => {
if (hasDevices) {
handleFetch("device-change");
} else {
setTimeSeries([]);
setHistorySeries([]);
}
}, [handleFetch, hasDevices]);
void handleFetch();
}, [handleFetch]);
const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [
@@ -387,6 +520,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const renderEmpty = () => {
if (isSimulating) {
return <Box sx={{ display: "grid", placeItems: "center", height: "100%" }}>
<CircularProgress size={32} />
</Box>;
}
if (!hasDevices) {
return (
<PanelEmptyState
@@ -424,7 +562,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
"#3f51b5", // 靛蓝色
];
const xData = dataset.map((item) => item.label);
const hasFlowSeries = historySeries.some((item) => item.metric === "flow");
const hasPressureSeries = historySeries.some(
(item) => item.metric === "pressure",
@@ -449,31 +586,25 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
? "circle"
: metadata.source === "scada_raw"
? "diamond"
: "none",
symbolSize: isObserved ? 7 : 0,
: "circle",
symbolSize: isObserved ? 7 : 5,
showSymbol: isObserved,
sampling: "lttb",
connectNulls: !isObserved,
connectNulls: false,
itemStyle: {
color: colors[index % colors.length],
},
data: dataset.map((item) => item[key]),
lineStyle: isObserved ? { width: 0 } : undefined,
areaStyle: isObserved
? undefined
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
data: timeSeries
.filter((point) =>
Object.prototype.hasOwnProperty.call(point.values, key),
)
.map((point) => [
new Date(point.timestamp).getTime(), point.values[key] ?? null,
]),
lineStyle: {
width: isObserved ? 0 : 2,
type: metadata.source === "analysis_simulation" ? "dashed" : "solid",
},
},
];
});
@@ -484,20 +615,34 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
tooltip: {
trigger: "axis",
confine: true,
position: function (pt: any[]) {
return [pt[0], "10%"];
},
axisPointer: { type: "line", lineStyle: { color: "#94a3b8", type: "dashed" } },
valueFormatter: (value: number | string) => typeof value === "number"
? value.toLocaleString("zh-CN", { maximumSignificantDigits: 6, useGrouping: false })
: String(value ?? "--"),
},
legend: {
top: "top",
type: "scroll",
top: 8,
left: 20,
width: "90%",
itemWidth: 20,
itemHeight: 8,
itemGap: 20,
textStyle: { color: "#475569", fontSize: 12 },
},
grid: {
left: "5%",
right: "5%",
bottom: "11%",
top: 120,
left: 20,
right: 24,
bottom: 76,
containLabel: true,
},
toolbox: {
top: 36,
right: 16,
itemSize: 15,
itemGap: 16,
iconStyle: { borderColor: "#64748b" },
feature: {
dataZoom: {
yAxisIndex: "none",
@@ -507,9 +652,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
},
},
xAxis: {
type: "category",
type: "time",
boundaryGap: false,
data: xData,
min: normalizedRange.from.valueOf(),
max: normalizedRange.to.valueOf(),
minInterval: stepSeconds * 1000,
axisLabel: {
formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"),
hideOverlap: true,
color: "#64748b",
fontSize: 12,
lineHeight: 16,
margin: 12,
},
splitNumber: 6,
axisLine: { lineStyle: { color: "#cbd5e1" } },
axisTick: { show: false },
},
yAxis: [
...(hasFlowSeries
@@ -518,6 +676,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
type: "value",
scale: true,
name: `流量 (${FLOW_DISPLAY_UNIT})`,
nameTextStyle: { color: "#64748b", fontSize: 12 },
axisLabel: { color: "#64748b", fontSize: 12 },
splitLine: { lineStyle: { color: "#e2e8f0", type: "dashed" } },
},
]
: []),
@@ -527,6 +688,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
type: "value",
scale: true,
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
nameTextStyle: { color: "#64748b", fontSize: 12 },
axisLabel: { color: "#64748b", fontSize: 12 },
splitLine: { show: !hasFlowSeries, lineStyle: { color: "#e2e8f0", type: "dashed" } },
},
]
: []),
@@ -538,11 +702,46 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
end: 100,
},
{
type: "slider",
start: 0,
end: 100,
bottom: 12,
height: 24,
showDetail: false,
borderColor: "transparent",
backgroundColor: "#f5f7fa",
fillerColor: "rgba(25, 118, 210, 0.08)",
dataBackground: {
lineStyle: { color: "#94a3b8" },
areaStyle: { opacity: 0 },
},
selectedDataBackground: {
lineStyle: { color: "#1976d2" },
areaStyle: { opacity: 0 },
},
},
],
series: getSeries(),
series: getSeries().map((series, index) => ({
...series,
...(index === 0 && eventRange
? {
markArea: {
silent: true,
itemStyle: { color: "rgba(255, 152, 0, 0.06)" },
label: {
position: "insideTop",
distance: 10,
color: "#996515",
fontSize: 11,
},
data: [[
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
{ xAxis: Date.parse(eventRange.end) },
]],
},
}
: {}),
})),
};
return (
@@ -711,21 +910,12 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<DateTimePicker
label="开始时间"
value={from}
disabled={isSimulating}
onChange={(value) => {
if (value && dayjs.isDayjs(value) && value.isValid()) {
setFrom(value);
}
}}
onAccept={(value) => {
if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change");
}
}}
maxDateTime={to}
slotProps={{
textField: { fullWidth: true, size: "small" },
@@ -734,21 +924,12 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<DateTimePicker
label="结束时间"
value={to}
disabled={isSimulating}
onChange={(value) => {
if (value && dayjs.isDayjs(value) && value.isValid()) {
setTo(value);
}
}}
onAccept={(value) => {
if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change");
}
}}
minDateTime={from}
slotProps={{
textField: { fullWidth: true, size: "small" },
@@ -756,7 +937,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/>
</Stack>
<Stack
direction="row"
direction={{ xs: "column", sm: "row" }}
spacing={1}
alignItems="center"
justifyContent="space-between"
@@ -780,6 +961,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/>
</Tabs>
<Stack direction="row" spacing={1}>
{type !== "none" && (
<Tooltip title={canRunSimulation
? "按管网水力步长计算当前查询时段的在线模拟数据"
: "需要在线模拟运行权限"}>
<span>
<Button
variant="contained"
size="small"
startIcon={isSimulating ? <CircularProgress size={16} color="inherit" /> : <PlayArrow fontSize="small" />}
disabled={!canRunSimulation || !projectId || !hasDevices || !schemeReady || Boolean(schemeError) || isSimulating || loadingState === "loading"}
onClick={() => { void handleOnlineSimulation(); }}
>
{isSimulating ? "模拟中…" : "在线模拟"}
</Button>
</span>
</Tooltip>
)}
<Tooltip title="刷新数据">
<span>
<Button
@@ -787,8 +985,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
size="small"
color="primary"
startIcon={<Refresh fontSize="small" />}
disabled={!hasDevices || loadingState === "loading"}
onClick={() => handleFetch("manual")}
disabled={
!hasDevices || !schemeReady || isSimulating || loadingState === "loading"
}
onClick={() => {
if (schemeError) {
setSchemeState(null);
setSchemeReload((value) => value + 1);
} else {
void handleFetch();
}
}}
>
刷新
</Button>
@@ -799,13 +1006,34 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
</Stack>
</LocalizationProvider>
{error && (
{eventRange && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)}
</Typography>
)}
{isSimulating && (
<Typography role="status" variant="caption" sx={{ mt: 1, display: "block" }}>
正在计算在线模拟数据,完成后将自动刷新图表。
</Typography>
)}
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
</Typography>
)}
{simulationError && (
<Typography role="alert" variant="caption" color="error" sx={{ mt: 1, display: "block" }}>
在线模拟失败:{simulationError}
</Typography>
)}
{(schemeError || error) && (
<Typography
variant="caption"
color="error"
sx={{ mt: 1, display: "block" }}
>
获取数据失败:{error}
获取数据失败:{schemeError || error}
</Typography>
)}
</Box>
@@ -814,7 +1042,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
{/* Content */}
<Box sx={{ flex: 1, position: "relative", p: 2, overflow: "auto" }}>
{loadingState === "loading" && (
{(!schemeReady || loadingState === "loading") && (
<Box
sx={{
position: "absolute",
+5 -2
View File
@@ -132,8 +132,11 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
paper: {
sx: {
borderRadius: 2,
padding: 2,
minWidth: 400,
boxSizing: "border-box",
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)",
backdropFilter: "blur(10px)",
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" });
fireEvent.change(screen.getByRole("slider", { name: "建筑背景透明度 60%" }), {
target: { value: "0.35" },
@@ -35,7 +35,7 @@ const sceneModes: Array<{ mode: SceneMode; label: string }> = [
{ mode: "network", label: "供水管网" },
{ mode: "hydraulic", label: "泵组与管网" },
{ mode: "map", label: "站区轻量版" },
{ mode: "detail", label: "站房精细版" },
{ mode: "detail", label: "站区精细版" },
{ mode: "pump", label: "CAD 泵房参考" },
{ mode: "meters", label: "设备样件" },
];
+113
View File
@@ -21,6 +21,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/keycloak-user-syncs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Sync Keycloak User Directory */
post: operations["post_admin_keycloak_user_syncs"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/project-provisions": {
parameters: {
query?: never;
@@ -5149,6 +5166,26 @@ export interface components {
*/
HistorySource: "scada_raw" | "scada_cleaned" | "realtime_simulation" | "analysis_simulation";
JsonValue: unknown;
/** KeycloakUserSyncIssue */
KeycloakUserSyncIssue: {
/** Reason */
reason: string;
/** Username */
username: string;
};
/** KeycloakUserSyncResponse */
KeycloakUserSyncResponse: {
/** Created */
created: number;
/** Issues */
issues?: components["schemas"]["KeycloakUserSyncIssue"][];
/** Scanned */
scanned: number;
/** Skipped */
skipped: number;
/** Updated */
updated: number;
};
/** LeakageIdentifyRequestRest */
LeakageIdentifyRequestRest: {
/**
@@ -5995,6 +6032,80 @@ export interface operations {
};
};
};
post_admin_keycloak_user_syncs: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KeycloakUserSyncResponse"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
post_admin_project_provisions: {
parameters: {
query?: never;
@@ -28768,6 +28879,8 @@ export interface operations {
accident_element: string[];
/** @description 已故障的阀门ID列表(可选) */
disabled_valves?: string[];
/** @description 事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link */
accident_element_type?: "auto" | "link" | "node";
};
header: {
"X-Project-Id": string;
+47
View File
@@ -2,6 +2,7 @@ import { api } from "@/lib/api";
import {
getAnalysisResults,
getAnalysisScheme,
getAnalysisHistoryContext,
listAnalysisSchemes,
} from "./analysisRuns";
@@ -94,3 +95,49 @@ describe("analysis runs API adapter", () => {
);
});
});
describe("analysis history context", () => {
const run = {
run_id: "run-1", name: "event", run_type: "burst_analysis",
created_by: "alice", created_at: "2026-11-03T05:00:00Z",
started_at: "2026-08-25T09:00:00+08:00", status: "completed", parameters: {},
};
it.each([
["burst_analysis", { modify_total_duration: 3600 }],
["flushing_analysis", { duration: 3600 }],
["contaminant_analysis", { duration: 3600 }],
])("uses the simulated interval of %s rather than its creation time", (run_type, parameters) => {
expect(getAnalysisHistoryContext({ ...run, run_type, parameters })).toEqual({
simulationRunId: "run-1",
eventRange: { start: "2026-08-25T01:00:00.000Z", end: "2026-08-25T02:00:00.000Z" },
});
});
it("does not treat a detection run as a hydraulic simulation", () => {
expect(getAnalysisHistoryContext({
...run, run_type: "burst_detection", parameters: {
scada_window: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
},
})).toEqual({
eventRange: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
});
});
it("uses the linked hydraulic run for a localization analysis", () => {
expect(getAnalysisHistoryContext({
...run, run_type: "burst_location", parameters: {
result_payload: {
simulation_run: { run_id: "simulation-1" },
scada_window: { burst_start: "2026-08-25T01:00:00Z", burst_end: "2026-08-25T02:00:00Z" },
},
},
}).simulationRunId).toBe("simulation-1");
});
it("rejects invalid or missing event durations without inventing an interval", () => {
expect(getAnalysisHistoryContext(run).eventRange).toBeNull();
expect(getAnalysisHistoryContext({ ...run, parameters: { duration: -1 } }).eventRange).toBeNull();
expect(getAnalysisHistoryContext({ ...run, started_at: "invalid", parameters: { duration: 3600 } }).eventRange).toBeNull();
});
});
+60
View File
@@ -11,6 +11,66 @@ export type AnalysisRun = {
parameters: Record<string, unknown>;
};
export type AnalysisHistoryContext = {
simulationRunId?: string;
eventRange: { start: string; end: string } | null;
};
const SIMULATION_RUN_TYPES = new Set([
"burst_analysis",
"flushing_analysis",
"contaminant_analysis",
"valve_close_analysis",
"pressure_regulation",
]);
export const getAnalysisHistoryContext = (
run: AnalysisRun,
): AnalysisHistoryContext => {
const parameters = normalizeParameters(run.parameters);
if (SIMULATION_RUN_TYPES.has(run.run_type)) {
const start = Date.parse(run.started_at);
const duration = parameters.modify_total_duration ?? parameters.duration;
const end = new Date(
typeof duration === "number" ? start + duration * 1000 : NaN,
).getTime();
return {
simulationRunId: run.run_id,
eventRange:
Number.isFinite(start) && Number.isFinite(end) && end > start
? {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
}
: null,
};
}
const payload = normalizeParameters(parameters.result_payload);
const window = normalizeParameters(
parameters.scada_window ?? payload.scada_window,
);
const start = window.burst_start ?? window.start ?? parameters.scada_start;
const end = window.burst_end ?? window.end ?? parameters.scada_end;
const simulation = normalizeParameters(
payload.simulation_run ?? payload.simulation_scheme,
);
const simulationRunId = parameters.simulation_run_id ?? simulation.run_id;
return {
simulationRunId:
typeof simulationRunId === "string" && simulationRunId.trim()
? simulationRunId.trim()
: undefined,
eventRange:
typeof start === "string" &&
typeof end === "string" &&
Number.isFinite(Date.parse(start)) &&
Date.parse(end) > Date.parse(start)
? { start, end }
: null,
};
};
export type AnalysisResult = {
result_id: string;
run_id: string;
+40 -2
View File
@@ -28,8 +28,46 @@ describe("element history display conversion", () => {
];
const points = toTimeSeriesPoints(series);
expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z");
expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334);
expect(points[0].timestamp).toBe("2026-09-01T00:00:00.000Z");
expect(points[0].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(83.3333333334);
expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
});
it("aligns equivalent timestamps and keeps same-name pipes and junctions separate", () => {
const base: ElementHistorySeries = {
element_id: "shared-id", element_type: "pipe", device_id: null,
metric: "flow", source: "realtime_simulation", source_unit: "L/s",
display_unit: "m³/h", unit_inferred: false,
points: [{ time: "2026-09-01T08:00:00+08:00", value: 10 }],
};
const points = toTimeSeriesPoints([
base,
{ ...base, element_type: "junction", metric: "pressure", source_unit: "m",
points: [{ time: "2026-09-01T00:00:00Z", value: 20 }] },
{ ...base, source: "analysis_simulation", points: [
{ time: "2026-09-01T00:00:00.000Z", value: null },
] },
]);
expect(points).toHaveLength(1);
expect(points[0].values).toEqual({
'["pipe","shared-id","realtime_simulation"]': 36,
'["junction","shared-id","realtime_simulation"]': 20,
'["pipe","shared-id","analysis_simulation"]': null,
});
});
it("keeps online IDs ending in _scheme distinct from another element's scheme series", () => {
const base: ElementHistorySeries = {
element_id: "P_scheme", element_type: "pipe", device_id: null,
metric: "flow", source: "realtime_simulation", source_unit: "m3/h",
display_unit: "m³/h", unit_inferred: false,
points: [{ time: "2026-09-01T00:00:00Z", value: 100 }],
};
const points = toTimeSeriesPoints([
base,
{ ...base, element_id: "P", source: "analysis_simulation",
points: [{ time: "2026-09-01T00:00:00Z", value: 5 }] },
]);
expect(Object.values(points[0].values).sort((a, b) => Number(a) - Number(b))).toEqual([5, 100]);
});
});
+12 -6
View File
@@ -49,8 +49,10 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
analysis_simulation: "scheme_sim",
};
export const historySeriesKey = (series: ElementHistorySeries) =>
`${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`;
export const historySeriesKey = (series: ElementHistorySeries) => {
if (series.device_id) return `${series.device_id}_${SOURCE_SUFFIX[series.source]}`;
return JSON.stringify([series.element_type, series.element_id, series.source]);
};
export const historySourceLabel = (source: HistorySource) => {
switch (source) {
@@ -65,8 +67,11 @@ export const historySourceLabel = (source: HistorySource) => {
}
};
export const historySeriesLabel = (series: ElementHistorySeries) =>
`${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`;
export const historySeriesLabel = (series: ElementHistorySeries) => {
const target = series.device_id ??
`${series.element_type === "pipe" ? "管道" : "节点"} ${series.element_id}`;
return `${target} (${historySourceLabel(series.source)})`;
};
export const toTimeSeriesPoints = (
seriesList: ElementHistorySeries[],
@@ -75,13 +80,14 @@ export const toTimeSeriesPoints = (
seriesList.forEach((series) => {
const key = historySeriesKey(series);
series.points.forEach((point) => {
const values = timeMap.get(point.time) ?? {};
const timestamp = new Date(point.time).toISOString();
const values = timeMap.get(timestamp) ?? {};
values[key] = toDisplayValue(
point.value,
series.metric,
series.source_unit,
);
timeMap.set(point.time, values);
timeMap.set(timestamp, values);
});
});
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(
+25
View File
@@ -1,4 +1,8 @@
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
import {
clearIntentionalLogout,
isIntentionalLogout,
} from "./logoutIntent";
describe("reportLogoutAudit", () => {
it("starts an authenticated keepalive request", async () => {
@@ -28,6 +32,10 @@ describe("reportLogoutAudit", () => {
});
describe("completeLogout", () => {
beforeEach(() => {
clearIntentionalLogout();
});
it("navigates immediately without waiting for audit reporting", async () => {
const reportAudit = jest.fn(
() => new Promise<unknown>(() => undefined),
@@ -44,6 +52,7 @@ describe("completeLogout", () => {
await Promise.resolve();
expect(reportAudit).toHaveBeenCalledTimes(1);
expect(isIntentionalLogout()).toBe(true);
expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
await expect(result).resolves.toEqual({ success: true });
@@ -79,4 +88,20 @@ describe("completeLogout", () => {
expect(clearLocalState).toHaveBeenCalledTimes(1);
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";
type CompleteLogoutOptions = {
@@ -41,7 +43,12 @@ export const completeLogout = async ({
// 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);
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;
};
+86
View File
@@ -0,0 +1,86 @@
import { apiFetch } from "@/lib/apiFetch";
import { useProjectStore } from "@/store/projectStore";
import { alignSimulationRange, parseHydraulicStepSeconds, runOnlineSimulation } from "./onlineSimulation";
jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn() }));
const response = (body: unknown, ok = true) => ({
ok, json: async () => body,
}) as Response;
describe("online simulation time alignment", () => {
it.each([["0:15", 900], ["00:05:00", 300], ["1:00", 3600], ["0:00:30", 30]])(
"reads the configured step %s", (clock, seconds) => {
expect(parseHydraulicStepSeconds(clock)).toBe(seconds);
},
);
it.each(["0:00", "0:60", "0:00:60", "15", "invalid", undefined])(
"does not invent a step from invalid config %s", (clock) => {
expect(parseHydraulicStepSeconds(clock)).toBeUndefined();
},
);
it("aligns the screenshot's range to 15-minute clock boundaries", () => {
const range = alignSimulationRange({
from: new Date("2026-07-30T12:38:42+08:00"),
to: new Date("2026-07-30T15:38:42+08:00"),
}, 900);
expect(range.from.toISOString()).toBe("2026-07-30T04:30:00.000Z");
expect(range.to.toISOString()).toBe("2026-07-30T07:45:00.000Z");
});
it("keeps aligned endpoints and handles ranges crossing midnight", () => {
const range = alignSimulationRange({
from: new Date("2026-07-30T23:50:00+08:00"),
to: new Date("2026-07-31T00:10:00+08:00"),
}, 900);
expect(range.from.toISOString()).toBe("2026-07-30T15:45:00.000Z");
expect(range.to.toISOString()).toBe("2026-07-30T16:15:00.000Z");
expect(alignSimulationRange(range, 900)).toEqual(range);
});
});
describe("online simulation request", () => {
const range = {
from: new Date("2026-07-30T12:38:00+08:00"),
to: new Date("2026-07-30T15:38:00+08:00"),
};
beforeEach(() => {
jest.mocked(apiFetch).mockReset();
useProjectStore.getState().setCurrentProject("project-1", "network-1");
});
it("runs the full snapped range including the right endpoint, without network fields", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(response({ times: { "HYDRAULIC TIMESTEP": "0:15" } }))
.mockResolvedValueOnce(response({ status: "success" }));
const result = await runOnlineSimulation(range);
expect(result.range.to.toISOString()).toBe("2026-07-30T07:45:00.000Z");
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toEqual({
start_time: "2026-07-30T04:30:00.000Z", duration: 210,
});
expect(String(jest.mocked(apiFetch).mock.calls[1][0])).toContain("/api/v1/simulation-runs");
});
it("refuses to calculate with a missing hydraulic step", async () => {
jest.mocked(apiFetch).mockResolvedValue(response({}));
await expect(runOnlineSimulation(range)).rejects.toThrow("水力时间步长无效");
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("does not submit a write if the project changed during the config read", async () => {
jest.mocked(apiFetch).mockImplementationOnce(async () => {
useProjectStore.getState().setCurrentProject("project-2", "network-2");
return response({ "HYDRAULIC TIMESTEP": "0:15" });
});
await expect(runOnlineSimulation(range)).rejects.toThrow("项目已切换");
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("does not retry or treat a queued acknowledgment as completion", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(response({ "HYDRAULIC TIMESTEP": "0:15" }))
.mockResolvedValueOnce(response({ status: "queued" }));
await expect(runOnlineSimulation(range)).rejects.toThrow("未确认完成");
expect(apiFetch).toHaveBeenCalledTimes(2);
});
});
+79
View File
@@ -0,0 +1,79 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { useProjectStore } from "@/store/projectStore";
dayjs.extend(utc);
dayjs.extend(timezone);
export type SimulationRange = { from: Date; to: Date };
export const parseHydraulicStepSeconds = (value: unknown): number | undefined => {
if (typeof value !== "string" || !/^\d+:\d{1,2}(:\d{1,2})?$/.test(value.trim())) {
return undefined;
}
const [hours, minutes, seconds = 0] = value.trim().split(":").map(Number);
const step = hours * 3600 + minutes * 60 + seconds;
return minutes < 60 && seconds < 60 && step > 0 ? step : undefined;
};
/** 与地图时间轴一致,以北京时间零点为基准向外取整,跨日仍保持同一步长。 */
export const alignSimulationRange = (
range: SimulationRange,
stepSeconds: number,
): SimulationRange => {
if (!Number.isFinite(stepSeconds) || stepSeconds <= 0 ||
!Number.isFinite(range.from.getTime()) || !Number.isFinite(range.to.getTime())) {
throw new Error("查询时间或水力时间步长无效");
}
const start = Math.min(range.from.getTime(), range.to.getTime());
const end = Math.max(range.from.getTime(), range.to.getTime());
const origin = dayjs(start).tz("Asia/Shanghai").startOf("day").valueOf();
const step = stepSeconds * 1000;
const from = origin + Math.floor((start - origin) / step) * step;
const to = Math.max(from + step, origin + Math.ceil((end - origin) / step) * step);
return { from: new Date(from), to: new Date(to) };
};
/** 复用同步在线模拟接口,成功响应表示计算和保存已完成;不自动重试写请求。 */
export const runOnlineSimulation = async (
range: SimulationRange,
signal?: AbortSignal,
): Promise<{ range: SimulationRange; stepSeconds: number }> => {
const projectId = useProjectStore.getState().currentProjectId;
if (!projectId) throw new Error("请先选择项目,未启动在线模拟");
const projectHeaders = { "X-Project-Id": projectId };
const timeResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/network-options/time`, {
signal, headers: projectHeaders, projectHeaderMode: "omit",
},
);
if (!timeResponse.ok) throw new Error("无法读取管网时间步长,未启动在线模拟");
const payload = await timeResponse.json();
const stepSeconds = parseHydraulicStepSeconds(
(payload?.times ?? payload)?.["HYDRAULIC TIMESTEP"],
);
if (!stepSeconds) throw new Error("管网水力时间步长无效,未启动在线模拟");
if (signal?.aborted || useProjectStore.getState().currentProjectId !== projectId) {
throw new Error("项目已切换或面板已关闭,未启动在线模拟");
}
const alignedRange = alignSimulationRange(range, stepSeconds);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/simulation-runs`, {
method: "POST",
headers: { ...projectHeaders, "Content-Type": "application/json" },
projectHeaderMode: "omit",
// 后端循环的结束时间不包含在计算内,多算一个步长以覆盖图表右端点。
body: JSON.stringify({
start_time: alignedRange.from.toISOString(),
duration: Math.ceil((alignedRange.to.getTime() - alignedRange.from.getTime() + stepSeconds * 1000) / 60000),
}),
signal,
});
const result = await response.json().catch(() => null);
if (!response.ok || result?.status !== "success") {
throw new Error(typeof result?.detail === "string" ? result.detail : "在线模拟未确认完成,请刷新查看已生成的数据");
}
return { range: alignedRange, stepSeconds };
};
+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 { isIntentionalLogout } from "@/lib/logoutIntent";
interface AuthState {
accessToken: string | null;
sessionExpired: boolean;
@@ -16,11 +18,21 @@ export const useAuthStore = create<AuthState>((set) => ({
sessionExpired: false,
sessionExpiryReason: null,
setAccessToken: (token) => set({ accessToken: token }),
markSessionExpired: (reason) => set({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: reason,
}),
markSessionExpired: (reason) => {
if (reason === "unauthorized" && isIntentionalLogout()) {
set({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
return;
}
set({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: reason,
});
},
clearSessionExpired: () => set({
sessionExpired: false,
sessionExpiryReason: null,