Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca1c41aa9d | ||
|
|
7f1ab4a79c |
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"file": "server-v1.openapi.json",
|
"file": "server-v1.openapi.json",
|
||||||
"sha256": "b565d841061c9091f48ff3118bcc0cbb1b918b0cb8c2316d177570e8b2d8ba29"
|
"sha256": "19961572fad04f8545a4f607d4bf7276de64a84b266daecbb21d74e0975f97ef"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,3 +15,37 @@ test("已登录管理员可以打开审计日志页面", async ({ page }) => {
|
|||||||
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
|
await expect(page.getByText("系统管理员权限已验证")).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "查询结果" })).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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
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 (
|
if (
|
||||||
url.pathname === "/api/v1/audit-logs" ||
|
url.pathname === "/api/v1/audit-logs" ||
|
||||||
url.pathname === "/api/v1/admin/users" ||
|
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, []);
|
return json(route, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,29 @@ 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(),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("账号尚未获得访问权限"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("应用内容")).not.toBeInTheDocument();
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("prioritizes an unauthenticated session over route permissions", async () => {
|
it("prioritizes an unauthenticated session over route permissions", async () => {
|
||||||
mockSessionState = { data: null, status: "unauthenticated" };
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
|||||||
import { RefineSnackbarProvider } from "@refinedev/mui";
|
import { RefineSnackbarProvider } from "@refinedev/mui";
|
||||||
import { SessionProvider, signIn, useSession } from "next-auth/react";
|
import { SessionProvider, signIn, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import routerProvider from "@refinedev/nextjs-router";
|
import routerProvider from "@refinedev/nextjs-router";
|
||||||
import { NavigationLink } from "@components/loading/NavigationLink";
|
import { NavigationLink } from "@components/loading/NavigationLink";
|
||||||
@@ -75,6 +75,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const setAccessContext = useAccessStore((state) => state.setContext);
|
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||||
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
const setAccessLoading = useAccessStore((state) => state.setLoading);
|
||||||
const resetAccess = useAccessStore((state) => state.reset);
|
const resetAccess = useAccessStore((state) => state.reset);
|
||||||
|
const [accessDenied, setAccessDenied] = useState(false);
|
||||||
const can = (permission: string) => permissions.includes(permission);
|
const can = (permission: string) => permissions.includes(permission);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,21 +104,29 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
|
setAccessDenied(false);
|
||||||
resetAccess();
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setAccessDenied(false);
|
||||||
setAccessLoading(true);
|
setAccessLoading(true);
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
|
||||||
projectHeaderMode: currentProjectId ? "include" : "omit",
|
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (response.status === 403) {
|
||||||
|
setAccessDenied(true);
|
||||||
|
resetAccess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
resetAccess();
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setAccessDenied(false);
|
||||||
setAccessContext(await response.json());
|
setAccessContext(await response.json());
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -392,7 +401,10 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||||
<RoutePermissionGuard authenticated={status === "authenticated"}>
|
<RoutePermissionGuard
|
||||||
|
authenticated={status === "authenticated"}
|
||||||
|
accessDenied={accessDenied}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</RoutePermissionGuard>
|
</RoutePermissionGuard>
|
||||||
<RefineKbar />
|
<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 个。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
Save as SaveIcon,
|
Save as SaveIcon,
|
||||||
Security as SecurityIcon,
|
Security as SecurityIcon,
|
||||||
Storage as StorageIcon,
|
Storage as StorageIcon,
|
||||||
|
Sync as SyncIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
@@ -70,6 +71,17 @@ type MetadataUser = {
|
|||||||
is_superuser: boolean;
|
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 = {
|
type ProjectMember = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -469,6 +481,9 @@ export const SystemAdminPanel = () => {
|
|||||||
const [adminChecked, setAdminChecked] = useState(false);
|
const [adminChecked, setAdminChecked] = useState(false);
|
||||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||||
const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true);
|
const [metadataConfigAvailable, setMetadataConfigAvailable] = useState(true);
|
||||||
|
const [syncingKeycloakUsers, setSyncingKeycloakUsers] = useState(false);
|
||||||
|
const [keycloakSyncResult, setKeycloakSyncResult] =
|
||||||
|
useState<KeycloakUserSyncResult | null>(null);
|
||||||
const [projectId, setProjectId] = useState(currentProjectId ?? "");
|
const [projectId, setProjectId] = useState(currentProjectId ?? "");
|
||||||
const [memberForm, setMemberForm] = useState({
|
const [memberForm, setMemberForm] = useState({
|
||||||
user_id: "",
|
user_id: "",
|
||||||
@@ -564,6 +579,33 @@ export const SystemAdminPanel = () => {
|
|||||||
setUsers(await response.json());
|
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) => {
|
const applyProjectForm = useCallback((project: AdminProject | null) => {
|
||||||
if (!project) {
|
if (!project) {
|
||||||
setProjectForm(defaultProjectForm);
|
setProjectForm(defaultProjectForm);
|
||||||
@@ -1238,13 +1280,44 @@ export const SystemAdminPanel = () => {
|
|||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="系统用户"
|
title="系统用户"
|
||||||
description="Keycloak 信息只读展示;这里维护系统角色和启用状态。超级管理员由后台初始化或受控脚本设置,不在页面修改。"
|
description="从 Keycloak 同步身份后,在这里维护系统角色和启用状态。新用户默认停用,项目权限仍需单独配置。"
|
||||||
action={
|
action={
|
||||||
<Button startIcon={<RefreshIcon />} variant="outlined" onClick={loadUsers}>
|
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
|
||||||
刷新
|
<Button
|
||||||
</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 }}>
|
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 2 }}>
|
||||||
<Table size="small" sx={tableSx}>
|
<Table size="small" sx={tableSx}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { useAuthStore } from "@/store/authStore";
|
|||||||
export const RoutePermissionGuard = ({
|
export const RoutePermissionGuard = ({
|
||||||
children,
|
children,
|
||||||
authenticated,
|
authenticated,
|
||||||
|
accessDenied = false,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
|
accessDenied?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
@@ -37,6 +39,21 @@ export const RoutePermissionGuard = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessDenied) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: { xs: 2, md: 4 } }}>
|
||||||
|
<Alert severity="warning" icon={<LockOutlinedIcon />}>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography fontWeight={700}>账号尚未获得访问权限</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
身份认证已成功,但当前账号尚未由系统管理员启用,或无权访问所选项目。请联系系统管理员完成授权后重试。
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (requiredPermission && loading) {
|
if (requiredPermission && loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
<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 { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
import { zhCN } from "@mui/x-data-grid/locales";
|
import { zhCN } from "@mui/x-data-grid/locales";
|
||||||
import ReactECharts from "echarts-for-react";
|
import ReactECharts from "echarts-for-react";
|
||||||
import * as echarts from "echarts";
|
|
||||||
import "dayjs/locale/zh-cn"; // 引入中文包
|
import "dayjs/locale/zh-cn"; // 引入中文包
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
@@ -587,8 +586,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
? "circle"
|
? "circle"
|
||||||
: metadata.source === "scada_raw"
|
: metadata.source === "scada_raw"
|
||||||
? "diamond"
|
? "diamond"
|
||||||
: "none",
|
: "circle",
|
||||||
symbolSize: isObserved ? 7 : 0,
|
symbolSize: isObserved ? 7 : 5,
|
||||||
showSymbol: isObserved,
|
showSymbol: isObserved,
|
||||||
sampling: "lttb",
|
sampling: "lttb",
|
||||||
connectNulls: false,
|
connectNulls: false,
|
||||||
@@ -602,22 +601,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
.map((point) => [
|
.map((point) => [
|
||||||
new Date(point.timestamp).getTime(), point.values[key] ?? null,
|
new Date(point.timestamp).getTime(), point.values[key] ?? null,
|
||||||
]),
|
]),
|
||||||
lineStyle: isObserved ? { width: 0 } : undefined,
|
lineStyle: {
|
||||||
areaStyle: isObserved
|
width: isObserved ? 0 : 2,
|
||||||
? undefined
|
type: metadata.source === "analysis_simulation" ? "dashed" : "solid",
|
||||||
: {
|
},
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -628,20 +615,34 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
confine: true,
|
confine: true,
|
||||||
position: function (pt: any[]) {
|
axisPointer: { type: "line", lineStyle: { color: "#94a3b8", type: "dashed" } },
|
||||||
return [pt[0], "10%"];
|
valueFormatter: (value: number | string) => typeof value === "number"
|
||||||
},
|
? value.toLocaleString("zh-CN", { maximumSignificantDigits: 6, useGrouping: false })
|
||||||
|
: String(value ?? "--"),
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
top: "top",
|
type: "scroll",
|
||||||
|
top: 8,
|
||||||
|
left: 20,
|
||||||
|
width: "90%",
|
||||||
|
itemWidth: 20,
|
||||||
|
itemHeight: 8,
|
||||||
|
itemGap: 20,
|
||||||
|
textStyle: { color: "#475569", fontSize: 12 },
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: "5%",
|
top: 120,
|
||||||
right: "5%",
|
left: 20,
|
||||||
bottom: "11%",
|
right: 24,
|
||||||
|
bottom: 76,
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
},
|
},
|
||||||
toolbox: {
|
toolbox: {
|
||||||
|
top: 36,
|
||||||
|
right: 16,
|
||||||
|
itemSize: 15,
|
||||||
|
itemGap: 16,
|
||||||
|
iconStyle: { borderColor: "#64748b" },
|
||||||
feature: {
|
feature: {
|
||||||
dataZoom: {
|
dataZoom: {
|
||||||
yAxisIndex: "none",
|
yAxisIndex: "none",
|
||||||
@@ -659,7 +660,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
axisLabel: {
|
axisLabel: {
|
||||||
formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"),
|
formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"),
|
||||||
hideOverlap: true,
|
hideOverlap: true,
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
margin: 12,
|
||||||
},
|
},
|
||||||
|
splitNumber: 6,
|
||||||
|
axisLine: { lineStyle: { color: "#cbd5e1" } },
|
||||||
|
axisTick: { show: false },
|
||||||
},
|
},
|
||||||
yAxis: [
|
yAxis: [
|
||||||
...(hasFlowSeries
|
...(hasFlowSeries
|
||||||
@@ -668,6 +676,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
type: "value",
|
type: "value",
|
||||||
scale: true,
|
scale: true,
|
||||||
name: `流量 (${FLOW_DISPLAY_UNIT})`,
|
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",
|
type: "value",
|
||||||
scale: true,
|
scale: true,
|
||||||
name: `压力 (${PRESSURE_DISPLAY_UNIT})`,
|
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,
|
end: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
type: "slider",
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 100,
|
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) => ({
|
series: getSeries().map((series, index) => ({
|
||||||
@@ -698,7 +727,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
? {
|
? {
|
||||||
markArea: {
|
markArea: {
|
||||||
silent: true,
|
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: [[
|
data: [[
|
||||||
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
|
{ name: "事件方案时段", xAxis: Date.parse(eventRange.start) },
|
||||||
{ xAxis: Date.parse(eventRange.end) },
|
{ xAxis: Date.parse(eventRange.end) },
|
||||||
|
|||||||
+515
-134
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user