feat(frontend): enforce RBAC and refine burst analysis
This commit is contained in:
+149
-86
@@ -1,32 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Refine, type AuthProvider } from "@refinedev/core";
|
|
||||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
|
||||||
import {
|
import {
|
||||||
RefineSnackbarProvider,
|
Refine,
|
||||||
} from "@refinedev/mui";
|
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 { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
import routerProvider from "@refinedev/nextjs-router";
|
import routerProvider from "@refinedev/nextjs-router";
|
||||||
|
|
||||||
import { ColorModeContextProvider } from "@contexts/color-mode";
|
import { ColorModeContextProvider } from "@contexts/color-mode";
|
||||||
import { dataProvider } from "@providers/data-provider";
|
import { dataProvider } from "@providers/data-provider";
|
||||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||||
|
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||||
|
|
||||||
import { LiaNetworkWiredSolid } from "react-icons/lia";
|
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 { LuReplace } from "react-icons/lu";
|
||||||
import { AiOutlineSecurityScan } from "react-icons/ai";
|
import { AiOutlineSecurityScan } from "react-icons/ai";
|
||||||
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
|
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
|
||||||
import {
|
import {
|
||||||
ManageAccounts as ManageAccountsIcon,
|
|
||||||
FactCheck as FactCheckIcon,
|
FactCheck as FactCheckIcon,
|
||||||
|
ManageAccounts as ManageAccountsIcon,
|
||||||
MyLocation as MyLocationIcon,
|
MyLocation as MyLocationIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
@@ -36,16 +42,12 @@ type RefineContextProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RefineContext = (
|
export const RefineContext = (
|
||||||
props: React.PropsWithChildren<RefineContextProps>
|
props: React.PropsWithChildren<RefineContextProps>,
|
||||||
) => {
|
) => (
|
||||||
return (
|
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ProjectProvider>
|
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
</ProjectProvider>
|
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
type AppProps = {
|
type AppProps = {
|
||||||
defaultMode?: string;
|
defaultMode?: string;
|
||||||
@@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const { data, status } = useSession();
|
const { data, status } = useSession();
|
||||||
const to = usePathname();
|
const to = usePathname();
|
||||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
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(() => {
|
useEffect(() => {
|
||||||
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
|
setAccessToken(
|
||||||
|
typeof data?.accessToken === "string" ? data.accessToken : null,
|
||||||
|
);
|
||||||
}, [data?.accessToken, setAccessToken]);
|
}, [data?.accessToken, setAccessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
setIsMetadataAdmin(false);
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
|
setAccessLoading(true);
|
||||||
projectHeaderMode: "omit",
|
apiFetch(`${config.BACKEND_URL}/api/v1/access/context`, {
|
||||||
|
projectHeaderMode: currentProjectId ? "include" : "omit",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setIsMetadataAdmin(false);
|
resetAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await response.json();
|
setAccessContext(await response.json());
|
||||||
setIsMetadataAdmin(Boolean(payload?.is_superuser || payload?.role === "admin"));
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setIsMetadataAdmin(false);
|
if (!cancelled) resetAccess();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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") {
|
if (status === "loading") {
|
||||||
return <span>loading...</span>;
|
return <span>loading...</span>;
|
||||||
@@ -100,75 +133,65 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
callbackUrl: to ? to.toString() : "/",
|
callbackUrl: to ? to.toString() : "/",
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
|
return { success: true };
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
signOut({
|
try {
|
||||||
redirect: true,
|
await apiFetch(`${config.BACKEND_URL}/api/v1/audit/session-events`, {
|
||||||
callbackUrl: "/login",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ event: "logout" }),
|
||||||
|
projectHeaderMode: "omit",
|
||||||
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
return {
|
// Logout must still complete when audit storage is unavailable.
|
||||||
success: true,
|
}
|
||||||
};
|
if (data?.user?.id) {
|
||||||
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||||
|
}
|
||||||
|
signOut({ redirect: true, callbackUrl: "/login" });
|
||||||
|
return { success: true };
|
||||||
},
|
},
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
return {
|
return { logout: true };
|
||||||
logout: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return { error };
|
||||||
return {
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
check: async () => {
|
|
||||||
if (status === "unauthenticated") {
|
|
||||||
return {
|
|
||||||
authenticated: false,
|
|
||||||
redirectTo: "/login",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
authenticated: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getPermissions: async () => {
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
|
check: async () =>
|
||||||
|
status === "unauthenticated"
|
||||||
|
? { authenticated: false, redirectTo: "/login" }
|
||||||
|
: { authenticated: true },
|
||||||
|
getPermissions: async () => permissions,
|
||||||
getIdentity: async () => {
|
getIdentity: async () => {
|
||||||
if (data?.user) {
|
if (!data?.user) return null;
|
||||||
const { user } = data;
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: data.user.id,
|
||||||
username: user.username,
|
username: data.user.username,
|
||||||
name: user.name,
|
name: data.user.name,
|
||||||
avatar: user.image,
|
avatar: data.user.image,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const resources = [
|
||||||
<>
|
...(can(permissionCodes.simulationView)
|
||||||
<RefineKbarProvider>
|
? [
|
||||||
<ColorModeContextProvider defaultMode={defaultMode}>
|
|
||||||
<RefineSnackbarProvider>
|
|
||||||
<Refine
|
|
||||||
routerProvider={routerProvider}
|
|
||||||
dataProvider={dataProvider}
|
|
||||||
notificationProvider={useAppNotificationProvider}
|
|
||||||
authProvider={authProvider}
|
|
||||||
resources={[
|
|
||||||
{
|
{
|
||||||
name: "管网在线模拟",
|
name: "管网在线模拟",
|
||||||
list: "/network-simulation",
|
list: "/network-simulation",
|
||||||
@@ -177,6 +200,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "管网在线模拟",
|
label: "管网在线模拟",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.scadaClean)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "SCADA 数据清洗",
|
name: "SCADA 数据清洗",
|
||||||
list: "/scada-data-cleaning",
|
list: "/scada-data-cleaning",
|
||||||
@@ -185,6 +212,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "SCADA 数据清洗",
|
label: "SCADA 数据清洗",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.optimizationRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "监测点优化布置",
|
name: "监测点优化布置",
|
||||||
list: "/monitoring-place-optimization",
|
list: "/monitoring-place-optimization",
|
||||||
@@ -193,6 +224,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "监测点优化布置",
|
label: "监测点优化布置",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.riskRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "健康风险分析",
|
name: "健康风险分析",
|
||||||
list: "/health-risk-analysis",
|
list: "/health-risk-analysis",
|
||||||
@@ -201,13 +236,18 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "健康风险分析",
|
label: "健康风险分析",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "Hydraulic Simulation",
|
name: "Hydraulic Simulation",
|
||||||
meta: {
|
meta: { label: "事件模拟" },
|
||||||
// icon: <MdWater className="w-6 h-6" />,
|
|
||||||
label: "事件模拟",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.burstRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "爆管模拟",
|
name: "爆管模拟",
|
||||||
list: "/hydraulic-simulation/burst-simulation",
|
list: "/hydraulic-simulation/burst-simulation",
|
||||||
@@ -244,6 +284,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "DMA 漏损识别",
|
label: "DMA 漏损识别",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.simulationRun)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "水质模拟",
|
name: "水质模拟",
|
||||||
list: "/hydraulic-simulation/contaminant-simulation",
|
list: "/hydraulic-simulation/contaminant-simulation",
|
||||||
@@ -262,7 +306,9 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "管道冲洗",
|
label: "管道冲洗",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(isMetadataAdmin
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.environmentManage)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
name: "系统管理",
|
name: "系统管理",
|
||||||
@@ -272,6 +318,10 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
label: "系统管理",
|
label: "系统管理",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(can(permissionCodes.auditView)
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "审计日志",
|
name: "审计日志",
|
||||||
list: "/audit-logs",
|
list: "/audit-logs",
|
||||||
@@ -282,18 +332,31 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]}
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectProvider>
|
||||||
|
<RefineKbarProvider>
|
||||||
|
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||||
|
<RefineSnackbarProvider>
|
||||||
|
<Refine
|
||||||
|
routerProvider={routerProvider}
|
||||||
|
dataProvider={dataProvider}
|
||||||
|
notificationProvider={useAppNotificationProvider}
|
||||||
|
authProvider={authProvider}
|
||||||
|
accessControlProvider={accessControlProvider}
|
||||||
|
resources={resources}
|
||||||
options={{
|
options={{
|
||||||
syncWithLocation: true,
|
syncWithLocation: true,
|
||||||
warnWhenUnsavedChanges: true,
|
warnWhenUnsavedChanges: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||||
<RefineKbar />
|
<RefineKbar />
|
||||||
</Refine>
|
</Refine>
|
||||||
</RefineSnackbarProvider>
|
</RefineSnackbarProvider>
|
||||||
</ColorModeContextProvider>
|
</ColorModeContextProvider>
|
||||||
</RefineKbarProvider>
|
</RefineKbarProvider>
|
||||||
</>
|
</ProjectProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,24 +106,30 @@ type DatabaseHealth = {
|
|||||||
|
|
||||||
const businessRoleOptions = [
|
const businessRoleOptions = [
|
||||||
{ value: "admin", label: "系统管理员" },
|
{ value: "admin", label: "系统管理员" },
|
||||||
{ value: "operator", label: "运行人员" },
|
|
||||||
{ value: "user", label: "普通用户" },
|
{ value: "user", label: "普通用户" },
|
||||||
{ value: "viewer", label: "只读用户" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const projectRoleLabels: Record<string, string> = {
|
const projectRoleLabels: Record<string, string> = {
|
||||||
owner: "项目负责人",
|
|
||||||
admin: "项目管理员",
|
|
||||||
member: "项目成员",
|
member: "项目成员",
|
||||||
viewer: "只读成员",
|
viewer: "只读成员",
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectRoleOptions = [
|
const projectRoleOptions = [
|
||||||
{ value: "admin", label: projectRoleLabels.admin },
|
|
||||||
{ value: "member", label: projectRoleLabels.member },
|
{ value: "member", label: projectRoleLabels.member },
|
||||||
{ value: "viewer", label: projectRoleLabels.viewer },
|
{ value: "viewer", label: projectRoleLabels.viewer },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const projectRolePermissionSummary = [
|
||||||
|
{
|
||||||
|
role: "项目成员",
|
||||||
|
permissions: "WebGIS 编辑、SCADA 清洗、水力模拟、爆管、风险和监测点优化分析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "只读成员",
|
||||||
|
permissions: "默认工作台、WebGIS、SCADA 和既有模拟结果只读",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const projectStatusOptions = [
|
const projectStatusOptions = [
|
||||||
{ value: "active", label: "启用" },
|
{ value: "active", label: "启用" },
|
||||||
{ value: "inactive", label: "停用" },
|
{ value: "inactive", label: "停用" },
|
||||||
@@ -1340,7 +1346,7 @@ export const SystemAdminPanel = () => {
|
|||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="项目成员"
|
title="项目成员"
|
||||||
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。"
|
description="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -1352,6 +1358,25 @@ export const SystemAdminPanel = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1 }}>
|
||||||
|
固定项目权限
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", md: "row" }}
|
||||||
|
spacing={1}
|
||||||
|
useFlexGap
|
||||||
|
flexWrap="wrap"
|
||||||
|
>
|
||||||
|
{projectRolePermissionSummary.map((item) => (
|
||||||
|
<Chip
|
||||||
|
key={item.role}
|
||||||
|
label={`${item.role}:${item.permissions}`}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
{!hasProjectId && (
|
{!hasProjectId && (
|
||||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||||
当前未选择管理项目。
|
当前未选择管理项目。
|
||||||
@@ -1467,7 +1492,6 @@ export const SystemAdminPanel = () => {
|
|||||||
)}
|
)}
|
||||||
{members.map((member) => {
|
{members.map((member) => {
|
||||||
const isSelf = member.user_id === currentAdmin?.id;
|
const isSelf = member.user_id === currentAdmin?.id;
|
||||||
const isOwner = member.project_role === "owner";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={member.id} hover>
|
<TableRow key={member.id} hover>
|
||||||
@@ -1486,15 +1510,6 @@ export const SystemAdminPanel = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{member.email}</TableCell>
|
<TableCell>{member.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{isOwner ? (
|
|
||||||
<Tooltip title="项目负责人不在系统管理页维护">
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
color="warning"
|
|
||||||
label={getProjectRoleLabel(member.project_role)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
|
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
|
||||||
<span>
|
<span>
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -1520,20 +1535,13 @@ export const SystemAdminPanel = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusChip active={member.is_active} />
|
<StatusChip active={member.is_active} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={isSelf ? "不能操作当前登录用户" : ""}
|
||||||
isSelf
|
|
||||||
? "不能操作当前登录用户"
|
|
||||||
: isOwner
|
|
||||||
? "项目负责人不在系统管理页维护"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
@@ -1542,7 +1550,7 @@ export const SystemAdminPanel = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<RemoveCircleOutlineIcon />}
|
startIcon={<RemoveCircleOutlineIcon />}
|
||||||
onClick={() => removeMember(member)}
|
onClick={() => removeMember(member)}
|
||||||
disabled={isSelf || isOwner}
|
disabled={isSelf}
|
||||||
>
|
>
|
||||||
移除
|
移除
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import {
|
|||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { permissionCodes } from "@/lib/permissions";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
|
||||||
type AuditLog = {
|
type AuditLog = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -105,6 +107,8 @@ const defaultFilters: AuditFilters = {
|
|||||||
end_time: "",
|
end_time: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AUDIT_LOGS_PATH = "/api/v1/audit/logs";
|
||||||
|
|
||||||
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [
|
||||||
{ value: "all", label: "全部状态" },
|
{ value: "all", label: "全部状态" },
|
||||||
{ value: "success", label: "成功 2xx" },
|
{ value: "success", label: "成功 2xx" },
|
||||||
@@ -338,8 +342,14 @@ const DetailSection = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const AuditLogPanel = () => {
|
export const AuditLogPanel = () => {
|
||||||
const [adminChecked, setAdminChecked] = useState(false);
|
const accessLoading = useAccessStore((state) => state.loading);
|
||||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
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<AuditLog[]>([]);
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
const [users, setUsers] = useState<MetadataUser[]>([]);
|
const [users, setUsers] = useState<MetadataUser[]>([]);
|
||||||
const [projects, setProjects] = useState<AdminProject[]>([]);
|
const [projects, setProjects] = useState<AdminProject[]>([]);
|
||||||
@@ -392,8 +402,8 @@ export const AuditLogPanel = () => {
|
|||||||
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
|
const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage);
|
||||||
const countParams = buildCountParams(appliedFilters);
|
const countParams = buildCountParams(appliedFilters);
|
||||||
const [logsResponse, countResponse] = await Promise.all([
|
const [logsResponse, countResponse] = await Promise.all([
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs?${params.toString()}`),
|
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`),
|
||||||
apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/count?${countParams.toString()}`),
|
apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`),
|
||||||
]);
|
]);
|
||||||
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
|
if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse));
|
||||||
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
|
if (!countResponse.ok) throw new Error(await readErrorText(countResponse));
|
||||||
@@ -402,7 +412,7 @@ export const AuditLogPanel = () => {
|
|||||||
setTotalCount(Number(countPayload.count ?? 0));
|
setTotalCount(Number(countPayload.count ?? 0));
|
||||||
} else {
|
} else {
|
||||||
const params = buildServerParams(appliedFilters, 0, 1000);
|
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));
|
if (!response.ok) throw new Error(await readErrorText(response));
|
||||||
const payload = (await response.json()) as AuditLog[];
|
const payload = (await response.json()) as AuditLog[];
|
||||||
setLogs(payload);
|
setLogs(payload);
|
||||||
@@ -417,42 +427,9 @@ export const AuditLogPanel = () => {
|
|||||||
}, [appliedFilters, page, rowsPerPage]);
|
}, [appliedFilters, page, rowsPerPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
if (!isAuthorized) return;
|
||||||
|
|
||||||
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;
|
|
||||||
void loadOptions();
|
void loadOptions();
|
||||||
}, [adminChecked, isAuthorized, loadOptions]);
|
}, [isAuthorized, loadOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!adminChecked || !isAuthorized) return;
|
if (!adminChecked || !isAuthorized) return;
|
||||||
@@ -476,7 +453,7 @@ export const AuditLogPanel = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = buildServerParams(appliedFilters, 0, 1000);
|
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));
|
if (!response.ok) throw new Error(await readErrorText(response));
|
||||||
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
|
const payload = ((await response.json()) as AuditLog[]).filter((log) =>
|
||||||
matchesStatusFilter(log, appliedFilters.status),
|
matchesStatusFilter(log, appliedFilters.status),
|
||||||
@@ -544,7 +521,7 @@ export const AuditLogPanel = () => {
|
|||||||
审计日志
|
审计日志
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
管理员审计查询
|
全局审计查询
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
|
|||||||
<Chip
|
<Chip
|
||||||
color="success"
|
color="success"
|
||||||
icon={<AdminPanelSettingsIcon />}
|
icon={<AdminPanelSettingsIcon />}
|
||||||
label="管理员权限已验证"
|
label="系统管理员权限已验证"
|
||||||
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
|
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredPermission && !permissions.includes(requiredPermission)) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: { xs: 2, md: 4 } }}>
|
||||||
|
<Alert severity="error" icon={<LockOutlinedIcon />}>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography fontWeight={700}>无权访问此功能</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
当前项目角色缺少权限:{requiredPermission}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
@@ -11,7 +11,10 @@ import AnalysisReport, {
|
|||||||
matchesValveAnalysis,
|
matchesValveAnalysis,
|
||||||
} from "./AnalysisReport";
|
} from "./AnalysisReport";
|
||||||
import { SchemeRecord, ValveIsolationResult } from "./types";
|
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||||
import { isAllowedAccidentPipe } from "./valveIsolationScope";
|
import {
|
||||||
|
isAllowedAccidentPipe,
|
||||||
|
normalizeValveIsolationResult,
|
||||||
|
} from "./valveIsolationScope";
|
||||||
|
|
||||||
jest.mock("@/utils/mapQueryService", () => ({
|
jest.mock("@/utils/mapQueryService", () => ({
|
||||||
queryFeaturesByIds: jest.fn(),
|
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(
|
||||||
|
<AnalysisReport
|
||||||
|
scheme={scheme}
|
||||||
|
valveResult={nonIsolatableResult}
|
||||||
|
disabledValves={[]}
|
||||||
|
generatedAt={new Date("2026-07-30T10:00:00+08:00")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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", () => {
|
it("limits scheme-bound valve analysis to the scheme accident pipes", () => {
|
||||||
expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true);
|
expect(isAllowedAccidentPipe("P-1", ["P-1", "P-2"])).toBe(true);
|
||||||
expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false);
|
expect(isAllowedAccidentPipe("P-99", ["P-1", "P-2"])).toBe(false);
|
||||||
expect(isAllowedAccidentPipe("P-99", undefined)).toBe(true);
|
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 () => {
|
it("prints only after assigning the report print state", async () => {
|
||||||
const originalTitle = document.title;
|
const originalTitle = document.title;
|
||||||
const print = jest
|
const print = jest
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type PipeDiameterMap,
|
type PipeDiameterMap,
|
||||||
} from "./schemePipeDiameters";
|
} from "./schemePipeDiameters";
|
||||||
import { SchemeRecord, ValveIsolationResult } from "./types";
|
import { SchemeRecord, ValveIsolationResult } from "./types";
|
||||||
|
import { getAffectedNodeCount } from "./valveIsolationScope";
|
||||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
interface AnalysisReportProps {
|
interface AnalysisReportProps {
|
||||||
@@ -212,6 +213,18 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
|
|||||||
? valveResult
|
? valveResult
|
||||||
: null;
|
: null;
|
||||||
const duration = scheme.schemeDetail?.modify_total_duration;
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -348,7 +361,7 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
|
|||||||
{[
|
{[
|
||||||
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
|
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
|
||||||
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`],
|
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`],
|
||||||
["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0} 个`],
|
["受影响节点", `${getAffectedNodeCount(matchedValveResult)} 个`],
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<Paper
|
<Paper
|
||||||
key={label}
|
key={label}
|
||||||
@@ -364,20 +377,19 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
|
|||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
{[
|
{valveDetailRows.map(([label, values]) => (
|
||||||
["已分析事故管段", matchedValveResult.accident_elements],
|
<Box key={label} sx={{ breakInside: "avoid" }}>
|
||||||
["必关阀门", matchedValveResult.must_close_valves],
|
|
||||||
["可选阀门", matchedValveResult.optional_valves],
|
|
||||||
["不可用阀门", disabledValves],
|
|
||||||
["受影响节点", matchedValveResult.affected_nodes],
|
|
||||||
].map(([label, values]) => (
|
|
||||||
<Box key={label as string} sx={{ breakInside: "avoid" }}>
|
|
||||||
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
|
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
|
||||||
{label as string}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<IdList values={values as string[]} />
|
<IdList values={values} />
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
{!matchedValveResult.isolatable && (
|
||||||
|
<Alert severity="info" variant="outlined">
|
||||||
|
不可隔离,未生成受影响节点清单。
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Alert severity="info" variant="outlined">
|
<Alert severity="info" variant="outlined">
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ import {
|
|||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
import { isAllowedAccidentPipe } from "./valveIsolationScope";
|
import {
|
||||||
|
getAffectedNodeCount,
|
||||||
|
isAllowedAccidentPipe,
|
||||||
|
normalizeValveIsolationResult,
|
||||||
|
} from "./valveIsolationScope";
|
||||||
|
|
||||||
interface ValveIsolationProps {
|
interface ValveIsolationProps {
|
||||||
initialPipeIds?: string[];
|
initialPipeIds?: string[];
|
||||||
@@ -363,7 +367,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
if (disabled.length > 0) {
|
if (disabled.length > 0) {
|
||||||
params.disabled_valves = disabled;
|
params.disabled_valves = disabled;
|
||||||
}
|
}
|
||||||
const response = await api.get(
|
const response = await api.get<ValveIsolationResult>(
|
||||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
||||||
{
|
{
|
||||||
params,
|
params,
|
||||||
@@ -372,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setResult(response.data);
|
setResult(normalizeValveIsolationResult(response.data));
|
||||||
if (!isExpandSearch) {
|
if (!isExpandSearch) {
|
||||||
setActiveStep(1);
|
setActiveStep(1);
|
||||||
} else {
|
} else {
|
||||||
@@ -711,7 +715,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
{[
|
{[
|
||||||
{ label: "必关阀门", value: result.must_close_valves?.length || 0, color: "red", bgInfo: "from-red-50 to-red-100", textInfo: "text-red-700" },
|
{ 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.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) => (
|
].map((item, index) => (
|
||||||
<Box
|
<Box
|
||||||
key={index}
|
key={index}
|
||||||
@@ -877,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!result.isolatable && (
|
||||||
|
<Alert severity="info" variant="outlined">
|
||||||
|
不可隔离,未生成受影响节点清单。
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 受影响节点 */}
|
{/* 受影响节点 */}
|
||||||
{result.affected_nodes && result.affected_nodes.length > 0 && (
|
{result.isolatable && result.affected_nodes && result.affected_nodes.length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Box className="flex items-center justify-between mb-2">
|
<Box className="flex items-center justify-between mb-2">
|
||||||
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
|
<Typography variant="caption" className="text-gray-800 font-bold border-l-4 border-blue-500 pl-2">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface SchemaItem {
|
|||||||
export interface ValveIsolationResult {
|
export interface ValveIsolationResult {
|
||||||
accident_elements: string[];
|
accident_elements: string[];
|
||||||
affected_nodes: string[];
|
affected_nodes: string[];
|
||||||
|
affected_node_count?: number;
|
||||||
must_close_valves: string[];
|
must_close_valves: string[];
|
||||||
optional_valves: string[];
|
optional_valves: string[];
|
||||||
isolatable: boolean;
|
isolatable: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
|
import type { ValveIsolationResult } from "./types";
|
||||||
|
|
||||||
export const isAllowedAccidentPipe = (
|
export const isAllowedAccidentPipe = (
|
||||||
pipeId: string,
|
pipeId: string,
|
||||||
allowedPipeIds: string[] | undefined,
|
allowedPipeIds: string[] | undefined,
|
||||||
) => allowedPipeIds === undefined || allowedPipeIds.includes(pipeId);
|
) => 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),
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { FiSkipBack, FiSkipForward } from "react-icons/fi";
|
|||||||
import { useData } from "../MapComponent";
|
import { useData } from "../MapComponent";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { permissionCodes } from "@/lib/permissions";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useMap } from "../MapComponent";
|
import { useMap } from "../MapComponent";
|
||||||
import {
|
import {
|
||||||
formatTimelineTime,
|
formatTimelineTime,
|
||||||
@@ -81,6 +83,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
schemeType = "burst_analysis",
|
schemeType = "burst_analysis",
|
||||||
}) => {
|
}) => {
|
||||||
const data = useData();
|
const data = useData();
|
||||||
|
const canRunSimulation = useAccessStore((state) =>
|
||||||
|
state.permissions.includes(permissionCodes.simulationRun),
|
||||||
|
);
|
||||||
const fallbackSelectedDateRef = useRef(new Date());
|
const fallbackSelectedDateRef = useRef(new Date());
|
||||||
const hasTimelineState =
|
const hasTimelineState =
|
||||||
data &&
|
data &&
|
||||||
@@ -926,8 +931,8 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
{canRunSimulation && (
|
||||||
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
||||||
{/* 强制计算时间段 */}
|
|
||||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||||
<InputLabel>计算时间段</InputLabel>
|
<InputLabel>计算时间段</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -943,7 +948,6 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* 功能按钮 */}
|
|
||||||
<Tooltip title="强制计算">
|
<Tooltip title="强制计算">
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -955,6 +959,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 当前时间显示 */}
|
{/* 当前时间显示 */}
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { useStyleEditor } from "./useStyleEditor";
|
|||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useProject } from "@/contexts/ProjectContext";
|
import { useProject } from "@/contexts/ProjectContext";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { permissionCodes } from "@/lib/permissions";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
|
||||||
// 添加接口定义隐藏按钮的props
|
// 添加接口定义隐藏按钮的props
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
@@ -94,6 +96,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
const project = useProject();
|
const project = useProject();
|
||||||
|
const canEditNetwork = useAccessStore((state) =>
|
||||||
|
state.permissions.includes(permissionCodes.webgisEdit),
|
||||||
|
);
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [activeTools, setActiveTools] = useState<string[]>([]);
|
const [activeTools, setActiveTools] = useState<string[]>([]);
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
@@ -119,6 +124,15 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
}
|
}
|
||||||
}, [enableCompare, isCompareMode, toggleCompareMode]);
|
}, [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)
|
// Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
|
||||||
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
|
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
|
||||||
[string, string][] | null
|
[string, string][] | null
|
||||||
@@ -697,7 +711,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
buildFeatureProperties(
|
buildFeatureProperties(
|
||||||
selectedFeature,
|
selectedFeature,
|
||||||
computedProperties,
|
computedProperties,
|
||||||
selectedValveId
|
canEditNetwork && selectedValveId
|
||||||
? {
|
? {
|
||||||
value: valveStatus,
|
value: valveStatus,
|
||||||
loading: isValveStatusLoading,
|
loading: isValveStatusLoading,
|
||||||
@@ -705,7 +719,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
onSave: handleValveStatusSave,
|
onSave: handleValveStatusSave,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
selectedValveId
|
canEditNetwork && selectedValveId
|
||||||
? {
|
? {
|
||||||
value: valveProperties.setting,
|
value: valveProperties.setting,
|
||||||
vType: selectedValveType,
|
vType: selectedValveType,
|
||||||
@@ -719,6 +733,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
[
|
[
|
||||||
selectedFeature,
|
selectedFeature,
|
||||||
computedProperties,
|
computedProperties,
|
||||||
|
canEditNetwork,
|
||||||
selectedValveId,
|
selectedValveId,
|
||||||
valveStatus,
|
valveStatus,
|
||||||
valveProperties,
|
valveProperties,
|
||||||
@@ -755,7 +770,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
onClick={() => handleToolClick("history")}
|
onClick={() => handleToolClick("history")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!hiddenButtons?.includes("draw") && (
|
{canEditNetwork && !hiddenButtons?.includes("draw") && (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<EditOutlinedIcon />}
|
icon={<EditOutlinedIcon />}
|
||||||
name="标记绘制"
|
name="标记绘制"
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
||||||
import { useSession } from "next-auth/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 { ProjectSelector } from "@/components/project/ProjectSelector";
|
||||||
|
import {
|
||||||
|
config,
|
||||||
|
NETWORK_NAME,
|
||||||
|
setMapExtent,
|
||||||
|
setMapWorkspace,
|
||||||
|
setNetworkName,
|
||||||
|
} from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { permissionCodes } from "@/lib/permissions";
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
|
||||||
interface ProjectContextType {
|
interface ProjectContextType {
|
||||||
@@ -21,6 +38,11 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const canManageEnvironment = useAccessStore((state) =>
|
||||||
|
state.permissions.includes(permissionCodes.environmentManage),
|
||||||
|
);
|
||||||
const [isConfigured, setIsConfigured] = useState(false);
|
const [isConfigured, setIsConfigured] = useState(false);
|
||||||
const setCurrentProjectId = useProjectStore(
|
const setCurrentProjectId = useProjectStore(
|
||||||
(state) => state.setCurrentProjectId,
|
(state) => state.setCurrentProjectId,
|
||||||
@@ -31,76 +53,69 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
extent: config.MAP_EXTENT,
|
extent: config.MAP_EXTENT,
|
||||||
});
|
});
|
||||||
|
|
||||||
const applyConfig = useCallback(async (
|
const applyConfig = useCallback(
|
||||||
|
async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
ws: string,
|
workspace: string,
|
||||||
net: string,
|
networkName: string,
|
||||||
extent: number[],
|
extent: number[],
|
||||||
) => {
|
) => {
|
||||||
const resolvedProjectId = projectId || net || ws;
|
const resolvedProjectId = projectId || networkName || workspace;
|
||||||
setMapWorkspace(ws);
|
setMapWorkspace(workspace);
|
||||||
setNetworkName(net);
|
setNetworkName(networkName);
|
||||||
setMapExtent(extent);
|
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.setItem(MAP_EXTENT_STORAGE_KEY, extent.join(","));
|
||||||
// Reset extent cache
|
localStorage.removeItem(`${workspace}_map_view`);
|
||||||
localStorage.removeItem(`${ws}_map_view`);
|
setCurrentProject({ workspace, networkName, extent });
|
||||||
setCurrentProject({ workspace: ws, networkName: net, extent: extent });
|
|
||||||
setCurrentProjectId(resolvedProjectId);
|
setCurrentProjectId(resolvedProjectId);
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, ws);
|
|
||||||
localStorage.setItem(NETWORK_NAME_STORAGE_KEY, net);
|
|
||||||
|
|
||||||
setIsConfigured(true);
|
setIsConfigured(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Open project backend (simulation model)
|
|
||||||
const openResponse = await apiFetch(
|
const openResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/projects/open?network=${net}`,
|
`${config.BACKEND_URL}/api/v1/projects/open?network=${networkName}`,
|
||||||
{
|
{ method: "POST" },
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if (!openResponse.ok) {
|
if (!openResponse.ok) {
|
||||||
throw new Error(`Failed to open project: HTTP ${openResponse.status}`);
|
throw new Error(`Failed to open project: HTTP ${openResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch project metadata
|
|
||||||
const infoResponse = await apiFetch(
|
const infoResponse = await apiFetch(
|
||||||
`${config.BACKEND_URL}/api/v1/project-info?network=${net}`,
|
`${config.BACKEND_URL}/api/v1/project-info?network=${networkName}`,
|
||||||
);
|
);
|
||||||
if (!infoResponse.ok) {
|
if (!infoResponse.ok) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Failed to fetch project info: HTTP ${infoResponse.status}`,
|
`Failed to fetch project info: HTTP ${infoResponse.status}`,
|
||||||
);
|
);
|
||||||
} else {
|
return;
|
||||||
const data = await infoResponse.json();
|
}
|
||||||
|
|
||||||
// Update workspace if different
|
const data = await infoResponse.json();
|
||||||
if (data?.gs_workspace && data.gs_workspace !== ws) {
|
if (data?.gs_workspace && data.gs_workspace !== workspace) {
|
||||||
setMapWorkspace(data.gs_workspace);
|
setMapWorkspace(data.gs_workspace);
|
||||||
localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, data.gs_workspace);
|
localStorage.setItem(MAP_WORKSPACE_STORAGE_KEY, data.gs_workspace);
|
||||||
setCurrentProject((prev) => ({
|
setCurrentProject((previous) => ({
|
||||||
...prev,
|
...previous,
|
||||||
workspace: data.gs_workspace,
|
workspace: data.gs_workspace,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update extent if available
|
|
||||||
const bbox = Array.isArray(data?.map_extent?.bbox)
|
const bbox = Array.isArray(data?.map_extent?.bbox)
|
||||||
? data.map_extent.bbox.map((value: number) => Number(value))
|
? data.map_extent.bbox.map((value: number) => Number(value))
|
||||||
: null;
|
: null;
|
||||||
if (bbox && bbox.length === 4) {
|
if (bbox?.length === 4) {
|
||||||
setMapExtent(bbox);
|
setMapExtent(bbox);
|
||||||
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, bbox.join(","));
|
localStorage.setItem(MAP_EXTENT_STORAGE_KEY, bbox.join(","));
|
||||||
localStorage.removeItem(`${ws}_map_view`);
|
localStorage.removeItem(`${workspace}_map_view`);
|
||||||
setCurrentProject((prev) => ({ ...prev, extent: bbox }));
|
setCurrentProject((previous) => ({ ...previous, extent: bbox }));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to setup project:", error);
|
console.error("Failed to setup project:", error);
|
||||||
}
|
}
|
||||||
}, [setCurrentProjectId]);
|
},
|
||||||
|
[setCurrentProjectId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check localStorage
|
// Check localStorage
|
||||||
@@ -120,11 +135,16 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}, [applyConfig]);
|
}, [applyConfig]);
|
||||||
|
|
||||||
// Only show selector if authenticated and not configured
|
const isGlobalManagementRoute =
|
||||||
if (status === "authenticated" && !isConfigured) {
|
pathname.startsWith("/system-admin") || pathname.startsWith("/audit-logs");
|
||||||
|
|
||||||
|
if (status === "authenticated" && !isConfigured && !isGlobalManagementRoute) {
|
||||||
return (
|
return (
|
||||||
<ProjectSelector
|
<ProjectSelector
|
||||||
open={true}
|
open={true}
|
||||||
|
onClose={
|
||||||
|
canManageEnvironment ? () => router.push("/system-admin") : undefined
|
||||||
|
}
|
||||||
onSelect={(projectId, ws, net, extent) =>
|
onSelect={(projectId, ws, net, extent) =>
|
||||||
applyConfig(projectId, ws, net, extent)
|
applyConfig(projectId, ws, net, extent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, PermissionCode> = {
|
||||||
|
"管网在线模拟": 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;
|
||||||
@@ -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<AccessState>((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 }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user