From 723931b6ae5eac23b799545ec4ce8552959e1dfc Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 16:45:10 +0800 Subject: [PATCH] feat(frontend): enforce RBAC and refine burst analysis --- src/app/RefineContext.tsx | 449 ++++++++++-------- src/components/admin/SystemAdminPanel.tsx | 104 ++-- src/components/audit/AuditLogPanel.tsx | 63 +-- src/components/auth/RoutePermissionGuard.tsx | 45 ++ .../BurstSimulation/AnalysisReport.test.tsx | 62 ++- .../olmap/BurstSimulation/AnalysisReport.tsx | 34 +- .../olmap/BurstSimulation/ValveIsolation.tsx | 20 +- src/components/olmap/BurstSimulation/types.ts | 1 + .../BurstSimulation/valveIsolationScope.ts | 13 + .../olmap/core/Controls/Timeline.tsx | 61 +-- .../olmap/core/Controls/Toolbar.tsx | 21 +- src/contexts/ProjectContext.tsx | 136 +++--- src/lib/permissions.test.ts | 34 ++ src/lib/permissions.ts | 91 ++++ src/store/accessStore.ts | 28 ++ 15 files changed, 772 insertions(+), 390 deletions(-) create mode 100644 src/components/auth/RoutePermissionGuard.tsx create mode 100644 src/lib/permissions.test.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/store/accessStore.ts diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 87773c8..35c5622 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -1,32 +1,38 @@ "use client"; -import { Refine, type AuthProvider } from "@refinedev/core"; -import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import { - RefineSnackbarProvider, -} from "@refinedev/mui"; + Refine, + type AccessControlProvider, + type AuthProvider, +} from "@refinedev/core"; +import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; +import { RefineSnackbarProvider } from "@refinedev/mui"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import routerProvider from "@refinedev/nextjs-router"; import { ColorModeContextProvider } from "@contexts/color-mode"; import { dataProvider } from "@providers/data-provider"; import { ProjectProvider } from "@/contexts/ProjectContext"; +import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard"; import { useAuthStore } from "@/store/authStore"; +import { useAccessStore } from "@/store/accessStore"; +import { useProjectStore } from "@/store/projectStore"; import { apiFetch } from "@/lib/apiFetch"; +import { permissionCodes, resourcePermissions } from "@/lib/permissions"; import { config } from "@config/config"; import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider"; import { LiaNetworkWiredSolid } from "react-icons/lia"; -import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; +import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb"; import { LuReplace } from "react-icons/lu"; import { AiOutlineSecurityScan } from "react-icons/ai"; -import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md"; +import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md"; import { - ManageAccounts as ManageAccountsIcon, FactCheck as FactCheckIcon, + ManageAccounts as ManageAccountsIcon, MyLocation as MyLocationIcon, Search as SearchIcon, } from "@mui/icons-material"; @@ -36,16 +42,12 @@ type RefineContextProps = { }; export const RefineContext = ( - props: React.PropsWithChildren -) => { - return ( - - - - - - ); -}; + props: React.PropsWithChildren, +) => ( + + + +); type AppProps = { defaultMode?: string; @@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren) => { const { data, status } = useSession(); const to = usePathname(); const setAccessToken = useAuthStore((state) => state.setAccessToken); - const [isMetadataAdmin, setIsMetadataAdmin] = useState(false); + const currentProjectId = useProjectStore((state) => state.currentProjectId); + const permissions = useAccessStore((state) => state.permissions); + const setAccessContext = useAccessStore((state) => state.setContext); + const setAccessLoading = useAccessStore((state) => state.setLoading); + const resetAccess = useAccessStore((state) => state.reset); + const can = (permission: string) => permissions.includes(permission); useEffect(() => { - setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null); + setAccessToken( + typeof data?.accessToken === "string" ? data.accessToken : null, + ); }, [data?.accessToken, setAccessToken]); useEffect(() => { if (status !== "authenticated") { - setIsMetadataAdmin(false); + resetAccess(); return; } let cancelled = false; - apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { - projectHeaderMode: "omit", + setAccessLoading(true); + apiFetch(`${config.BACKEND_URL}/api/v1/access/context`, { + projectHeaderMode: currentProjectId ? "include" : "omit", skipAuthRedirect: true, }) .then(async (response) => { if (cancelled) return; if (!response.ok) { - setIsMetadataAdmin(false); + resetAccess(); return; } - const payload = await response.json(); - setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin")); + setAccessContext(await response.json()); }) .catch(() => { - if (!cancelled) setIsMetadataAdmin(false); + if (!cancelled) resetAccess(); }); return () => { cancelled = true; }; - }, [status]); + }, [ + currentProjectId, + resetAccess, + setAccessContext, + setAccessLoading, + status, + ]); + + useEffect(() => { + if (status !== "authenticated" || !data?.user?.id) return; + const auditKey = `tjwater-login-audit:${data.user.id}`; + if (sessionStorage.getItem(auditKey)) return; + + apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event: "login" }), + projectHeaderMode: "omit", + skipAuthRedirect: true, + }) + .then((response) => { + if (response.ok) sessionStorage.setItem(auditKey, "1"); + }) + .catch(() => undefined); + }, [data?.user?.id, status]); if (status === "loading") { return loading...; @@ -100,200 +133,230 @@ const App = (props: React.PropsWithChildren) => { callbackUrl: to ? to.toString() : "/", redirect: true, }); - - return { - success: true, - }; + return { success: true }; }, logout: async () => { - signOut({ - redirect: true, - callbackUrl: "/login", - }); - - return { - success: true, - }; + try { + await apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event: "logout" }), + projectHeaderMode: "omit", + skipAuthRedirect: true, + }); + } catch { + // Logout must still complete when audit storage is unavailable. + } + if (data?.user?.id) { + sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); + } + signOut({ redirect: true, callbackUrl: "/login" }); + return { success: true }; }, onError: async (error) => { if (error.response?.status === 401) { - return { - logout: true, - }; + return { logout: true }; } - - return { - error, - }; - }, - check: async () => { - if (status === "unauthenticated") { - return { - authenticated: false, - redirectTo: "/login", - }; - } - - return { - authenticated: true, - }; - }, - getPermissions: async () => { - return null; + return { error }; }, + check: async () => + status === "unauthenticated" + ? { authenticated: false, redirectTo: "/login" } + : { authenticated: true }, + getPermissions: async () => permissions, getIdentity: async () => { - if (data?.user) { - const { user } = data; - return { - id: user.id, - username: user.username, - name: user.name, - avatar: user.image, - }; - } - - return null; + if (!data?.user) return null; + return { + id: data.user.id, + username: data.user.username, + name: data.user.name, + avatar: data.user.image, + }; }, }; - const defaultMode = props?.defaultMode; + const accessControlProvider: AccessControlProvider = { + can: async ({ resource }) => { + const requiredPermission = resource + ? resourcePermissions[resource] + : undefined; + return { + can: !requiredPermission || permissions.includes(requiredPermission), + reason: requiredPermission + ? `需要权限:${requiredPermission}` + : undefined, + }; + }, + }; + + const resources = [ + ...(can(permissionCodes.simulationView) + ? [ + { + name: "管网在线模拟", + list: "/network-simulation", + meta: { + icon: , + label: "管网在线模拟", + }, + }, + ] + : []), + ...(can(permissionCodes.scadaClean) + ? [ + { + name: "SCADA 数据清洗", + list: "/scada-data-cleaning", + meta: { + icon: , + label: "SCADA 数据清洗", + }, + }, + ] + : []), + ...(can(permissionCodes.optimizationRun) + ? [ + { + name: "监测点优化布置", + list: "/monitoring-place-optimization", + meta: { + icon: , + label: "监测点优化布置", + }, + }, + ] + : []), + ...(can(permissionCodes.riskRun) + ? [ + { + name: "健康风险分析", + list: "/health-risk-analysis", + meta: { + icon: , + label: "健康风险分析", + }, + }, + ] + : []), + ...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun) + ? [ + { + name: "Hydraulic Simulation", + meta: { label: "事件模拟" }, + }, + ] + : []), + ...(can(permissionCodes.burstRun) + ? [ + { + name: "爆管模拟", + list: "/hydraulic-simulation/burst-simulation", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "爆管模拟", + }, + }, + { + name: "爆管侦测", + list: "/hydraulic-simulation/burst-detection", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "爆管侦测", + }, + }, + { + name: "爆管定位", + list: "/hydraulic-simulation/burst-location", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "爆管定位", + }, + }, + { + name: "DMA 漏损识别", + list: "/hydraulic-simulation/dma-leak-detection", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "DMA 漏损识别", + }, + }, + ] + : []), + ...(can(permissionCodes.simulationRun) + ? [ + { + name: "水质模拟", + list: "/hydraulic-simulation/contaminant-simulation", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "水质模拟", + }, + }, + { + name: "管道冲洗", + list: "/hydraulic-simulation/flushing-analysis", + meta: { + parent: "Hydraulic Simulation", + icon: , + label: "管道冲洗", + }, + }, + ] + : []), + ...(can(permissionCodes.environmentManage) + ? [ + { + name: "系统管理", + list: "/system-admin", + meta: { + icon: , + label: "系统管理", + }, + }, + ] + : []), + ...(can(permissionCodes.auditView) + ? [ + { + name: "审计日志", + list: "/audit-logs", + meta: { + icon: , + label: "审计日志", + }, + }, + ] + : []), + ]; return ( - <> + - + , - label: "管网在线模拟", - }, - }, - { - name: "SCADA 数据清洗", - list: "/scada-data-cleaning", - meta: { - icon: , - label: "SCADA 数据清洗", - }, - }, - { - name: "监测点优化布置", - list: "/monitoring-place-optimization", - meta: { - icon: , - label: "监测点优化布置", - }, - }, - { - name: "健康风险分析", - list: "/health-risk-analysis", - meta: { - icon: , - label: "健康风险分析", - }, - }, - { - name: "Hydraulic Simulation", - meta: { - // icon: , - label: "事件模拟", - }, - }, - { - name: "爆管模拟", - list: "/hydraulic-simulation/burst-simulation", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "爆管模拟", - }, - }, - { - name: "爆管侦测", - list: "/hydraulic-simulation/burst-detection", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "爆管侦测", - }, - }, - { - name: "爆管定位", - list: "/hydraulic-simulation/burst-location", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "爆管定位", - }, - }, - { - name: "DMA 漏损识别", - list: "/hydraulic-simulation/dma-leak-detection", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "DMA 漏损识别", - }, - }, - { - name: "水质模拟", - list: "/hydraulic-simulation/contaminant-simulation", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "水质模拟", - }, - }, - { - name: "管道冲洗", - list: "/hydraulic-simulation/flushing-analysis", - meta: { - parent: "Hydraulic Simulation", - icon: , - label: "管道冲洗", - }, - }, - ...(isMetadataAdmin - ? [ - { - name: "系统管理", - list: "/system-admin", - meta: { - icon: , - label: "系统管理", - }, - }, - { - name: "审计日志", - list: "/audit-logs", - meta: { - icon: , - label: "审计日志", - }, - }, - ] - : []), - ]} + accessControlProvider={accessControlProvider} + resources={resources} options={{ syncWithLocation: true, warnWhenUnsavedChanges: true, }} > - {props.children} + {props.children} - + ); }; diff --git a/src/components/admin/SystemAdminPanel.tsx b/src/components/admin/SystemAdminPanel.tsx index b1f1ad2..dc81947 100644 --- a/src/components/admin/SystemAdminPanel.tsx +++ b/src/components/admin/SystemAdminPanel.tsx @@ -106,24 +106,30 @@ type DatabaseHealth = { const businessRoleOptions = [ { value: "admin", label: "系统管理员" }, - { value: "operator", label: "运行人员" }, { value: "user", label: "普通用户" }, - { value: "viewer", label: "只读用户" }, ]; const projectRoleLabels: Record = { - owner: "项目负责人", - admin: "项目管理员", member: "项目成员", viewer: "只读成员", }; const projectRoleOptions = [ - { value: "admin", label: projectRoleLabels.admin }, { value: "member", label: projectRoleLabels.member }, { value: "viewer", label: projectRoleLabels.viewer }, ]; +const projectRolePermissionSummary = [ + { + role: "项目成员", + permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析", + }, + { + role: "只读成员", + permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读", + }, +]; + const projectStatusOptions = [ { value: "active", label: "启用" }, { value: "inactive", label: "停用" }, @@ -1340,7 +1346,7 @@ export const SystemAdminPanel = () => { { } /> + + + 固定项目权限 + + + {projectRolePermissionSummary.map((item) => ( + + ))} + + {!hasProjectId && ( 当前未选择管理项目。 @@ -1467,7 +1492,6 @@ export const SystemAdminPanel = () => { )} {members.map((member) => { const isSelf = member.user_id === currentAdmin?.id; - const isOwner = member.project_role === "owner"; return ( @@ -1486,54 +1510,38 @@ export const SystemAdminPanel = () => { {member.email} - {isOwner ? ( - - + + - - ) : ( - - - + - updateMemberRole(member, e.target.value) - } - renderValue={(value) => - getProjectRoleLabel(String(value)) - } - > - {projectRoleOptions.map((role) => ( - - {role.label} - - ))} - - - - - )} + {projectRoleOptions.map((role) => ( + + {role.label} + + ))} + + + + diff --git a/src/components/audit/AuditLogPanel.tsx b/src/components/audit/AuditLogPanel.tsx index d82c5e7..ad89516 100644 --- a/src/components/audit/AuditLogPanel.tsx +++ b/src/components/audit/AuditLogPanel.tsx @@ -49,6 +49,8 @@ import { } from "@mui/icons-material"; import { config } from "@config/config"; import { apiFetch } from "@/lib/apiFetch"; +import { permissionCodes } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; type AuditLog = { id: string; @@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = { end_time: "", }; +const AUDIT_LOGS_PATH = "/api/v1/audit/logs"; + const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [ { value: "all", label: "全部状态" }, { value: "success", label: "成功 2xx" }, @@ -338,8 +342,14 @@ const DetailSection = ({ ); export const AuditLogPanel = () => { - const [adminChecked, setAdminChecked] = useState(false); - const [isAuthorized, setIsAuthorized] = useState(false); + const accessLoading = useAccessStore((state) => state.loading); + const permissions = useAccessStore((state) => state.permissions); + const isSystemAdmin = useAccessStore( + (state) => state.context?.is_system_admin === true, + ); + const canViewAudit = permissions.includes(permissionCodes.auditView); + const adminChecked = !accessLoading; + const isAuthorized = adminChecked && canViewAudit && isSystemAdmin; const [logs, setLogs] = useState([]); const [users, setUsers] = useState([]); const [projects, setProjects] = useState([]); @@ -392,8 +402,8 @@ export const AuditLogPanel = () => { const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage); const countParams = buildCountParams(appliedFilters); const [logsResponse, countResponse] = await Promise.all([ - apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`), - apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`), + apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`), + apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`), ]); if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse)); if (!countResponse.ok) throw new Error(await readErrorText(countResponse)); @@ -402,7 +412,7 @@ export const AuditLogPanel = () => { setTotalCount(Number(countPayload.count ?? 0)); } else { const params = buildServerParams(appliedFilters, 0, 1000); - const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`); + const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`); if (!response.ok) throw new Error(await readErrorText(response)); const payload = (await response.json()) as AuditLog[]; setLogs(payload); @@ -417,42 +427,9 @@ export const AuditLogPanel = () => { }, [appliedFilters, page, rowsPerPage]); useEffect(() => { - let cancelled = false; - - const checkAdmin = async () => { - try { - const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, { - projectHeaderMode: "omit", - skipAuthRedirect: true, - }); - if (cancelled) return; - if (!response.ok) { - setIsAuthorized(false); - setAdminChecked(true); - return; - } - const payload = await response.json(); - setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin")); - setAdminChecked(true); - } catch (err) { - if (!cancelled) { - setIsAuthorized(false); - setAdminChecked(true); - setError(String(err)); - } - } - }; - - void checkAdmin(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - if (!adminChecked || !isAuthorized) return; + if (!isAuthorized) return; void loadOptions(); - }, [adminChecked, isAuthorized, loadOptions]); + }, [isAuthorized, loadOptions]); useEffect(() => { if (!adminChecked || !isAuthorized) return; @@ -476,7 +453,7 @@ export const AuditLogPanel = () => { setError(null); try { const params = buildServerParams(appliedFilters, 0, 1000); - const response = await apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`); + const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`); if (!response.ok) throw new Error(await readErrorText(response)); const payload = ((await response.json()) as AuditLog[]).filter((log) => matchesStatusFilter(log, appliedFilters.status), @@ -544,7 +521,7 @@ export const AuditLogPanel = () => { 审计日志 - 管理员审计查询 + 全局审计查询 @@ -552,7 +529,7 @@ export const AuditLogPanel = () => { } - label="管理员权限已验证" + label="系统管理员权限已验证" sx={{ alignSelf: { xs: "flex-start", md: "center" } }} /> )} diff --git a/src/components/auth/RoutePermissionGuard.tsx b/src/components/auth/RoutePermissionGuard.tsx new file mode 100644 index 0000000..0c455c0 --- /dev/null +++ b/src/components/auth/RoutePermissionGuard.tsx @@ -0,0 +1,45 @@ +"use client"; + +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; +import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +import { permissionForPath } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; + +export const RoutePermissionGuard = ({ + children, +}: { + children: ReactNode; +}) => { + const pathname = usePathname(); + const permissions = useAccessStore((state) => state.permissions); + const loading = useAccessStore((state) => state.loading); + const requiredPermission = permissionForPath(pathname); + + if (requiredPermission && loading) { + return ( + + + + ); + } + + if (requiredPermission && !permissions.includes(requiredPermission)) { + return ( + + }> + + 无权访问此功能 + + 当前项目角色缺少权限:{requiredPermission} + + + + + ); + } + + return children; +}; diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx index 4032017..e6e244f 100644 --- a/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisReport.test.tsx @@ -11,7 +11,10 @@ import AnalysisReport, { matchesValveAnalysis, } from "./AnalysisReport"; import { SchemeRecord, ValveIsolationResult } from "./types"; -import { isAllowedAccidentPipe } from "./valveIsolationScope"; +import { + isAllowedAccidentPipe, + normalizeValveIsolationResult, +} from "./valveIsolationScope"; jest.mock("@/utils/mapQueryService", () => ({ queryFeaturesByIds: jest.fn(), @@ -139,12 +142,69 @@ describe("AnalysisReport", () => { ); }); + it("shows only the affected count for a non-isolatable result", async () => { + const legacyAffectedNodes = Array.from( + { length: 100 }, + (_, index) => `legacy-node-${index}`, + ); + const nonIsolatableResult = { + ...valveResult, + affected_nodes: legacyAffectedNodes, + affected_node_count: 85747, + must_close_valves: [], + isolatable: false, + }; + + render( + , + ); + + const preview = within( + screen.getByTestId("burst-analysis-report-preview"), + ); + expect(preview.getByText("85747 个")).toBeInTheDocument(); + expect( + preview.getByText("不可隔离,未生成受影响节点清单。"), + ).toBeInTheDocument(); + expect(preview.queryByText("legacy-node-0")).not.toBeInTheDocument(); + expect(preview.queryByText("legacy-node-99")).not.toBeInTheDocument(); + await waitFor(() => + expect(preview.getByText("315 mm")).toBeInTheDocument(), + ); + }); + it("limits scheme-bound valve analysis to the scheme accident pipes", () => { expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true); expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false); expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true); }); + it("normalizes legacy valve results without changing isolatable lists", () => { + expect( + normalizeValveIsolationResult({ + ...valveResult, + affected_nodes: ["J-1", "J-2", "J-3"], + must_close_valves: [], + isolatable: false, + }), + ).toMatchObject({ + affected_nodes: [], + affected_node_count: 3, + isolatable: false, + }); + + expect(normalizeValveIsolationResult(valveResult)).toMatchObject({ + affected_nodes: ["J-1", "J-2"], + affected_node_count: 2, + isolatable: true, + }); + }); + it("prints only after assigning the report print state", async () => { const originalTitle = document.title; const print = jest diff --git a/src/components/olmap/BurstSimulation/AnalysisReport.tsx b/src/components/olmap/BurstSimulation/AnalysisReport.tsx index 2049b0f..3624c53 100644 --- a/src/components/olmap/BurstSimulation/AnalysisReport.tsx +++ b/src/components/olmap/BurstSimulation/AnalysisReport.tsx @@ -35,6 +35,7 @@ import { type PipeDiameterMap, } from "./schemePipeDiameters"; import { SchemeRecord, ValveIsolationResult } from "./types"; +import { getAffectedNodeCount } from "./valveIsolationScope"; import PanelEmptyState from "@components/olmap/common/PanelEmptyState"; interface AnalysisReportProps { @@ -212,6 +213,18 @@ const ReportDocument: React.FC = ({ ? valveResult : null; const duration = scheme.schemeDetail?.modify_total_duration; + const valveDetailRows: Array<[string, string[] | undefined]> = + matchedValveResult + ? [ + ["已分析事故管段", matchedValveResult.accident_elements], + ["必关阀门", matchedValveResult.must_close_valves], + ["可选阀门", matchedValveResult.optional_valves], + ["不可用阀门", disabledValves], + ] + : []; + if (matchedValveResult?.isolatable) { + valveDetailRows.push(["受影响节点", matchedValveResult.affected_nodes]); + } return ( = ({ {[ ["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"], ["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`], - ["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0} 个`], + ["受影响节点", `${getAffectedNodeCount(matchedValveResult)} 个`], ].map(([label, value]) => ( = ({ ))} - {[ - ["已分析事故管段", matchedValveResult.accident_elements], - ["必关阀门", matchedValveResult.must_close_valves], - ["可选阀门", matchedValveResult.optional_valves], - ["不可用阀门", disabledValves], - ["受影响节点", matchedValveResult.affected_nodes], - ].map(([label, values]) => ( - + {valveDetailRows.map(([label, values]) => ( + - {label as string} + {label} - + ))} + {!matchedValveResult.isolatable && ( + + 不可隔离,未生成受影响节点清单。 + + )} ) : ( diff --git a/src/components/olmap/BurstSimulation/ValveIsolation.tsx b/src/components/olmap/BurstSimulation/ValveIsolation.tsx index 19ff8db..761f513 100644 --- a/src/components/olmap/BurstSimulation/ValveIsolation.tsx +++ b/src/components/olmap/BurstSimulation/ValveIsolation.tsx @@ -52,7 +52,11 @@ import { import { Point } from "ol/geom"; import { toLonLat } from "ol/proj"; import { useControllableObjectState } from "@components/olmap/core/useControllableState"; -import { isAllowedAccidentPipe } from "./valveIsolationScope"; +import { + getAffectedNodeCount, + isAllowedAccidentPipe, + normalizeValveIsolationResult, +} from "./valveIsolationScope"; interface ValveIsolationProps { initialPipeIds?: string[]; @@ -363,7 +367,7 @@ const ValveIsolation: React.FC = ({ if (disabled.length > 0) { params.disabled_valves = disabled; } - const response = await api.get( + const response = await api.get( `${config.BACKEND_URL}/api/v1/valve-isolation-analysis`, { params, @@ -372,7 +376,7 @@ const ValveIsolation: React.FC = ({ }, }, ); - setResult(response.data); + setResult(normalizeValveIsolationResult(response.data)); if (!isExpandSearch) { setActiveStep(1); } else { @@ -711,7 +715,7 @@ const ValveIsolation: React.FC = ({ {[ { label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" }, { label: "可选阀门", value: result.optional_valves?.length || 0, color: "orange", bgInfo: "from-orange-50 to-orange-100", textInfo: "text-orange-700" }, - { label: "影响节点", value: result.affected_nodes?.length || 0, color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" }, + { label: "影响节点", value: getAffectedNodeCount(result), color: "blue", bgInfo: "from-blue-50 to-blue-100", textInfo: "text-blue-700" }, ].map((item, index) => ( = ({ )} + {!result.isolatable && ( + + 不可隔离,未生成受影响节点清单。 + + )} + {/* 受影响节点 */} - {result.affected_nodes && result.affected_nodes.length > 0 && ( + {result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && ( diff --git a/src/components/olmap/BurstSimulation/types.ts b/src/components/olmap/BurstSimulation/types.ts index 8db31a0..302945d 100644 --- a/src/components/olmap/BurstSimulation/types.ts +++ b/src/components/olmap/BurstSimulation/types.ts @@ -31,6 +31,7 @@ export interface SchemaItem { export interface ValveIsolationResult { accident_elements: string[]; affected_nodes: string[]; + affected_node_count?: number; must_close_valves: string[]; optional_valves: string[]; isolatable: boolean; diff --git a/src/components/olmap/BurstSimulation/valveIsolationScope.ts b/src/components/olmap/BurstSimulation/valveIsolationScope.ts index 9c8c14c..d8cdac1 100644 --- a/src/components/olmap/BurstSimulation/valveIsolationScope.ts +++ b/src/components/olmap/BurstSimulation/valveIsolationScope.ts @@ -1,4 +1,17 @@ +import type { ValveIsolationResult } from "./types"; + export const isAllowedAccidentPipe = ( pipeId: string, allowedPipeIds: string[] | undefined, ) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId); + +export const getAffectedNodeCount = (result: ValveIsolationResult) => + result.affected_node_count ?? result.affected_nodes?.length ?? 0; + +export const normalizeValveIsolationResult = ( + result: ValveIsolationResult, +): ValveIsolationResult => ({ + ...result, + affected_nodes: result.isolatable ? result.affected_nodes ?? [] : [], + affected_node_count: getAffectedNodeCount(result), +}); diff --git a/src/components/olmap/core/Controls/Timeline.tsx b/src/components/olmap/core/Controls/Timeline.tsx index dc7869b..e0f423f 100644 --- a/src/components/olmap/core/Controls/Timeline.tsx +++ b/src/components/olmap/core/Controls/Timeline.tsx @@ -29,6 +29,8 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi"; import { useData } from "../MapComponent"; import { config, NETWORK_NAME } from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; +import { permissionCodes } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; import { useMap } from "../MapComponent"; import { formatTimelineTime, @@ -81,6 +83,9 @@ const Timeline: React.FC = ({ schemeType = "burst_analysis", }) => { const data = useData(); + const canRunSimulation = useAccessStore((state) => + state.permissions.includes(permissionCodes.simulationRun), + ); const fallbackSelectedDateRef = useRef(new Date()); const hasTimelineState = data && @@ -926,35 +931,35 @@ const Timeline: React.FC = ({ - - {/* 强制计算时间段 */} - - 计算时间段 - - + {canRunSimulation && ( + + + 计算时间段 + + - {/* 功能按钮 */} - - - - + + + + + )} {/* 当前时间显示 */} = ({ const map = useMap(); const data = useData(); const project = useProject(); + const canEditNetwork = useAccessStore((state) => + state.permissions.includes(permissionCodes.webgisEdit), + ); const { open } = useNotification(); const [activeTools, setActiveTools] = useState([]); const [highlightFeatures, setHighlightFeatures] = useState([]); @@ -119,6 +124,15 @@ const Toolbar: React.FC = ({ } }, [enableCompare, isCompareMode, toggleCompareMode]); + useEffect(() => { + if (!canEditNetwork) { + setShowDrawPanel(false); + setActiveTools((previous) => + previous.filter((tool) => tool !== "draw"), + ); + } + }, [canEditNetwork]); + // Chat tool action → direct featureInfos override (bypasses OL Feature lookup) const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState< [string, string][] | null @@ -697,7 +711,7 @@ const Toolbar: React.FC = ({ buildFeatureProperties( selectedFeature, computedProperties, - selectedValveId + canEditNetwork && selectedValveId ? { value: valveStatus, loading: isValveStatusLoading, @@ -705,7 +719,7 @@ const Toolbar: React.FC = ({ onSave: handleValveStatusSave, } : undefined, - selectedValveId + canEditNetwork && selectedValveId ? { value: valveProperties.setting, vType: selectedValveType, @@ -719,6 +733,7 @@ const Toolbar: React.FC = ({ [ selectedFeature, computedProperties, + canEditNetwork, selectedValveId, valveStatus, valveProperties, @@ -755,7 +770,7 @@ const Toolbar: React.FC = ({ onClick={() => handleToolClick("history")} /> )} - {!hiddenButtons?.includes("draw") && ( + {canEditNetwork && !hiddenButtons?.includes("draw") && ( } name="标记绘制" diff --git a/src/contexts/ProjectContext.tsx b/src/contexts/ProjectContext.tsx index 8e768f9..447c202 100644 --- a/src/contexts/ProjectContext.tsx +++ b/src/contexts/ProjectContext.tsx @@ -1,9 +1,26 @@ "use client"; -import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; + import { useSession } from "next-auth/react"; -import { config, NETWORK_NAME, setMapWorkspace, setNetworkName, setMapExtent } from "@/config/config"; +import { usePathname, useRouter } from "next/navigation"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + import { ProjectSelector } from "@/components/project/ProjectSelector"; +import { + config, + NETWORK_NAME, + setMapExtent, + setMapWorkspace, + setNetworkName, +} from "@/config/config"; import { apiFetch } from "@/lib/apiFetch"; +import { permissionCodes } from "@/lib/permissions"; +import { useAccessStore } from "@/store/accessStore"; import { useProjectStore } from "@/store/projectStore"; interface ProjectContextType { @@ -21,6 +38,11 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { status } = useSession(); + const pathname = usePathname(); + const router = useRouter(); + const canManageEnvironment = useAccessStore((state) => + state.permissions.includes(permissionCodes.environmentManage), + ); const [isConfigured, setIsConfigured] = useState(false); const setCurrentProjectId = useProjectStore( (state) => state.setCurrentProjectId, @@ -31,76 +53,69 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ extent: config.MAP_EXTENT, }); - const applyConfig = useCallback(async ( - projectId: string, - ws: string, - net: string, - extent: number[], - ) => { - const resolvedProjectId = projectId || net || ws; - setMapWorkspace(ws); - setNetworkName(net); - setMapExtent(extent); - localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(",")); - // Reset extent cache - localStorage.removeItem(`${ws}_map_view`); - setCurrentProject({ workspace: ws, networkName: net, extent: extent }); - setCurrentProjectId(resolvedProjectId); + const applyConfig = useCallback( + async ( + projectId: string, + workspace: string, + networkName: string, + extent: number[], + ) => { + const resolvedProjectId = projectId || networkName || workspace; + setMapWorkspace(workspace); + setNetworkName(networkName); + setMapExtent(extent); + localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, workspace); + localStorage.setItem(NETWORK_NAME_STORAGE_KEY, networkName); + localStorage.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(",")); + localStorage.removeItem(`${workspace}_map_view`); + setCurrentProject({ workspace, networkName, extent }); + setCurrentProjectId(resolvedProjectId); + setIsConfigured(true); - // Save to localStorage - localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, ws); - localStorage.setItem(NETWORK_NAME_STORAGE_KEY, net); - - setIsConfigured(true); - - try { - // Open project backend (simulation model) - const openResponse = await apiFetch( - `${config.BACKEND_URL}/api/v1/projects/open?network=${net}`, - { - method: "POST", - }, - ); - if (!openResponse.ok) { - throw new Error(`Failed to open project: HTTP ${openResponse.status}`); - } - - // Fetch project metadata - const infoResponse = await apiFetch( - `${config.BACKEND_URL}/api/v1/project-info?network=${net}`, - ); - if (!infoResponse.ok) { - console.warn( - `Failed to fetch project info: HTTP ${infoResponse.status}`, + try { + const openResponse = await apiFetch( + `${config.BACKEND_URL}/api/v1/projects/open?network=${networkName}`, + { method: "POST" }, ); - } else { - const data = await infoResponse.json(); + if (!openResponse.ok) { + throw new Error(`Failed to open project: HTTP ${openResponse.status}`); + } - // Update workspace if different - if (data?.gs_workspace && data.gs_workspace !== ws) { + const infoResponse = await apiFetch( + `${config.BACKEND_URL}/api/v1/project-info?network=${networkName}`, + ); + if (!infoResponse.ok) { + console.warn( + `Failed to fetch project info: HTTP ${infoResponse.status}`, + ); + return; + } + + const data = await infoResponse.json(); + if (data?.gs_workspace && data.gs_workspace !== workspace) { setMapWorkspace(data.gs_workspace); localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, data.gs_workspace); - setCurrentProject((prev) => ({ - ...prev, + setCurrentProject((previous) => ({ + ...previous, workspace: data.gs_workspace, })); } - // Update extent if available const bbox = Array.isArray(data?.map_extent?.bbox) ? data.map_extent.bbox.map((value: number) => Number(value)) : null; - if (bbox && bbox.length === 4) { + if (bbox?.length === 4) { setMapExtent(bbox); localStorage.setItem(MAP_EXTENT_STORAGE_KEY, bbox.join(",")); - localStorage.removeItem(`${ws}_map_view`); - setCurrentProject((prev) => ({ ...prev, extent: bbox })); + localStorage.removeItem(`${workspace}_map_view`); + setCurrentProject((previous) => ({ ...previous, extent: bbox })); } + } catch (error) { + console.error("Failed to setup project:", error); } - } catch (error) { - console.error("Failed to setup project:", error); - } - }, [setCurrentProjectId]); + }, + [setCurrentProjectId], + ); useEffect(() => { // Check localStorage @@ -120,11 +135,16 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ } }, [applyConfig]); - // Only show selector if authenticated and not configured - if (status === "authenticated" && !isConfigured) { + const isGlobalManagementRoute = + pathname.startsWith("/system-admin") || pathname.startsWith("/audit-logs"); + + if (status === "authenticated" && !isConfigured && !isGlobalManagementRoute) { return ( router.push("/system-admin") : undefined + } onSelect={(projectId, ws, net, extent) => applyConfig(projectId, ws, net, extent) } diff --git a/src/lib/permissions.test.ts b/src/lib/permissions.test.ts new file mode 100644 index 0000000..a5b102b --- /dev/null +++ b/src/lib/permissions.test.ts @@ -0,0 +1,34 @@ +import { + permissionCodes, + permissionForPath, + resourcePermissions, +} from "./permissions"; + +describe("permission mappings", () => { + it("maps protected routes to backend permission codes", () => { + expect(permissionForPath("/system-admin")).toBe( + permissionCodes.environmentManage, + ); + expect(permissionForPath("/scada-data-cleaning/devices")).toBe( + permissionCodes.scadaClean, + ); + expect(permissionForPath("/monitoring-place-optimization")).toBe( + permissionCodes.optimizationRun, + ); + expect( + permissionForPath("/hydraulic-simulation/burst-location"), + ).toBe(permissionCodes.burstRun); + }); + + it("leaves public or project-selection routes without a feature permission", () => { + expect(permissionForPath("/login")).toBeUndefined(); + expect(permissionForPath("/")).toBeUndefined(); + }); + + it("uses the same permission codes for resources and routes", () => { + expect(resourcePermissions["系统管理"]).toBe( + permissionCodes.environmentManage, + ); + expect(resourcePermissions["审计日志"]).toBe(permissionCodes.auditView); + }); +}); diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..885c148 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,91 @@ +export const permissionCodes = { + webgisView: "webgis.view", + webgisEdit: "webgis.edit", + scadaView: "scada.view", + scadaClean: "scada.clean", + simulationView: "simulation.view", + simulationRun: "simulation.run", + burstView: "burst.view", + burstRun: "burst.run", + riskView: "risk.view", + riskRun: "risk.run", + optimizationView: "optimization.view", + optimizationRun: "optimization.run", + modelImport: "model.import", + auditView: "audit.view", + environmentManage: "environment.manage", + membershipManage: "membership.manage", +} as const; + +export type PermissionCode = + (typeof permissionCodes)[keyof typeof permissionCodes]; + +export type AccessContext = { + user_id: string; + username: string; + system_role: string; + is_system_admin: boolean; + project_id?: string | null; + project_role?: string | null; + permissions: string[]; +}; + +export const resourcePermissions: Record = { + "管网在线模拟": permissionCodes.simulationView, + "SCADA 数据清洗": permissionCodes.scadaClean, + "监测点优化布置": permissionCodes.optimizationRun, + "健康风险分析": permissionCodes.riskRun, + "爆管模拟": permissionCodes.burstRun, + "爆管侦测": permissionCodes.burstRun, + "爆管定位": permissionCodes.burstRun, + "DMA 漏损识别": permissionCodes.burstRun, + "水质模拟": permissionCodes.simulationRun, + "管道冲洗": permissionCodes.simulationRun, + "系统管理": permissionCodes.environmentManage, + "审计日志": permissionCodes.auditView, +}; + +export const pathPermissions: Array<{ + prefix: string; + permission: PermissionCode; +}> = [ + { prefix: "/system-admin", permission: permissionCodes.environmentManage }, + { prefix: "/audit-logs", permission: permissionCodes.auditView }, + { prefix: "/scada-data-cleaning", permission: permissionCodes.scadaClean }, + { + prefix: "/monitoring-place-optimization", + permission: permissionCodes.optimizationRun, + }, + { prefix: "/health-risk-analysis", permission: permissionCodes.riskRun }, + { + prefix: "/hydraulic-simulation/burst-simulation", + permission: permissionCodes.burstRun, + }, + { + prefix: "/hydraulic-simulation/burst-detection", + permission: permissionCodes.burstRun, + }, + { + prefix: "/hydraulic-simulation/burst-location", + permission: permissionCodes.burstRun, + }, + { + prefix: "/hydraulic-simulation/dma-leak-detection", + permission: permissionCodes.burstRun, + }, + { + prefix: "/hydraulic-simulation/contaminant-simulation", + permission: permissionCodes.simulationRun, + }, + { + prefix: "/hydraulic-simulation/flushing-analysis", + permission: permissionCodes.simulationRun, + }, + { + prefix: "/network-simulation", + permission: permissionCodes.simulationView, + }, +]; + +export const permissionForPath = (pathname: string) => + pathPermissions.find(({ prefix }) => pathname.startsWith(prefix))?.permission; diff --git a/src/store/accessStore.ts b/src/store/accessStore.ts new file mode 100644 index 0000000..b7212a7 --- /dev/null +++ b/src/store/accessStore.ts @@ -0,0 +1,28 @@ +import { create } from "zustand"; + +import type { AccessContext } from "@/lib/permissions"; + +type AccessState = { + context: AccessContext | null; + permissions: string[]; + loading: boolean; + setLoading: (loading: boolean) => void; + setContext: (context: AccessContext) => void; + reset: () => void; +}; + +export const useAccessStore = create((set) => ({ + context: null, + permissions: [], + loading: true, + setLoading: (loading) => set({ loading }), + setContext: (context) => + set({ + context, + permissions: Array.isArray(context.permissions) + ? context.permissions + : [], + loading: false, + }), + reset: () => set({ context: null, permissions: [], loading: false }), +}));