Compare commits

...
Author SHA1 Message Date
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
12 changed files with 815 additions and 55 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",
+40
View File
@@ -15,3 +15,43 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
await expect(page.getByRole("heading", { name: "查询结果" })).toBeVisible();
});
test("已认证但未授权的账号会看到明确说明", async ({ page }) => {
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();
await expect(page.getByText("TJWater 智慧水务平台")).toBeVisible();
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByText("账号尚未获得访问权限")).toBeVisible();
await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible();
});
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, []);
}
+49
View File
@@ -115,6 +115,55 @@ describe("RefineContext access authentication", () => {
});
});
it("shows an authorization message when access-context rejects the account", async () => {
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.getByRole("button", { name: "退出登录" }),
).toBeInTheDocument();
expect(screen.getByText("Test User")).toBeInTheDocument();
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.getByRole("button", { name: "切换项目" }),
).toBeInTheDocument();
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
});
it("prioritizes an unauthenticated session over route permissions", async () => {
mockSessionState = { data: null, status: "unauthenticated" };
+57 -20
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";
@@ -68,6 +68,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const setCurrentProject = useProjectStore((state) => state.setCurrentProject);
const currentProjectCode = useProjectStore(
(state) => state.currentProjectCode,
);
@@ -75,6 +76,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(() => {
@@ -103,21 +107,39 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
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 +179,32 @@ 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: () => {
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
clearSessionRecoveryDrafts();
},
navigate: (path) => window.location.assign(path),
});
const handleChooseAnotherProject = () => {
setCurrentProject(null, null);
localStorage.removeItem("MAP_WORKSPACE");
localStorage.removeItem("MAP_EXTENT");
window.location.assign("/");
};
const authProvider: AuthProvider = {
login: async () => {
signIn("keycloak", {
@@ -165,24 +213,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 };
},
@@ -392,7 +423,13 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
}}
>
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard authenticated={status === "authenticated"}>
<RoutePermissionGuard
authenticated={status === "authenticated"}
accessDeniedReason={accessDeniedReason}
userName={data?.user?.name ?? data?.user?.username}
onLogout={() => void handleLogout()}
onChooseAnotherProject={handleChooseAnotherProject}
>
{props.children}
</RoutePermissionGuard>
<RefineKbar />
@@ -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>
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
@@ -52,6 +52,46 @@ describe("RoutePermissionGuard", () => {
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
});
it("keeps a minimal identity header and logout action for pending accounts", () => {
const onLogout = jest.fn();
render(
<RoutePermissionGuard
authenticated
accessDeniedReason="account"
userName="zjb"
onLogout={onLogout}
>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("TJWater 智慧水务平台")).toBeInTheDocument();
expect(screen.getByText("当前登录账号")).toBeInTheDocument();
expect(screen.getByText("zjb")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "退出登录" }));
expect(onLogout).toHaveBeenCalledTimes(1);
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
});
it("keeps project recovery available when only the selected project is denied", () => {
const onChooseAnotherProject = jest.fn();
render(
<RoutePermissionGuard
authenticated
accessDeniedReason="project"
onChooseAnotherProject={onChooseAnotherProject}
>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("无权访问当前项目")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "切换项目" }));
expect(onChooseAnotherProject).toHaveBeenCalledTimes(1);
});
it("checks authentication before the expired state effect runs", () => {
render(
<RoutePermissionGuard authenticated={false}>
+114 -1
View File
@@ -1,10 +1,23 @@
"use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
import LogoutIcon from "@mui/icons-material/Logout";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import {
Alert,
AppBar,
Avatar,
Box,
Button,
CircularProgress,
Stack,
Toolbar,
Typography,
} from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { Title } from "@/components/title";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
@@ -12,9 +25,17 @@ import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({
children,
authenticated,
accessDeniedReason = null,
userName,
onLogout,
onChooseAnotherProject,
}: {
children: ReactNode;
authenticated: boolean;
accessDeniedReason?: "account" | "project" | null;
userName?: string | null;
onLogout?: () => void;
onChooseAnotherProject?: () => void;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
@@ -37,6 +58,98 @@ export const RoutePermissionGuard = ({
);
}
if (accessDeniedReason) {
const displayName = userName?.trim() || "已登录账号";
const accountPending = accessDeniedReason === "account";
return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<AppBar
color="inherit"
elevation={0}
position="static"
sx={{ borderBottom: 1, borderColor: "divider" }}
>
<Toolbar sx={{ gap: 2, justifyContent: "space-between" }}>
<Title />
<Stack direction="row" spacing={1.5} alignItems="center">
<Avatar sx={{ width: 32, height: 32, bgcolor: "primary.main" }}>
{displayName.slice(0, 1).toUpperCase()}
</Avatar>
<Box sx={{ display: { xs: "none", sm: "block" } }}>
<Typography variant="caption" color="text.secondary">
当前登录账号
</Typography>
<Typography variant="body2" fontWeight={600}>
{displayName}
</Typography>
</Box>
{!accountPending && (
<Button
aria-label="切换项目"
color="inherit"
onClick={onChooseAnotherProject}
sx={{
gap: { xs: 0, sm: 1 },
minWidth: { xs: 40, sm: "auto" },
px: { xs: 1, sm: 2 },
"&:active": { transform: "translateY(1px)" },
}}
variant="outlined"
>
<SwapHorizIcon fontSize="small" />
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
切换项目
</Box>
</Button>
)}
<Button
aria-label="退出登录"
color="inherit"
onClick={onLogout}
sx={{
gap: { xs: 0, sm: 1 },
minWidth: { xs: 40, sm: "auto" },
px: { xs: 1, sm: 2 },
"&:active": { transform: "translateY(1px)" },
}}
variant="outlined"
>
<LogoutIcon fontSize="small" />
<Box component="span" sx={{ display: { xs: "none", sm: "inline" } }}>
退出登录
</Box>
</Button>
</Stack>
</Toolbar>
</AppBar>
<Box
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" }}>
@@ -26,7 +26,6 @@ import { Close, PlayArrow, Refresh, ShowChart, TableChart } from "@mui/icons-mat
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";
@@ -587,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,
@@ -602,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",
},
},
];
});
@@ -628,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",
@@ -659,7 +660,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
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
@@ -668,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" } },
},
]
: []),
@@ -677,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" } },
},
]
: []),
@@ -688,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) => ({
@@ -698,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) },
+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;