feat(frontend): enforce RBAC and refine burst analysis
This commit is contained in:
+256
-193
@@ -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<RefineContextProps>
|
||||
) => {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ProjectProvider>
|
||||
<App {...props} />
|
||||
</ProjectProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
props: React.PropsWithChildren<RefineContextProps>,
|
||||
) => (
|
||||
<SessionProvider>
|
||||
<App {...props} />
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
type AppProps = {
|
||||
defaultMode?: string;
|
||||
@@ -55,40 +57,71 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
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 <span>loading...</span>;
|
||||
@@ -100,200 +133,230 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
||||
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: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||
label: "管网在线模拟",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.scadaClean)
|
||||
? [
|
||||
{
|
||||
name: "SCADA 数据清洗",
|
||||
list: "/scada-data-cleaning",
|
||||
meta: {
|
||||
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||
label: "SCADA 数据清洗",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.optimizationRun)
|
||||
? [
|
||||
{
|
||||
name: "监测点优化布置",
|
||||
list: "/monitoring-place-optimization",
|
||||
meta: {
|
||||
icon: <LuReplace className="w-6 h-6" />,
|
||||
label: "监测点优化布置",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.riskRun)
|
||||
? [
|
||||
{
|
||||
name: "健康风险分析",
|
||||
list: "/health-risk-analysis",
|
||||
meta: {
|
||||
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
|
||||
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: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "爆管模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管侦测",
|
||||
list: "/hydraulic-simulation/burst-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbActivity className="w-6 h-6" />,
|
||||
label: "爆管侦测",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管定位",
|
||||
list: "/hydraulic-simulation/burst-location",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||
label: "爆管定位",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMA 漏损识别",
|
||||
list: "/hydraulic-simulation/dma-leak-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <SearchIcon className="w-6 h-6" />,
|
||||
label: "DMA 漏损识别",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.simulationRun)
|
||||
? [
|
||||
{
|
||||
name: "水质模拟",
|
||||
list: "/hydraulic-simulation/contaminant-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
||||
label: "水质模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管道冲洗",
|
||||
list: "/hydraulic-simulation/flushing-analysis",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
||||
label: "管道冲洗",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.environmentManage)
|
||||
? [
|
||||
{
|
||||
name: "系统管理",
|
||||
list: "/system-admin",
|
||||
meta: {
|
||||
icon: <ManageAccountsIcon className="w-6 h-6" />,
|
||||
label: "系统管理",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(permissionCodes.auditView)
|
||||
? [
|
||||
{
|
||||
name: "审计日志",
|
||||
list: "/audit-logs",
|
||||
meta: {
|
||||
icon: <FactCheckIcon className="w-6 h-6" />,
|
||||
label: "审计日志",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectProvider>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider defaultMode={defaultMode}>
|
||||
<ColorModeContextProvider defaultMode={props.defaultMode}>
|
||||
<RefineSnackbarProvider>
|
||||
<Refine
|
||||
routerProvider={routerProvider}
|
||||
dataProvider={dataProvider}
|
||||
notificationProvider={useAppNotificationProvider}
|
||||
authProvider={authProvider}
|
||||
resources={[
|
||||
{
|
||||
name: "管网在线模拟",
|
||||
list: "/network-simulation",
|
||||
meta: {
|
||||
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
|
||||
label: "管网在线模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SCADA 数据清洗",
|
||||
list: "/scada-data-cleaning",
|
||||
meta: {
|
||||
icon: <TbDatabaseEdit className="w-6 h-6" />,
|
||||
label: "SCADA 数据清洗",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "监测点优化布置",
|
||||
list: "/monitoring-place-optimization",
|
||||
meta: {
|
||||
icon: <LuReplace className="w-6 h-6" />,
|
||||
label: "监测点优化布置",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "健康风险分析",
|
||||
list: "/health-risk-analysis",
|
||||
meta: {
|
||||
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
|
||||
label: "健康风险分析",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Hydraulic Simulation",
|
||||
meta: {
|
||||
// icon: <MdWater className="w-6 h-6" />,
|
||||
label: "事件模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管模拟",
|
||||
list: "/hydraulic-simulation/burst-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbLocationPin className="w-6 h-6" />,
|
||||
label: "爆管模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管侦测",
|
||||
list: "/hydraulic-simulation/burst-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <TbActivity className="w-6 h-6" />,
|
||||
label: "爆管侦测",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "爆管定位",
|
||||
list: "/hydraulic-simulation/burst-location",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MyLocationIcon className="w-6 h-6" />,
|
||||
label: "爆管定位",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DMA 漏损识别",
|
||||
list: "/hydraulic-simulation/dma-leak-detection",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <SearchIcon className="w-6 h-6" />,
|
||||
label: "DMA 漏损识别",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "水质模拟",
|
||||
list: "/hydraulic-simulation/contaminant-simulation",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
|
||||
label: "水质模拟",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "管道冲洗",
|
||||
list: "/hydraulic-simulation/flushing-analysis",
|
||||
meta: {
|
||||
parent: "Hydraulic Simulation",
|
||||
icon: <MdCleaningServices className="w-6 h-6" />,
|
||||
label: "管道冲洗",
|
||||
},
|
||||
},
|
||||
...(isMetadataAdmin
|
||||
? [
|
||||
{
|
||||
name: "系统管理",
|
||||
list: "/system-admin",
|
||||
meta: {
|
||||
icon: <ManageAccountsIcon className="w-6 h-6" />,
|
||||
label: "系统管理",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "审计日志",
|
||||
list: "/audit-logs",
|
||||
meta: {
|
||||
icon: <FactCheckIcon className="w-6 h-6" />,
|
||||
label: "审计日志",
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
accessControlProvider={accessControlProvider}
|
||||
resources={resources}
|
||||
options={{
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||
<RefineKbar />
|
||||
</Refine>
|
||||
</RefineSnackbarProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</>
|
||||
</ProjectProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -106,24 +106,30 @@ type DatabaseHealth = {
|
||||
|
||||
const businessRoleOptions = [
|
||||
{ value: "admin", label: "系统管理员" },
|
||||
{ value: "operator", label: "运行人员" },
|
||||
{ value: "user", label: "普通用户" },
|
||||
{ value: "viewer", label: "只读用户" },
|
||||
];
|
||||
|
||||
const projectRoleLabels: Record<string, string> = {
|
||||
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 = () => {
|
||||
<Stack spacing={2}>
|
||||
<SectionHeader
|
||||
title="项目成员"
|
||||
description="维护当前项目内权限;此处只管理项目管理员、项目成员和只读成员,项目负责人另行维护。"
|
||||
description="项目访问角色保存在 Metadata DB 的 user_project_membership.project_role。模型上传由桌面端发起,仅系统管理员可执行。"
|
||||
action={
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -1352,6 +1358,25 @@ export const SystemAdminPanel = () => {
|
||||
</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 && (
|
||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||
当前未选择管理项目。
|
||||
@@ -1467,7 +1492,6 @@ export const SystemAdminPanel = () => {
|
||||
)}
|
||||
{members.map((member) => {
|
||||
const isSelf = member.user_id === currentAdmin?.id;
|
||||
const isOwner = member.project_role === "owner";
|
||||
|
||||
return (
|
||||
<TableRow key={member.id} hover>
|
||||
@@ -1486,54 +1510,38 @@ export const SystemAdminPanel = () => {
|
||||
</TableCell>
|
||||
<TableCell>{member.email}</TableCell>
|
||||
<TableCell>
|
||||
{isOwner ? (
|
||||
<Tooltip title="项目负责人不在系统管理页维护">
|
||||
<Chip
|
||||
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
|
||||
<span>
|
||||
<FormControl
|
||||
size="small"
|
||||
color="warning"
|
||||
label={getProjectRoleLabel(member.project_role)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={isSelf ? "不能操作当前登录用户" : ""}>
|
||||
<span>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{ minWidth: 140 }}
|
||||
disabled={isSelf}
|
||||
sx={{ minWidth: 140 }}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<Select
|
||||
value={member.project_role}
|
||||
onChange={(e) =>
|
||||
updateMemberRole(member, e.target.value)
|
||||
}
|
||||
renderValue={(value) =>
|
||||
getProjectRoleLabel(String(value))
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={member.project_role}
|
||||
onChange={(e) =>
|
||||
updateMemberRole(member, e.target.value)
|
||||
}
|
||||
renderValue={(value) =>
|
||||
getProjectRoleLabel(String(value))
|
||||
}
|
||||
>
|
||||
{projectRoleOptions.map((role) => (
|
||||
<MenuItem key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{projectRoleOptions.map((role) => (
|
||||
<MenuItem key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusChip active={member.is_active} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip
|
||||
title={
|
||||
isSelf
|
||||
? "不能操作当前登录用户"
|
||||
: isOwner
|
||||
? "项目负责人不在系统管理页维护"
|
||||
: ""
|
||||
}
|
||||
title={isSelf ? "不能操作当前登录用户" : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
@@ -1542,7 +1550,7 @@ export const SystemAdminPanel = () => {
|
||||
variant="outlined"
|
||||
startIcon={<RemoveCircleOutlineIcon />}
|
||||
onClick={() => removeMember(member)}
|
||||
disabled={isSelf || isOwner}
|
||||
disabled={isSelf}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
|
||||
@@ -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<AuditLog[]>([]);
|
||||
const [users, setUsers] = useState<MetadataUser[]>([]);
|
||||
const [projects, setProjects] = useState<AdminProject[]>([]);
|
||||
@@ -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 = () => {
|
||||
审计日志
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
管理员审计查询
|
||||
全局审计查询
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -552,7 +529,7 @@ export const AuditLogPanel = () => {
|
||||
<Chip
|
||||
color="success"
|
||||
icon={<AdminPanelSettingsIcon />}
|
||||
label="管理员权限已验证"
|
||||
label="系统管理员权限已验证"
|
||||
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,
|
||||
} 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(
|
||||
<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", () => {
|
||||
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
|
||||
|
||||
@@ -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<ReportDocumentProps> = ({
|
||||
? 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 (
|
||||
<Box
|
||||
@@ -348,7 +361,7 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
|
||||
{[
|
||||
["隔离结论", matchedValveResult.isolatable ? "可隔离" : "不可隔离"],
|
||||
["必关阀门", `${matchedValveResult.must_close_valves?.length ?? 0} 个`],
|
||||
["受影响节点", `${matchedValveResult.affected_nodes?.length ?? 0} 个`],
|
||||
["受影响节点", `${getAffectedNodeCount(matchedValveResult)} 个`],
|
||||
].map(([label, value]) => (
|
||||
<Paper
|
||||
key={label}
|
||||
@@ -364,20 +377,19 @@ const ReportDocument: React.FC<ReportDocumentProps> = ({
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
{[
|
||||
["已分析事故管段", matchedValveResult.accident_elements],
|
||||
["必关阀门", matchedValveResult.must_close_valves],
|
||||
["可选阀门", matchedValveResult.optional_valves],
|
||||
["不可用阀门", disabledValves],
|
||||
["受影响节点", matchedValveResult.affected_nodes],
|
||||
].map(([label, values]) => (
|
||||
<Box key={label as string} sx={{ breakInside: "avoid" }}>
|
||||
{valveDetailRows.map(([label, values]) => (
|
||||
<Box key={label} sx={{ breakInside: "avoid" }}>
|
||||
<Typography sx={{ mb: 0.75, fontSize: 13, fontWeight: 700 }}>
|
||||
{label as string}
|
||||
{label}
|
||||
</Typography>
|
||||
<IdList values={values as string[]} />
|
||||
<IdList values={values} />
|
||||
</Box>
|
||||
))}
|
||||
{!matchedValveResult.isolatable && (
|
||||
<Alert severity="info" variant="outlined">
|
||||
不可隔离,未生成受影响节点清单。
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Alert severity="info" variant="outlined">
|
||||
|
||||
@@ -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<ValveIsolationProps> = ({
|
||||
if (disabled.length > 0) {
|
||||
params.disabled_valves = disabled;
|
||||
}
|
||||
const response = await api.get(
|
||||
const response = await api.get<ValveIsolationResult>(
|
||||
`${config.BACKEND_URL}/api/v1/valve-isolation-analysis`,
|
||||
{
|
||||
params,
|
||||
@@ -372,7 +376,7 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
},
|
||||
},
|
||||
);
|
||||
setResult(response.data);
|
||||
setResult(normalizeValveIsolationResult(response.data));
|
||||
if (!isExpandSearch) {
|
||||
setActiveStep(1);
|
||||
} 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.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) => (
|
||||
<Box
|
||||
key={index}
|
||||
@@ -877,8 +881,14 @@ const ValveIsolation: React.FC<ValveIsolationProps> = ({
|
||||
</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 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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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<TimelineProps> = ({
|
||||
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<TimelineProps> = ({
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
||||
{/* 强制计算时间段 */}
|
||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||
<InputLabel>计算时间段</InputLabel>
|
||||
<Select
|
||||
value={calculatedInterval}
|
||||
label="强制计算时间段"
|
||||
onChange={handleCalculatedIntervalChange}
|
||||
>
|
||||
{resolvedCalculatedIntervalOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{canRunSimulation && (
|
||||
<Box sx={{ display: "flex", gap: 1 }} className="ml-4">
|
||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||
<InputLabel>计算时间段</InputLabel>
|
||||
<Select
|
||||
value={calculatedInterval}
|
||||
label="强制计算时间段"
|
||||
onChange={handleCalculatedIntervalChange}
|
||||
>
|
||||
{resolvedCalculatedIntervalOptions.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 功能按钮 */}
|
||||
<Tooltip title="强制计算">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={handleForceCalculate}
|
||||
disabled={isCalculating}
|
||||
>
|
||||
强制计算
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Tooltip title="强制计算">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={handleForceCalculate}
|
||||
disabled={isCalculating}
|
||||
>
|
||||
强制计算
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 当前时间显示 */}
|
||||
<Typography
|
||||
|
||||
@@ -29,6 +29,8 @@ import { useStyleEditor } from "./useStyleEditor";
|
||||
import { config, NETWORK_NAME } from "@/config/config";
|
||||
import { useProject } from "@/contexts/ProjectContext";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { permissionCodes } from "@/lib/permissions";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
|
||||
// 添加接口定义隐藏按钮的props
|
||||
interface ToolbarProps {
|
||||
@@ -94,6 +96,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
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<string[]>([]);
|
||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||
@@ -119,6 +124,15 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
}
|
||||
}, [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<ToolbarProps> = ({
|
||||
buildFeatureProperties(
|
||||
selectedFeature,
|
||||
computedProperties,
|
||||
selectedValveId
|
||||
canEditNetwork && selectedValveId
|
||||
? {
|
||||
value: valveStatus,
|
||||
loading: isValveStatusLoading,
|
||||
@@ -705,7 +719,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onSave: handleValveStatusSave,
|
||||
}
|
||||
: undefined,
|
||||
selectedValveId
|
||||
canEditNetwork && selectedValveId
|
||||
? {
|
||||
value: valveProperties.setting,
|
||||
vType: selectedValveType,
|
||||
@@ -719,6 +733,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
[
|
||||
selectedFeature,
|
||||
computedProperties,
|
||||
canEditNetwork,
|
||||
selectedValveId,
|
||||
valveStatus,
|
||||
valveProperties,
|
||||
@@ -755,7 +770,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
onClick={() => handleToolClick("history")}
|
||||
/>
|
||||
)}
|
||||
{!hiddenButtons?.includes("draw") && (
|
||||
{canEditNetwork && !hiddenButtons?.includes("draw") && (
|
||||
<ToolbarButton
|
||||
icon={<EditOutlinedIcon />}
|
||||
name="标记绘制"
|
||||
|
||||
@@ -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 (
|
||||
<ProjectSelector
|
||||
open={true}
|
||||
onClose={
|
||||
canManageEnvironment ? () => router.push("/system-admin") : undefined
|
||||
}
|
||||
onSelect={(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