Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c8fc9db8 | ||
|
|
95ec090f2e | ||
|
|
e3ab82f6df | ||
|
|
5a0bb6864a | ||
|
|
80f05e481b | ||
|
|
6ff5322353 | ||
|
|
ca1c41aa9d | ||
|
|
7f1ab4a79c | ||
|
|
e67f9542cd |
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
|
||||
"sha256": "1edfd78405cfe2a50022afe70a4c4f32caa4e98e3b5b54b40841d532058f2fd6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@ 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 +23,8 @@ let mockSessionState: {
|
||||
},
|
||||
status: "authenticated",
|
||||
};
|
||||
let mockPathname = "/network-simulation";
|
||||
let mockProjectProviderBlocks = false;
|
||||
|
||||
jest.mock("next-auth/react", () => ({
|
||||
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
||||
@@ -26,7 +33,7 @@ jest.mock("next-auth/react", () => ({
|
||||
}));
|
||||
|
||||
jest.mock("next/navigation", () => ({
|
||||
usePathname: () => "/network-simulation",
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
@@ -43,6 +50,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 +63,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 +75,9 @@ describe("RefineContext access authentication", () => {
|
||||
const originalRequest = global.Request;
|
||||
|
||||
beforeEach(() => {
|
||||
clearIntentionalLogout();
|
||||
mockPathname = "/network-simulation";
|
||||
mockProjectProviderBlocks = false;
|
||||
mockSessionState = {
|
||||
data: {
|
||||
accessToken: "expired-access-token",
|
||||
@@ -115,6 +131,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 +198,81 @@ 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("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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+84
-44
@@ -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,6 +189,28 @@ 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", {
|
||||
@@ -165,24 +219,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
});
|
||||
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 = {
|
||||
|
||||
@@ -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 个。",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -8,6 +8,12 @@ jest.mock("next/navigation", () => ({
|
||||
usePathname: () => "/network-simulation",
|
||||
}));
|
||||
|
||||
jest.mock("@/components/header", () => ({
|
||||
Header: ({ accessMode }: { accessMode: string }) => (
|
||||
<div data-testid="access-header">{accessMode}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("RoutePermissionGuard", () => {
|
||||
beforeEach(() => {
|
||||
useAccessStore.setState({
|
||||
@@ -52,6 +58,34 @@ describe("RoutePermissionGuard", () => {
|
||||
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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("checks authentication before the expired state effect runs", () => {
|
||||
render(
|
||||
<RoutePermissionGuard authenticated={false}>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Header } from "@/components/header";
|
||||
import { permissionForPath } from "@/lib/permissions";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
@@ -12,9 +19,11 @@ import { useAuthStore } from "@/store/authStore";
|
||||
export const RoutePermissionGuard = ({
|
||||
children,
|
||||
authenticated,
|
||||
accessDeniedReason = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
authenticated: boolean;
|
||||
accessDeniedReason?: "account" | "project" | null;
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
const permissions = useAccessStore((state) => state.permissions);
|
||||
@@ -37,6 +46,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" }}>
|
||||
|
||||
@@ -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,27 +117,32 @@ 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",
|
||||
border: "1px solid transparent",
|
||||
"&:hover": {
|
||||
backgroundColor:
|
||||
mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.05)"
|
||||
: "rgba(0, 0, 0, 0.04)",
|
||||
transform: "translateY(-1px)",
|
||||
border: `1px solid ${mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.2)"
|
||||
: "rgba(0, 0, 0, 0.1)"
|
||||
@@ -141,7 +153,6 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
: "0 4px 12px rgba(0,0,0,0.08)",
|
||||
},
|
||||
"&:active": {
|
||||
transform: "translateY(0px)",
|
||||
boxShadow: "none",
|
||||
},
|
||||
}}
|
||||
@@ -176,13 +187,13 @@ export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(0,0,0,0.1)"
|
||||
}`,
|
||||
transition: "transform 0.3s ease",
|
||||
".MuiButtonBase-root:hover &": {
|
||||
transform: "rotate(5deg) scale(1.05)",
|
||||
borderColor: "primary.main",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{!user?.avatar && <FiUser aria-hidden="true" />}
|
||||
</Avatar>
|
||||
</Stack>
|
||||
</ButtonBase>
|
||||
<Menu
|
||||
@@ -208,13 +219,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 +235,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>
|
||||
|
||||
@@ -4,6 +4,8 @@ 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", () => ({
|
||||
@@ -29,6 +31,11 @@ jest.mock("@mui/x-date-pickers", () => ({
|
||||
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
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) =>
|
||||
({
|
||||
@@ -177,6 +184,8 @@ describe("HistoryDataPanel requests", () => {
|
||||
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());
|
||||
@@ -302,6 +311,7 @@ describe("HistoryDataPanel requests", () => {
|
||||
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);
|
||||
@@ -341,4 +351,58 @@ describe("HistoryDataPanel requests", () => {
|
||||
]);
|
||||
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";
|
||||
@@ -49,6 +48,15 @@ import {
|
||||
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,
|
||||
@@ -225,6 +233,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
runId,
|
||||
onClose,
|
||||
}) => {
|
||||
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;
|
||||
@@ -246,7 +261,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const now = dayjs();
|
||||
const start = start_time ? dayjs(start_time) : null;
|
||||
const end = end_time ? dayjs(end_time) : null;
|
||||
return {
|
||||
const range = {
|
||||
from: start?.isValid()
|
||||
? start
|
||||
: eventRange
|
||||
@@ -258,7 +273,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
? dayjs(eventRange.end).add(1, "hour")
|
||||
: now,
|
||||
};
|
||||
}, [start_time, end_time, eventRange]);
|
||||
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;
|
||||
@@ -273,10 +290,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const to = rangeForContext && (
|
||||
rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid()
|
||||
) ? rangeForContext.to : defaultRange.to;
|
||||
const setFrom = (value: Dayjs) =>
|
||||
setSelectedRange({ key: rangeKey, from: value, to, startTime: start_time, endTime: end_time });
|
||||
const setTo = (value: Dayjs) =>
|
||||
setSelectedRange({ key: rangeKey, from, to: value, startTime: start_time, endTime: end_time });
|
||||
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 });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!rangeForContext || (
|
||||
@@ -289,6 +310,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
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);
|
||||
|
||||
@@ -316,7 +340,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
};
|
||||
}, [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(
|
||||
@@ -325,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),
|
||||
@@ -392,6 +456,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
useEffect(
|
||||
() => () => {
|
||||
requestControllerRef.current?.abort();
|
||||
simulationControllerRef.current?.abort();
|
||||
simulationControllerRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -454,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
|
||||
@@ -515,8 +586,8 @@ 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: false,
|
||||
@@ -530,22 +601,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
.map((point) => [
|
||||
new Date(point.timestamp).getTime(), point.values[key] ?? null,
|
||||
]),
|
||||
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,
|
||||
},
|
||||
lineStyle: {
|
||||
width: isObserved ? 0 : 2,
|
||||
type: metadata.source === "analysis_simulation" ? "dashed" : "solid",
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -556,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",
|
||||
@@ -583,9 +656,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
boundaryGap: false,
|
||||
min: normalizedRange.from.valueOf(),
|
||||
max: normalizedRange.to.valueOf(),
|
||||
minInterval: stepSeconds * 1000,
|
||||
axisLabel: {
|
||||
formatter: (value: number) => formatTimestamp(new Date(value).toISOString()),
|
||||
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
|
||||
@@ -594,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" } },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -603,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" } },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -614,8 +702,23 @@ 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().map((series, index) => ({
|
||||
@@ -624,7 +727,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
? {
|
||||
markArea: {
|
||||
silent: true,
|
||||
itemStyle: { color: "rgba(255, 152, 0, 0.10)" },
|
||||
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) },
|
||||
@@ -801,6 +910,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
<DateTimePicker
|
||||
label="开始时间"
|
||||
value={from}
|
||||
disabled={isSimulating}
|
||||
onChange={(value) => {
|
||||
if (value && dayjs.isDayjs(value) && value.isValid()) {
|
||||
setFrom(value);
|
||||
@@ -814,6 +924,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
<DateTimePicker
|
||||
label="结束时间"
|
||||
value={to}
|
||||
disabled={isSimulating}
|
||||
onChange={(value) => {
|
||||
if (value && dayjs.isDayjs(value) && value.isValid()) {
|
||||
setTo(value);
|
||||
@@ -826,7 +937,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
@@ -850,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
|
||||
@@ -858,7 +986,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
color="primary"
|
||||
startIcon={<Refresh fontSize="small" />}
|
||||
disabled={
|
||||
!hasDevices || !schemeReady || loadingState === "loading"
|
||||
!hasDevices || !schemeReady || isSimulating || loadingState === "loading"
|
||||
}
|
||||
onClick={() => {
|
||||
if (schemeError) {
|
||||
@@ -883,6 +1011,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
事件方案时段:{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"
|
||||
|
||||
@@ -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: "设备样件" },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user